mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 22:43:47 -10:00
Compare commits
1049
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27c3559421 | ||
|
|
a91711afaf | ||
|
|
4df2461b65 | ||
|
|
35540bda73 | ||
|
|
f66e6a944e | ||
|
|
26547a5f29 | ||
|
|
41c2c2205c | ||
|
|
43134f4e41 | ||
|
|
85c8276e06 | ||
|
|
95bf2811fc | ||
|
|
cf62fe270b | ||
|
|
b784b15b8a | ||
|
|
38b508e5a7 | ||
|
|
de24a16ef4 | ||
|
|
9b53749263 | ||
|
|
cdccd03c7d | ||
|
|
27943eb6c7 | ||
|
|
28fbc7ff47 | ||
|
|
33ced209a0 | ||
|
|
8155baa6f3 | ||
|
|
423a87cf68 | ||
|
|
ff8a13c99a | ||
|
|
0e1fcaa3cc | ||
|
|
1b45824beb | ||
|
|
a5053a898c | ||
|
|
980807b0f5 | ||
|
|
610c2f3c10 | ||
|
|
7afb41a128 | ||
|
|
f6e96e484c | ||
|
|
ce64c617e9 | ||
|
|
1cfb4def89 | ||
|
|
b2fd94f49f | ||
|
|
b8eac21b6f | ||
|
|
fad711f55f | ||
|
|
4bc514829c | ||
|
|
b8ae7233e0 | ||
|
|
5001e3f57d | ||
|
|
f731ca1737 | ||
|
|
b124beafeb | ||
|
|
c7f448c523 | ||
|
|
c7937c5c20 | ||
|
|
ed652da572 | ||
|
|
b28797c534 | ||
|
|
42ecf0f673 | ||
|
|
cf2e9d1bc0 | ||
|
|
f8062e8fee | ||
|
|
7f760b8eb3 | ||
|
|
8baf3cad9e | ||
|
|
05f36b054c | ||
|
|
d89a8025f9 | ||
|
|
674596103f | ||
|
|
43d8bd01cd | ||
|
|
18156172a3 | ||
|
|
4c49c45a04 | ||
|
|
7bd8747909 | ||
|
|
40e66ea972 | ||
|
|
0acee0a978 | ||
|
|
324239ea84 | ||
|
|
93c4acf169 | ||
|
|
2f8c781f04 | ||
|
|
3cdd63e01d | ||
|
|
aace479a0c | ||
|
|
d54cab9565 | ||
|
|
a3c05538ca | ||
|
|
fddd6b3864 | ||
|
|
ee217acc19 | ||
|
|
18510648ea | ||
|
|
f65a4e45cf | ||
|
|
28c105fa9b | ||
|
|
2cd77f6d49 | ||
|
|
a045782e04 | ||
|
|
c5f0414a0a | ||
|
|
2c82bbee6a | ||
|
|
0529ae8250 | ||
|
|
1c876168bd | ||
|
|
48a3d8d175 | ||
|
|
f8040cb977 | ||
|
|
1af3b2ebd9 | ||
|
|
1e8396db70 | ||
|
|
6a031aa651 | ||
|
|
98d53642fa | ||
|
|
60bcab5d61 | ||
|
|
c083b9ed02 | ||
|
|
1ada2e33c8 | ||
|
|
2a6bf0fae5 | ||
|
|
5f14c125ed | ||
|
|
152afb5529 | ||
|
|
3972efa333 | ||
|
|
4c348837e4 | ||
|
|
521a761745 | ||
|
|
bf11ca8ac9 | ||
|
|
df08ffda39 | ||
|
|
7d3aeb0315 | ||
|
|
d4b7ef198c | ||
|
|
6b011e436d | ||
|
|
d98450a575 | ||
|
|
d860b9bfc0 | ||
|
|
7f4ae40cc5 | ||
|
|
7a53c8833b | ||
|
|
f8e7517393 | ||
|
|
42cbbb565e | ||
|
|
4c7ada0d60 | ||
|
|
1788bf8134 | ||
|
|
8d2b298253 | ||
|
|
9914a8af41 | ||
|
|
9972feb710 | ||
|
|
e776eb1716 | ||
|
|
78e635fe3e | ||
|
|
5e582420ed | ||
|
|
3ff797d73d | ||
|
|
1d52fe8101 | ||
|
|
2487d29318 | ||
|
|
3b8a8eeb91 | ||
|
|
063b6ee2b7 | ||
|
|
4414c3c1af | ||
|
|
6cf5b046fa | ||
|
|
d6ecd23a6e | ||
|
|
12a8782268 | ||
|
|
028f608816 | ||
|
|
0f8f8fce11 | ||
|
|
18b317186a | ||
|
|
708db4627e | ||
|
|
e93299fb80 | ||
|
|
e12d4c9d07 | ||
|
|
505d17a425 | ||
|
|
2d89e9ae5f | ||
|
|
14470343d1 | ||
|
|
9d9389310d | ||
|
|
8649edaf16 | ||
|
|
3a8706d0d7 | ||
|
|
0762f49f20 | ||
|
|
d980703786 | ||
|
|
efd7b94c15 | ||
|
|
9f2e4485ad | ||
|
|
bbd5c1c833 | ||
|
|
639ac0e4c6 | ||
|
|
76e3a0f7b7 | ||
|
|
47e6541731 | ||
|
|
361020fc23 | ||
|
|
417ee432c5 | ||
|
|
accd2718ac | ||
|
|
854bcb158f | ||
|
|
1bd74d1b2f | ||
|
|
967a9363ce | ||
|
|
ed10d376fa | ||
|
|
8d5424408c | ||
|
|
99ce9afd63 | ||
|
|
21ac444f28 | ||
|
|
7efd8ef33a | ||
|
|
c6633ea1bb | ||
|
|
6c0e262aa0 | ||
|
|
1e7e2ffb27 | ||
|
|
c9990e43e9 | ||
|
|
79eddec09c | ||
|
|
0ff1df58a2 | ||
|
|
58beb98327 | ||
|
|
96f84abfeb | ||
|
|
0879f3dbd2 | ||
|
|
27af1879d7 | ||
|
|
096814ade9 | ||
|
|
d177ad48e5 | ||
|
|
21123aaa3c | ||
|
|
65096c5b2e | ||
|
|
9948e9d3ee | ||
|
|
3c3884ff38 | ||
|
|
30c58b6591 | ||
|
|
fd2265dc19 | ||
|
|
84dcd156f8 | ||
|
|
5ca6bd7de6 | ||
|
|
2a6b4c0ae6 | ||
|
|
d5aa3f6e42 | ||
|
|
fbc7c628f6 | ||
|
|
c20565ee49 | ||
|
|
4e9c68e3da | ||
|
|
712144297a | ||
|
|
651ac2cfd5 | ||
|
|
2853015f0e | ||
|
|
0914229800 | ||
|
|
e350b87b3c | ||
|
|
6567f95aa3 | ||
|
|
1361ae8338 | ||
|
|
d4412d160f | ||
|
|
38ddc48cab | ||
|
|
ef0d62e47e | ||
|
|
6f4b1bf4c2 | ||
|
|
691341b2b0 | ||
|
|
83979b38d6 | ||
|
|
b189f1b44c | ||
|
|
958db5f40f | ||
|
|
6b9d3309cc | ||
|
|
c5d737b536 | ||
|
|
57fe750035 | ||
|
|
3849e37393 | ||
|
|
9b091ed657 | ||
|
|
777e0e2eb5 | ||
|
|
04ecdee7a2 | ||
|
|
6ddc3226d4 | ||
|
|
1903865cd5 | ||
|
|
d5d54a8775 | ||
|
|
c27e2d2c2d | ||
|
|
d463d793a9 | ||
|
|
f10425675c | ||
|
|
aa8ab4253d | ||
|
|
fa56070097 | ||
|
|
dd5863edc2 | ||
|
|
37cabdcb02 | ||
|
|
a40ef4eee0 | ||
|
|
914a6956e9 | ||
|
|
698b49e3a5 | ||
|
|
ec067f1e39 | ||
|
|
6081a66629 | ||
|
|
ee69413a68 | ||
|
|
fc4e7af854 | ||
|
|
0e069c31d7 | ||
|
|
fbbff39e07 | ||
|
|
49c2000de3 | ||
|
|
f561d4845b | ||
|
|
d6c4e5a027 | ||
|
|
ad271604af | ||
|
|
9e6cd2ed70 | ||
|
|
a813c4a755 | ||
|
|
e8e7254710 | ||
|
|
639100ac95 | ||
|
|
9cb12d984b | ||
|
|
b211519e79 | ||
|
|
c4a39757a1 | ||
|
|
23d7e03a5b | ||
|
|
9a8e0e36a9 | ||
|
|
d68895d0b1 | ||
|
|
7690ad27c2 | ||
|
|
59ed6869e3 | ||
|
|
1ac4bcbe9b | ||
|
|
9b487bdfe9 | ||
|
|
1ec2b888aa | ||
|
|
dbb396e599 | ||
|
|
74da2fe6b7 | ||
|
|
966c38092d | ||
|
|
1e3a9d53a7 | ||
|
|
e5cd63b608 | ||
|
|
06522c11db | ||
|
|
6d4b5c345f | ||
|
|
8dcb4289a9 | ||
|
|
6df1dfd4da | ||
|
|
98ec18fcfe | ||
|
|
be0a93271f | ||
|
|
683ff6f73a | ||
|
|
7bdfe942a4 | ||
|
|
0ea243a1c7 | ||
|
|
9dd27667f2 | ||
|
|
8709026be3 | ||
|
|
2af3308892 | ||
|
|
a791368257 | ||
|
|
2b910fa16d | ||
|
|
af548f5ae8 | ||
|
|
1e741d1528 | ||
|
|
8fad68ab3e | ||
|
|
32c8def114 | ||
|
|
4483c2613c | ||
|
|
7c1055df97 | ||
|
|
d25648f729 | ||
|
|
70dd250ae6 | ||
|
|
c6276dbbde | ||
|
|
a0b4721c5b | ||
|
|
d136f966f8 | ||
|
|
1f457ff81e | ||
|
|
9663d6f53e | ||
|
|
1bddc392b2 | ||
|
|
3b19c386cf | ||
|
|
4a8cb8c4f9 | ||
|
|
6ef20dc9af | ||
|
|
e219a40ebc | ||
|
|
64ca4d485f | ||
|
|
66b9b3323c | ||
|
|
24d06f5ce2 | ||
|
|
19de683b66 | ||
|
|
ed8418a2ac | ||
|
|
c33a2a5d19 | ||
|
|
9b49a0b727 | ||
|
|
1b42652e96 | ||
|
|
1400da9637 | ||
|
|
62794a3fde | ||
|
|
c17da16907 | ||
|
|
62c5a6d27b | ||
|
|
9a9de6a936 | ||
|
|
fe9b20733a | ||
|
|
50f9255472 | ||
|
|
70181252d8 | ||
|
|
dc3762b533 | ||
|
|
4de85f000d | ||
|
|
4de42d0dc4 | ||
|
|
747c2031f1 | ||
|
|
14353e67d4 | ||
|
|
8027bda6bf | ||
|
|
0c5e30ae5e | ||
|
|
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 | ||
|
|
ee49ec5071 | ||
|
|
d89e27113f | ||
|
|
e6b92995f3 | ||
|
|
f08a564fa6 | ||
|
|
7262304a6d | ||
|
|
3094896ad1 | ||
|
|
1d2c5db426 | ||
|
|
964fbe1c29 | ||
|
|
9f0c201851 | ||
|
|
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 | ||
|
|
eb03afb3d4 | ||
|
|
07e317d350 | ||
|
|
4023708865 | ||
|
|
dc5d571b64 | ||
|
|
7d80fe24f2 | ||
|
|
212a7ec198 | ||
|
|
6fb56a9190 | ||
|
|
7b0db2d5ec | ||
|
|
2e73331cf6 | ||
|
|
d4137afb8c | ||
|
|
217a780f28 | ||
|
|
92167dab3c | ||
|
|
aea024d615 | ||
|
|
9d19e22efe | ||
|
|
1d878f41fd | ||
|
|
a541cbb77b | ||
|
|
905fd0d193 | ||
|
|
6a56f7e39f | ||
|
|
88b3b3e879 | ||
|
|
2e5e61600a | ||
|
|
517c54c427 | ||
|
|
48d95663d6 | ||
|
|
3c53df3100 | ||
|
|
ef54b481a7 | ||
|
|
22d72feae8 | ||
|
|
bd1f66f5ca | ||
|
|
df7103ef02 | ||
|
|
23db3397a7 | ||
|
|
d713ec4c92 | ||
|
|
3a5dd71152 | ||
|
|
1e9eca2dcf | ||
|
|
7f7e591c8a | ||
|
|
f41f360288 | ||
|
|
b8fdda0de9 | ||
|
|
3e22b6e383 | ||
|
|
2c783d099a | ||
|
|
f2070cef54 | ||
|
|
648060047c | ||
|
|
92f33c89fb | ||
|
|
bcb539057d | ||
|
|
e2b35bc827 | ||
|
|
d9b6483aa2 | ||
|
|
e314c2fa81 | ||
|
|
e8c1d1219d | ||
|
|
8601d34f20 | ||
|
|
ac10eff413 | ||
|
|
59d6b525ce | ||
|
|
d812712ded | ||
|
|
d24f9f5949 | ||
|
|
167b18d7d0 | ||
|
|
ce94056986 | ||
|
|
d50a5e1f64 | ||
|
|
3c85d2558b | ||
|
|
a05dc35c39 | ||
|
|
5d68512994 | ||
|
|
377fecbf79 | ||
|
|
a23a7ec35a | ||
|
|
50cd5576f9 | ||
|
|
07e935d0bf | ||
|
|
12e0094f1f | ||
|
|
1586018c65 | ||
|
|
8457b35d8a | ||
|
|
830b6890e4 | ||
|
|
0e793be2ac | ||
|
|
324a170a84 | ||
|
|
84fc738ae5 | ||
|
|
5fac4f0f34 | ||
|
|
86d90c38a9 | ||
|
|
d65bbfc30f | ||
|
|
784b586757 | ||
|
|
71a1181b10 | ||
|
|
45b80deb02 | ||
|
|
4ae877ab3b | ||
|
|
8aeb393c54 | ||
|
|
5cf1da78e8 | ||
|
|
2779c481ad | ||
|
|
932a2215fc | ||
|
|
ae713f7bd8 | ||
|
|
cb574cdfdc | ||
|
|
b6ee9b1de8 | ||
|
|
107a25bee3 | ||
|
|
9ba8223b40 | ||
|
|
345650c697 | ||
|
|
8f8aecd2c8 | ||
|
|
7495681901 | ||
|
|
3c820a0751 | ||
|
|
a1fa0149ff | ||
|
|
1d018614ac | ||
|
|
653e3a4ee8 | ||
|
|
fd915f8a5d | ||
|
|
0504663c41 | ||
|
|
9203f21b99 | ||
|
|
167c3d14bd | ||
|
|
152fe2bb0a | ||
|
|
6fd328c602 | ||
|
|
5c5ede59c1 | ||
|
|
9dda8e0678 | ||
|
|
42c653be00 | ||
|
|
6b5963d111 | ||
|
|
9ac405e9cc | ||
|
|
903b427006 | ||
|
|
2ce21900ae | ||
|
|
03cbb48fa4 | ||
|
|
1284f97df6 | ||
|
|
42cff7bbc2 | ||
|
|
604025a53b | ||
|
|
861ef16307 | ||
|
|
66294b102a | ||
|
|
5f98d0edfe | ||
|
|
523280e781 | ||
|
|
8648470c47 | ||
|
|
1a0b4ea7e3 | ||
|
|
6330080280 | ||
|
|
3738d8a668 | ||
|
|
c1a7b8ab9f | ||
|
|
28c78e8da8 | ||
|
|
eb664d50c1 | ||
|
|
e42ec2360c | ||
|
|
1e5ee00bcb | ||
|
|
b338c4e7c2 | ||
|
|
d53a0adb41 | ||
|
|
91d84ba3c2 | ||
|
|
4234918255 | ||
|
|
56b6229ef4 | ||
|
|
b7d802d7a6 | ||
|
|
cb246e7528 | ||
|
|
05d150349f | ||
|
|
712306d3b9 | ||
|
|
ea5f288a1f | ||
|
|
db228099ab | ||
|
|
fed7ea2275 | ||
|
|
9ce9282b21 | ||
|
|
2da8d6bf28 | ||
|
|
5b435bb03a | ||
|
|
a44d5c81a2 | ||
|
|
101d4689f6 | ||
|
|
c7c890dd69 | ||
|
|
62b8f33671 | ||
|
|
48fd9f4ea5 | ||
|
|
e42a82e9b9 | ||
|
|
d77569c672 | ||
|
|
937dcba5b8 | ||
|
|
e3f6f9d4dc | ||
|
|
0f581c669e | ||
|
|
d8b49e8697 | ||
|
|
71b8f39eb5 | ||
|
|
36f6ab9839 | ||
|
|
d53114fa82 | ||
|
|
d3b28edfb6 | ||
|
|
9b5c36e232 | ||
|
|
9c49424b4d | ||
|
|
f4271d7c16 | ||
|
|
6a65ee1124 | ||
|
|
ad70f755d7 | ||
|
|
d2ca443d6f | ||
|
|
716cc7f102 | ||
|
|
64279fa1ce | ||
|
|
78dba05d8f | ||
|
|
c35436a7da | ||
|
|
0e927e6f11 | ||
|
|
2eb96ddb7c | ||
|
|
97dd6d982a | ||
|
|
db0a33da3f | ||
|
|
393b8ef7e6 | ||
|
|
b5ec5a782f | ||
|
|
802368fb95 | ||
|
|
5815560b0b | ||
|
|
15be176380 | ||
|
|
8905aaf0dd | ||
|
|
4d897de20b | ||
|
|
b463f1c80a | ||
|
|
a8ccf43d2b | ||
|
|
6964ee8ece | ||
|
|
bbc1ffb408 | ||
|
|
d966b1e59b | ||
|
|
4c78d32e36 | ||
|
|
fc06585938 | ||
|
|
54ab5dcf00 | ||
|
|
e5e1b74fd8 | ||
|
|
4516f54e2b | ||
|
|
9809bf38b6 | ||
|
|
5ea88533e0 | ||
|
|
38cd3095e5 | ||
|
|
95eb81bfe5 | ||
|
|
c752c21246 | ||
|
|
594394ab30 | ||
|
|
82055fb57d | ||
|
|
c3ef0a06e7 | ||
|
|
bcf2a34a10 | ||
|
|
407053f37c | ||
|
|
531212c812 | ||
|
|
30027e32a6 | ||
|
|
2e9827a259 | ||
|
|
739d4fe187 | ||
|
|
f8582fb678 | ||
|
|
9777b525b7 | ||
|
|
24744aae44 | ||
|
|
97d119b4e8 | ||
|
|
2f6d259615 | ||
|
|
6c769f38ae | ||
|
|
475f64b79d | ||
|
|
bca4513187 | ||
|
|
9c3058f756 | ||
|
|
9ca282df1e | ||
|
|
28b72d46c5 | ||
|
|
cece5aca30 | ||
|
|
713de31f64 | ||
|
|
e2a48d1c36 | ||
|
|
b22c42ea85 | ||
|
|
ef0ea5daad | ||
|
|
a21cf57a11 | ||
|
|
9142f57bc8 | ||
|
|
6c36da40e0 | ||
|
|
57213668a0 | ||
|
|
584fa2a9b5 | ||
|
|
306c6625e9 | ||
|
|
46f674f842 | ||
|
|
dca3bb89fe | ||
|
|
e74a1fe461 | ||
|
|
581828bf94 | ||
|
|
2c0d3c1a86 | ||
|
|
6c09ec2cc8 | ||
|
|
4940a24423 | ||
|
|
62556429dc | ||
|
|
5435eb0f17 | ||
|
|
765cef8a4e | ||
|
|
370444eed0 | ||
|
|
29616ad502 | ||
|
|
79fca90886 | ||
|
|
f918fc711a | ||
|
|
cc0f8f8a50 | ||
|
|
eeceb9c2de | ||
|
|
c4427d2a14 | ||
|
|
2a1132387f | ||
|
|
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 | ||
|
|
0ee20da35c | ||
|
|
b7749dd0d2 | ||
|
|
ddfa71a46f | ||
|
|
18b544770f | ||
|
|
620dfc6e0d | ||
|
|
03cc30ae45 | ||
|
|
80e28dfe63 | ||
|
|
27f73e1db3 | ||
|
|
36c18fd8ea | ||
|
|
c3a12bf792 | ||
|
|
75bcbce441 | ||
|
|
c352c80c96 | ||
|
|
2f489f54c5 | ||
|
|
9ab0e2b6a8 | ||
|
|
498de72da2 | ||
|
|
7250944a7c | ||
|
|
04672c9613 | ||
|
|
76bebec833 | ||
|
|
fa4f38f824 | ||
|
|
9cebcc8d7f | ||
|
|
ac33fb74ec | ||
|
|
14dac749e3 | ||
|
|
10d0132a53 | ||
|
|
209fa169cd | ||
|
|
17fa6e826c | ||
|
|
66bec37b55 | ||
|
|
0fe747ad51 | ||
|
|
5be7c02d0c | ||
|
|
e11471cda0 | ||
|
|
731a35612a | ||
|
|
5ac1c82eb9 | ||
|
|
e184ab7a42 | ||
|
|
a934dac2bd | ||
|
|
6384234e37 | ||
|
|
3caf28c4d6 | ||
|
|
87d577e566 | ||
|
|
b5794fa5b3 | ||
|
|
4c9075a072 | ||
|
|
7ae20a5b29 | ||
|
|
4e82a62489 | ||
|
|
67b6e75c0b | ||
|
|
8909e0510c | ||
|
|
9ada76e00a | ||
|
|
2c894783aa | ||
|
|
9266e0c7c9 | ||
|
|
a68ee8009f | ||
|
|
ef0714a99a | ||
|
|
909f15b909 | ||
|
|
da57350af5 | ||
|
|
bf64f868d1 | ||
|
|
66607c9eab | ||
|
|
0d496fdc8f | ||
|
|
4cad82b4a9 | ||
|
|
8a5d7bb78d | ||
|
|
400d9e6115 | ||
|
|
140958fa58 | ||
|
|
04b0b9aed5 | ||
|
|
5e27fd3421 | ||
|
|
e544924504 | ||
|
|
29754b857d | ||
|
|
8144385ce6 | ||
|
|
11e1ff12d0 | ||
|
|
26aef75d7c | ||
|
|
25c53cc803 | ||
|
|
43e407562f | ||
|
|
348054d26e | ||
|
|
c05dc8af22 | ||
|
|
a45d0de43e | ||
|
|
688733132d | ||
|
|
9dd6933dab | ||
|
|
87b533c5a9 | ||
|
|
7f6e47b02e | ||
|
|
7fdd57ad1e | ||
|
|
f6db8b1f56 | ||
|
|
dca96dd1df | ||
|
|
e0e484e44b | ||
|
|
b3e1542e09 | ||
|
|
57e8de0007 | ||
|
|
861e7279e0 | ||
|
|
7673d38ed7 | ||
|
|
426b3c83cf | ||
|
|
f2856845f4 | ||
|
|
1d93086e33 | ||
|
|
00e15171d5 | ||
|
|
960b8aabad | ||
|
|
3f1181bab4 | ||
|
|
1596b84299 | ||
|
|
190cf4e0c2 | ||
|
|
cb488cf515 | ||
|
|
a146754b0b | ||
|
|
bafd010865 | ||
|
|
5f73d0d02d | ||
|
|
024415e09d | ||
|
|
8f78fdeb7b | ||
|
|
6977659843 | ||
|
|
d2a8cf3bdf | ||
|
|
ed9fdb5208 | ||
|
|
2929bb6dba | ||
|
|
6120c8c0ad | ||
|
|
53ea3e7224 | ||
|
|
462787aee1 | ||
|
|
b90f9ec1ef | ||
|
|
e9797d69d0 | ||
|
|
4b4361fbc0 | ||
|
|
b0ce58adea | ||
|
|
77fe538e09 | ||
|
|
d8b8246622 | ||
|
|
35a02c79a8 | ||
|
|
db985f717c | ||
|
|
e41c602359 | ||
|
|
6145077d7e | ||
|
|
512e003cbd | ||
|
|
daee960ba2 | ||
|
|
b33ae69938 | ||
|
|
2a297a43eb | ||
|
|
a529be8915 | ||
|
|
7eaa5f592b | ||
|
|
be714b38bb | ||
|
|
1a5e5eb358 | ||
|
|
951b88a3c2 | ||
|
|
d75ef5e4f7 | ||
|
|
414a2b82a8 | ||
|
|
da736d56d6 | ||
|
|
9958fe0e49 | ||
|
|
fba004eb3d | ||
|
|
2d70e55fbb | ||
|
|
d0df238972 | ||
|
|
cdafcd756f | ||
|
|
3fe301aaa1 | ||
|
|
d97b58fd4a | ||
|
|
dca6ebba0e | ||
|
|
6af81b600f | ||
|
|
851789830a | ||
|
|
e872d3ae54 | ||
|
|
317f30427e | ||
|
|
91f1f839ab | ||
|
|
1c6c6c0b97 | ||
|
|
a1761dcb65 | ||
|
|
cf6b91a710 | ||
|
|
fbdd04d817 | ||
|
|
aed4e26f59 | ||
|
|
b5adad15eb | ||
|
|
d17df3ca05 | ||
|
|
2d72e987c5 | ||
|
|
9ae3882c1a | ||
|
|
89c195fa6e | ||
|
|
de808e3612 | ||
|
|
4575f4f1df | ||
|
|
54b1c4f941 | ||
|
|
8a309c393d | ||
|
|
58a99a2ce9 | ||
|
|
2b2aecd975 | ||
|
|
23019a7bf9 | ||
|
|
8aad511579 | ||
|
|
94600b266f | ||
|
|
78a5ee0e55 | ||
|
|
63ee634ee0 | ||
|
|
752138e866 | ||
|
|
c30d51d65e | ||
|
|
c439b806b8 | ||
|
|
50fdcad10f | ||
|
|
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 | ||
|
|
a14da6bb70 | ||
|
|
10872bdd64 | ||
|
|
fc8e505c95 | ||
|
|
3114fc66e9 | ||
|
|
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 | ||
|
|
a07c6d57dc | ||
|
|
3c94b5441e | ||
|
|
5b89508fe9 | ||
|
|
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 | ||
|
|
5589edb118 | ||
|
|
a6d2eb9d64 | ||
|
|
d18197a176 | ||
|
|
0300ad8922 | ||
|
|
7a3a19697a | ||
|
|
e8ae26c270 | ||
|
|
5179f97582 | ||
|
|
e50eb7c71f | ||
|
|
b62095dfcb | ||
|
|
ba3b567f54 | ||
|
|
5d3e9d218d | ||
|
|
36ef05b0b9 | ||
|
|
49ad61035b | ||
|
|
3842d851d8 | ||
|
|
c987778e97 | ||
|
|
b28108128d | ||
|
|
c50557ea0b | ||
|
|
f364c4f9c6 | ||
|
|
9841ce7824 | ||
|
|
83a6f5c7b9 | ||
|
|
18f4dd92ae | ||
|
|
47654b62a3 | ||
|
|
2f3dade42f | ||
|
|
af49fd7dc3 | ||
|
|
af6faa88e5 | ||
|
|
e71d4281ac | ||
|
|
775e5b19cb | ||
|
|
f56bf1ba38 | ||
|
|
ca119bb069 | ||
|
|
26626e8c27 | ||
|
|
7102727197 | ||
|
|
70868f3208 | ||
|
|
aff673a08d | ||
|
|
2e642857fc | ||
|
|
147e5269c7 | ||
|
|
b3a499d85d | ||
|
|
3c013dac75 | ||
|
|
b913dab5e5 | ||
|
|
e87e839c97 | ||
|
|
8c8f857313 | ||
|
|
9c427dfae0 | ||
|
|
13144a82a1 | ||
|
|
ef711f8b1f | ||
|
|
601e7918a8 | ||
|
|
dd3697bb4d | ||
|
|
273729aac8 | ||
|
|
94592a35ac | ||
|
|
728bf14aa7 | ||
|
|
53501ab94e | ||
|
|
581dc08cf5 | ||
|
|
85a6ce0921 | ||
|
|
1b4fe0f067 | ||
|
|
fd0f09d6f6 | ||
|
|
06fb03567e | ||
|
|
c418034f10 | ||
|
|
c273a7271e | ||
|
|
5b9a66089e | ||
|
|
4c72a3a44f | ||
|
|
8691d9a8e8 | ||
|
|
d2105fe66c | ||
|
|
79bfb8fcb8 | ||
|
|
20630b1230 | ||
|
|
8d77913ea5 | ||
|
|
66637220ef | ||
|
|
5fa423169f | ||
|
|
183382cd2b | ||
|
|
84aba7b3d4 | ||
|
|
329ce1b724 | ||
|
|
e2a399aff0 | ||
|
|
2a0824cf5f | ||
|
|
a4d0f76edd | ||
|
|
00e2cfec29 | ||
|
|
a5fa40d2ee | ||
|
|
cc39d57e07 | ||
|
|
0a5e80de7b | ||
|
|
b7c47ccbf9 | ||
|
|
9c3450622b | ||
|
|
3615c65d18 | ||
|
|
7cc8def23f | ||
|
|
e553db9bb5 | ||
|
|
790b191ed1 | ||
|
|
d136dda092 | ||
|
|
27d750f1c9 | ||
|
|
a8205dd80d | ||
|
|
f252941ac1 | ||
|
|
30fa41ddbd | ||
|
|
e2781f7939 | ||
|
|
2f6f0e3e0f | ||
|
|
d338bcd070 | ||
|
|
8697f3fd3f | ||
|
|
6bc8a8b889 | ||
|
|
4825e537cc | ||
|
|
2e91bd84e9 | ||
|
|
5c0f7142f3 | ||
|
|
5d72fdd8c2 | ||
|
|
3287a74d64 | ||
|
|
946f47b3ec | ||
|
|
09078949d7 | ||
|
|
265965445d | ||
|
|
f5f7448909 | ||
|
|
9e3956282a | ||
|
|
408a75aeb3 | ||
|
|
19a5098b14 | ||
|
|
a7ff22eea0 |
No files matched your search
@@ -0,0 +1,7 @@
|
||||
# Trusted reviewers: they pick the pull requests the Nightly holds with `/nightly 2640 2642` in any issue or pull
|
||||
# request (docs/release-flow.md "Nightly channel", tools/ci/nightly_set.py). Ludovic is always allowed and isn't
|
||||
# listed. One GitHub login per line, with who added it and when. Removing a line revokes that login at once.
|
||||
# A line with only `paused` stops every reviewer's `/nightly` (Ludovic's still work).
|
||||
kacpherek # added by Ludovic, 2026-10-27
|
||||
rdbtCVS # added by Ludovic, 2026-10-27
|
||||
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
|
||||
@@ -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,237 @@
|
||||
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"
|
||||
- name: Old nightly/build-* branches deleted (scheduled runs only)
|
||||
# Ludovic, 2026-10-27: older than 7 days, but never the newest build or one a published nightly points to;
|
||||
# nothing but nightly/build-* (nightly.py stale_builds, tests/test_nightly.py).
|
||||
if: github.event_name == 'schedule'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
{ echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nightly-notes
|
||||
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]
|
||||
|
||||
@@ -35,7 +35,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
|
||||
| Crate | Owns |
|
||||
|---|---|
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~180 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~170 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
|
||||
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
|
||||
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
|
||||
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
|
||||
@@ -120,7 +121,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
|
||||
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
|
||||
|
||||
Generated
+19
@@ -1385,6 +1385,7 @@ dependencies = [
|
||||
"horizon-dualsense",
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-mods",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
@@ -1405,6 +1406,7 @@ dependencies = [
|
||||
"sha1",
|
||||
"sha2",
|
||||
"symphonia",
|
||||
"unicode-bidi",
|
||||
"ureq",
|
||||
"wgpu",
|
||||
"winit",
|
||||
@@ -1468,6 +1470,17 @@ dependencies = [
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-items"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-client-core",
|
||||
"horizon-core",
|
||||
"horizon-world",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-sdk"
|
||||
version = "0.1.0"
|
||||
@@ -3774,6 +3787,12 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
|
||||
@@ -9,6 +9,7 @@ members = [
|
||||
"crates/horizon-client",
|
||||
"crates/horizon-client-core",
|
||||
"crates/horizon-command",
|
||||
"crates/horizon-items",
|
||||
"crates/horizon-server-files",
|
||||
"crates/horizon-server",
|
||||
"crates/horizon-bots",
|
||||
|
||||
@@ -31,7 +31,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
|
||||
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
|
||||
|
||||
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)); Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -47,7 +47,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
|
||||
@@ -158,7 +158,7 @@ them too (Rust uses Java's own float maths on each platform).
|
||||
|
||||
### Known issues
|
||||
|
||||
Horizon can't sign in to a Microsoft account yet, so it can't join online-mode servers (it joins offline-mode ones); skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
|
||||
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Text components from commands: `ComponentArgument` reads an SNBT (or JSON)
|
||||
//! value, `ComponentSerialization` turns it into styled text.
|
||||
use crate::text::{Style, Text, named_color};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// `SnbtGrammar`, enough for components: compounds, lists, quoted and
|
||||
@@ -173,20 +174,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
|
||||
@@ -271,6 +300,15 @@ pub fn to_text_with(
|
||||
}
|
||||
}
|
||||
|
||||
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
|
||||
/// else is a component (`ComponentSerialization`).
|
||||
pub fn name_text(tag: &Tag) -> Text {
|
||||
match tag {
|
||||
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
|
||||
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
//! language used by every screen.
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
sync::{Arc, RwLock},
|
||||
sync::{
|
||||
Arc, RwLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -23,11 +26,16 @@ impl LanguageInfo {
|
||||
#[derive(Default)]
|
||||
pub struct Language {
|
||||
pub code: String,
|
||||
/// `LanguageInfo.bidirectional`: text without a strong character reads
|
||||
/// right to left (`ClientLanguage.defaultRightToLeft`).
|
||||
bidirectional: bool,
|
||||
map: HashMap<String, String>,
|
||||
}
|
||||
pub const DEFAULT: &str = "en_us";
|
||||
|
||||
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::new(None);
|
||||
/// The current language's `bidirectional`, read for every drawn string.
|
||||
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// A source of resource files: the prepared root plus packs, highest priority last.
|
||||
pub trait ResourceSource {
|
||||
@@ -134,8 +142,12 @@ impl Language {
|
||||
{
|
||||
apply_deprecated(&mut map, &bytes);
|
||||
}
|
||||
let bidirectional = available(sources)
|
||||
.iter()
|
||||
.any(|info| info.code == code && info.bidirectional);
|
||||
Self {
|
||||
code: code.to_owned(),
|
||||
bidirectional,
|
||||
map,
|
||||
}
|
||||
}
|
||||
@@ -197,11 +209,16 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
|
||||
}
|
||||
|
||||
pub fn set(language: Language) {
|
||||
BIDIRECTIONAL.store(language.bidirectional, Ordering::Relaxed);
|
||||
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
|
||||
}
|
||||
pub fn current() -> Option<Arc<Language>> {
|
||||
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
/// `ClientLanguage.defaultRightToLeft` of the current language.
|
||||
pub fn bidirectional() -> bool {
|
||||
BIDIRECTIONAL.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn code() -> String {
|
||||
current().map_or_else(|| DEFAULT.to_owned(), |l| l.code.clone())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -13,11 +14,15 @@ pub mod identifier;
|
||||
pub mod key_display;
|
||||
pub mod lang;
|
||||
pub mod nbt_path;
|
||||
pub mod nbt_value;
|
||||
pub mod particle_type;
|
||||
pub mod pattern;
|
||||
pub mod potions;
|
||||
pub mod registries;
|
||||
pub mod scoreboard;
|
||||
pub mod snbt;
|
||||
pub mod sound_source;
|
||||
pub mod text;
|
||||
pub mod uuid;
|
||||
pub mod who;
|
||||
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Reading and writing single NBT values the way vanilla's codecs accept them.
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
pub fn string(s: &str) -> Tag {
|
||||
Tag::String(s.into())
|
||||
}
|
||||
pub fn text(tag: Option<&Tag>) -> Option<String> {
|
||||
match tag {
|
||||
Some(Tag::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Any numeric tag as i64 (vanilla's `getIntOr` etc. accept every number type).
|
||||
pub fn number(tag: Option<&Tag>) -> Option<i64> {
|
||||
Some(match tag? {
|
||||
Tag::Byte(v) => i64::from(*v),
|
||||
Tag::Short(v) => i64::from(*v),
|
||||
Tag::Int(v) => i64::from(*v),
|
||||
Tag::Long(v) => *v,
|
||||
Tag::Float(b) => f32::from_bits(*b) as i64,
|
||||
Tag::Double(b) => f64::from_bits(*b) as i64,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
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()));
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Who an action came from or is aimed at: the player or a mob by id.
|
||||
|
||||
/// Who caused damage or is being targeted.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Who {
|
||||
Player,
|
||||
Mob(u32),
|
||||
}
|
||||
@@ -11,6 +11,7 @@ description = "Horizon native development client"
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
@@ -62,6 +63,7 @@ gilrs = { version = "0.11", optional = true }
|
||||
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
|
||||
# HTTPS to the Minecraft services (online-mode session join).
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
unicode-bidi = "0.3.18"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
|
||||
@@ -104,5 +106,6 @@ path = "examples/probe/main.rs"
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
|
||||
@@ -2,7 +2,7 @@
|
||||
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
|
||||
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
|
||||
#[allow(dead_code)]
|
||||
#[path = "../src/blocks/decode.rs"]
|
||||
#[path = "../../horizon-items/src/blocks/decode.rs"]
|
||||
mod decode;
|
||||
|
||||
use std::{hint::black_box, path::Path, time::Instant};
|
||||
|
||||
@@ -54,7 +54,7 @@ pub fn main() -> Result<()> {
|
||||
match c["axis"].as_i64() {
|
||||
Some(axis @ 0..=2) => {
|
||||
let line = record("electric_spark");
|
||||
particles::along_axis(&mut random, axis as usize, p, |p, v| {
|
||||
particles::along_axis(&mut random, axis as usize, p, (10, 19), |p, v| {
|
||||
lines.push(line(p, v))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ fn stack(spec: &str) -> LootStack {
|
||||
exploration: None,
|
||||
ominous_amplifier: None,
|
||||
stew_effects: Vec::new(),
|
||||
names: Vec::new(),
|
||||
};
|
||||
for e in parts {
|
||||
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
|
||||
|
||||
@@ -223,6 +223,7 @@ fn decorations(c: &Value, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
display_health: int("displayHealth", 0),
|
||||
last_health_time: millis - i64::from(int("lastHealthAgo", 0)),
|
||||
health_blink_time: i64::from(int("healthBlinkTime", 0)),
|
||||
..HealthHud::default()
|
||||
};
|
||||
let kind = if has("minecraft:poison") {
|
||||
HeartKind::Poisoned
|
||||
|
||||
@@ -101,7 +101,7 @@ fn title(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
}
|
||||
let partial = float(c, "partial", 0.0);
|
||||
let bg = background(c);
|
||||
let mut out = t.overlay_message(w, h, partial, &atlas.font, bg);
|
||||
let mut out = t.overlay_message(w, h, 39, partial, &atlas.font, bg);
|
||||
out.extend(t.title(w, h, partial, &atlas.font, bg));
|
||||
out
|
||||
}
|
||||
@@ -129,7 +129,7 @@ fn selected(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
}
|
||||
let mode = c["gameMode"].as_str().unwrap_or("survival");
|
||||
let can_hurt = mode == "survival" || mode == "adventure";
|
||||
highlight.draw(w, h, can_hurt, &atlas.font, background(c))
|
||||
highlight.draw(w, h, 39, can_hurt, &atlas.font, background(c))
|
||||
}
|
||||
|
||||
fn boss(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
|
||||
@@ -74,7 +74,14 @@ pub fn main() -> Result<()> {
|
||||
biomes: &biomes,
|
||||
registry: assets.registry,
|
||||
raining_at: &|_| false,
|
||||
height_no_leaves: &|_| 0,
|
||||
firefly_bush_sounds: false,
|
||||
sky_darken: 0,
|
||||
marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)),
|
||||
model_offset: &|_, _, _| None,
|
||||
gateway_particles: &|_, _| 0,
|
||||
thundering: false,
|
||||
ultrawarm: false,
|
||||
};
|
||||
engine.seed_randoms(1057, 0, 0);
|
||||
engine.requested = Some(Vec::new());
|
||||
|
||||
@@ -567,7 +567,13 @@ fn print_heart(
|
||||
/// The damage of a mob's direct hit on the player (others count as none).
|
||||
fn player_damage(e: &MobEvent) -> f32 {
|
||||
match e {
|
||||
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount,
|
||||
MobEvent::AttackPlayer(amount, _)
|
||||
| MobEvent::AttackPlayerWithEffect { amount, .. }
|
||||
| MobEvent::DamagePlayer { amount, .. } => *amount,
|
||||
MobEvent::SonicBoom {
|
||||
target: Who::Player,
|
||||
..
|
||||
} => 10.0,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
@@ -578,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,22 +1057,29 @@ 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,
|
||||
allies: Default::default(),
|
||||
remote: false,
|
||||
sleeping: false,
|
||||
sleep_timer: 0,
|
||||
passenger: false,
|
||||
}
|
||||
});
|
||||
let mut scratch = LegacyRandom::new(0);
|
||||
@@ -1070,6 +1089,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) => {
|
||||
@@ -1081,6 +1102,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
world: &world,
|
||||
registry,
|
||||
facts: &facts,
|
||||
fluid_tables: &motion.fluids,
|
||||
movement: &motion.blocks,
|
||||
collision: &collision,
|
||||
data: &data,
|
||||
@@ -1098,12 +1120,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!(
|
||||
@@ -353,6 +353,7 @@ pub fn main() -> Result<()> {
|
||||
horizon_world::food::Difficulty::Hard,
|
||||
][op["difficulty"].as_u64().unwrap() as usize],
|
||||
natural_regeneration: op["regenerate"].as_bool().unwrap(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
"food" => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! conditions advancements use).
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
@@ -437,6 +437,8 @@ pub struct EntityView {
|
||||
/// mainhand, offhand, head, chest, legs, feet, body.
|
||||
pub equipment: [Option<ItemView>; 7],
|
||||
pub vehicle: Option<Box<EntityView>>,
|
||||
/// Player: what it looks at within 100 blocks (`PlayerPredicate.lookingAt`).
|
||||
pub looking_at: Option<Box<EntityView>>,
|
||||
pub effects: Vec<(String, i32, i32)>,
|
||||
/// Player: game mode name.
|
||||
pub gamemode: Option<String>,
|
||||
@@ -458,7 +460,7 @@ fn range_f(v: &Value, x: f64) -> bool {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn range_i(v: &Value, x: i64) -> bool {
|
||||
pub fn range_i(v: &Value, x: i64) -> bool {
|
||||
range_f(v, x as f64)
|
||||
}
|
||||
/// A registry id, `#tag`, or list matched against one id.
|
||||
@@ -473,6 +475,51 @@ pub fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// `PotionsPredicate`: the potion in a `HolderSet`, plus a `CollectionPredicate`
|
||||
/// over the potion's effects (`contains`, `count`, `size`).
|
||||
pub fn potions(v: &Value, potion: Option<&str>, w: &dyn World) -> bool {
|
||||
if v.is_null() {
|
||||
return true;
|
||||
}
|
||||
if !v["potions"].is_null() && !potion.is_some_and(|p| id_matches(&v["potions"], "potion", p, w))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let c = &v["effects"];
|
||||
if c.is_null() {
|
||||
return true;
|
||||
}
|
||||
let effects: Vec<(String, i32, i32)> = potion
|
||||
.and_then(crate::brewing::potion)
|
||||
.map(|d| {
|
||||
d.effects
|
||||
.iter()
|
||||
.map(|(id, dur, amp)| (format!("minecraft:{id}"), *amp, *dur))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// `MobEffectsPredicate.test`: the one listed effect, amplifier and duration.
|
||||
let test = |pred: &Value, e: &(String, i32, i32)| {
|
||||
pred.as_object().is_some_and(|m| {
|
||||
m.get(&e.0).is_some_and(|want| {
|
||||
range_i(&want["amplifier"], i64::from(e.1))
|
||||
&& range_i(&want["duration"], i64::from(e.2))
|
||||
&& want["ambient"].as_bool().is_none_or(|b| !b)
|
||||
&& want["visible"].as_bool().is_none_or(|b| b)
|
||||
})
|
||||
})
|
||||
};
|
||||
c["contains"]
|
||||
.as_array()
|
||||
.is_none_or(|l| l.iter().all(|p| effects.iter().any(|e| test(p, e))))
|
||||
&& c["count"].as_array().is_none_or(|l| {
|
||||
l.iter().all(|entry| {
|
||||
let n = effects.iter().filter(|e| test(&entry["test"], e)).count();
|
||||
range_i(&entry["count"], n as i64)
|
||||
})
|
||||
})
|
||||
&& range_i(&c["size"], effects.len() as i64)
|
||||
}
|
||||
/// `ItemPredicate`.
|
||||
pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
|
||||
if v.is_null() {
|
||||
@@ -614,6 +661,18 @@ fn components(expected: &Value, have: &[(String, String)]) -> bool {
|
||||
})
|
||||
})
|
||||
}
|
||||
/// `DistancePredicate.matches` between two points.
|
||||
pub fn distance(v: &Value, a: [f64; 3], b: [f64; 3]) -> bool {
|
||||
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
range_f(&v["x"], d[0].abs())
|
||||
&& range_f(&v["y"], d[1].abs())
|
||||
&& range_f(&v["z"], d[2].abs())
|
||||
&& range_f(&v["horizontal"], (d[0] * d[0] + d[2] * d[2]).sqrt())
|
||||
&& range_f(
|
||||
&v["absolute"],
|
||||
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(),
|
||||
)
|
||||
}
|
||||
/// `EntityPredicate` (26.x keyed sub-predicates).
|
||||
pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> bool {
|
||||
if v.is_null() {
|
||||
@@ -629,21 +688,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
"minecraft:stepping_on" => {
|
||||
location(p, [e.position[0], e.position[1] - 0.5, e.position[2]], w)
|
||||
}
|
||||
"minecraft:distance" | "distance" => {
|
||||
let d = [
|
||||
e.position[0] - origin[0],
|
||||
e.position[1] - origin[1],
|
||||
e.position[2] - origin[2],
|
||||
];
|
||||
range_f(&p["x"], d[0].abs())
|
||||
&& range_f(&p["y"], d[1].abs())
|
||||
&& range_f(&p["z"], d[2].abs())
|
||||
&& range_f(&p["horizontal"], (d[0] * d[0] + d[2] * d[2]).sqrt())
|
||||
&& range_f(
|
||||
&p["absolute"],
|
||||
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(),
|
||||
)
|
||||
}
|
||||
"minecraft:distance" | "distance" => distance(p, e.position, origin),
|
||||
"minecraft:flags" | "flags" => {
|
||||
let f = |k: &str, have: bool| p[k].as_bool().is_none_or(|want| want == have);
|
||||
f("is_on_fire", e.on_fire)
|
||||
@@ -678,11 +723,16 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
})
|
||||
})
|
||||
}),
|
||||
"minecraft:type_specific/player" => p["gamemode"].as_array().is_none_or(|modes| {
|
||||
e.gamemode
|
||||
.as_deref()
|
||||
.is_some_and(|g| modes.iter().any(|m| m.as_str() == Some(g)))
|
||||
}),
|
||||
"minecraft:type_specific/player" => {
|
||||
p["gamemode"].as_array().is_none_or(|modes| {
|
||||
e.gamemode
|
||||
.as_deref()
|
||||
.is_some_and(|g| modes.iter().any(|m| m.as_str() == Some(g)))
|
||||
}) && (p["looking_at"].is_null()
|
||||
|| e.looking_at
|
||||
.as_ref()
|
||||
.is_some_and(|t| entity(&p["looking_at"], t, w, origin)))
|
||||
}
|
||||
"minecraft:type_specific/lightning" => {
|
||||
range_i(&p["blocks_set_on_fire"], 0)
|
||||
&& (p["entity_struck"].is_null()
|
||||
@@ -703,6 +753,87 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `KilledByArrowTrigger.TriggerInstance.matches`: the weapon, each listed
|
||||
/// victim condition matched by a distinct victim, and the count of entity types.
|
||||
pub fn killed_by_arrow(
|
||||
c: &Value,
|
||||
me: &EntityView,
|
||||
victims: &[EntityView],
|
||||
weapon: Option<&ItemView>,
|
||||
w: &dyn World,
|
||||
) -> bool {
|
||||
fn ctx<'a>(e: &'a EntityView, me: &EntityView) -> Entities<'a> {
|
||||
Entities {
|
||||
this: Some(e),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
}
|
||||
}
|
||||
if !conditions(&c["player"], &ctx(me, me), w) {
|
||||
return false;
|
||||
}
|
||||
if !c["fired_from_weapon"].is_null()
|
||||
&& !weapon.is_some_and(|it| item(&c["fired_from_weapon"], it, w))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut left: Vec<&EntityView> = victims.iter().collect();
|
||||
for want in c["victims"].as_array().into_iter().flatten() {
|
||||
let Some(i) = left.iter().position(|e| conditions(want, &ctx(e, me), w)) else {
|
||||
return false;
|
||||
};
|
||||
left.remove(i);
|
||||
}
|
||||
let kinds: BTreeSet<&str> = victims.iter().map(|e| e.kind.as_str()).collect();
|
||||
range_i(&c["unique_entity_types"], kinds.len() as i64)
|
||||
}
|
||||
/// A damage event as `DamagePredicate` sees it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DamageView {
|
||||
/// The damage type id (`minecraft:arrow`).
|
||||
pub kind: String,
|
||||
/// `DamageSource.getDirectEntity` and `getEntity`.
|
||||
pub direct: Option<EntityView>,
|
||||
pub source: Option<EntityView>,
|
||||
/// `DamageSource.isDirect`: the direct entity is the causing one.
|
||||
pub is_direct: bool,
|
||||
/// The damage before and after the shield took its share.
|
||||
pub original: f32,
|
||||
pub actual: f32,
|
||||
pub blocked: bool,
|
||||
}
|
||||
/// `DamagePredicate`, with its `DamageSourcePredicate` as `type`.
|
||||
pub fn damage(v: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> bool {
|
||||
if v.is_null() {
|
||||
return true;
|
||||
}
|
||||
let by = |key: &str, e: &Option<EntityView>| {
|
||||
v[key].is_null() || e.as_ref().is_some_and(|e| entity(&v[key], e, w, origin))
|
||||
};
|
||||
if !range_f(&v["dealt"], f64::from(d.original))
|
||||
|| !range_f(&v["taken"], f64::from(d.actual))
|
||||
|| !by("source_entity", &d.source)
|
||||
|| v["blocked"].as_bool().is_some_and(|b| b != d.blocked)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let ty = &v["type"];
|
||||
let tags_ok = ty["tags"].as_array().into_iter().flatten().all(|t| {
|
||||
let tag = t["id"].as_str().unwrap_or("");
|
||||
let tag = tag.strip_prefix('#').unwrap_or(tag);
|
||||
w.in_tag("damage_type", tag, &d.kind) == t["expected"].as_bool().unwrap_or(true)
|
||||
});
|
||||
let direct_ok = ty["direct_entity"].is_null()
|
||||
|| d.direct
|
||||
.as_ref()
|
||||
.is_some_and(|e| entity(&ty["direct_entity"], e, w, origin));
|
||||
let source_ok = ty["source_entity"].is_null()
|
||||
|| d.source
|
||||
.as_ref()
|
||||
.is_some_and(|e| entity(&ty["source_entity"], e, w, origin));
|
||||
tags_ok && direct_ok && source_ok && ty["is_direct"].as_bool().is_none_or(|b| b == d.is_direct)
|
||||
}
|
||||
/// The entities a loot-style condition list may refer to.
|
||||
pub struct Entities<'a> {
|
||||
pub this: Option<&'a EntityView>,
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::{
|
||||
blocks::{BlockFacts, Fluid},
|
||||
command::args::GameMode,
|
||||
items::ItemStack,
|
||||
particles::{ParticleEngine, ParticleOptions, ParticleType as T},
|
||||
particles::{ParticleEngine, ParticleOptions, ParticleType as T, along_axis},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -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,6 +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 {
|
||||
@@ -87,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),
|
||||
@@ -107,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()),
|
||||
@@ -126,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" => {
|
||||
@@ -167,6 +197,47 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
waterlogged: prop("waterlogged").as_deref() == Some("true"),
|
||||
},
|
||||
"end_portal" => Animate::EndPortal,
|
||||
"lever" if powered => Animate::Lever {
|
||||
facing: facing(),
|
||||
// FaceAttachedHorizontalDirectionalBlock.getConnectedDirection.
|
||||
connected: match prop("face").as_deref() {
|
||||
Some("ceiling") => [0, -1, 0],
|
||||
Some("floor") => [0, 1, 0],
|
||||
_ => facing(),
|
||||
},
|
||||
},
|
||||
"repeater" if powered => Animate::Repeater {
|
||||
facing: facing(),
|
||||
delay: prop("delay").and_then(|d| d.parse().ok()).unwrap_or(1),
|
||||
},
|
||||
"pointed_dripstone"
|
||||
if prop("vertical_direction").as_deref() == Some("down")
|
||||
&& prop("thickness").as_deref() == Some("tip")
|
||||
&& prop("waterlogged").as_deref() != Some("true") =>
|
||||
{
|
||||
Animate::PointedDripstone
|
||||
}
|
||||
"wither_rose" => Animate::WitherRose,
|
||||
"end_gateway" => Animate::EndGateway,
|
||||
_ if name == "lightning_rod" || name.ends_with("_lightning_rod") => {
|
||||
let [dx, dy, _] = facing();
|
||||
Animate::LightningRod(if dx != 0 {
|
||||
0
|
||||
} else if dy != 0 {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
})
|
||||
}
|
||||
"wet_sponge" => Animate::WetSponge,
|
||||
// BrushableBlock has no dust colour of its own, so the dust is the map colour.
|
||||
"suspicious_sand" => Animate::Falling(0xF7E9A3, false),
|
||||
"suspicious_gravel" => Animate::Falling(0x707070, false),
|
||||
"firefly_bush" => Animate::FireflyBush,
|
||||
"open_eyeblossom" => Animate::OpenEyeblossom,
|
||||
"pale_hanging_moss" => Animate::PaleHangingMoss,
|
||||
"dead_bush" => Animate::DeadBush,
|
||||
"short_dry_grass" | "tall_dry_grass" => Animate::DryGrass,
|
||||
"sculk_sensor" | "calibrated_sculk_sensor"
|
||||
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
|
||||
{
|
||||
@@ -233,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> {
|
||||
@@ -302,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,
|
||||
})
|
||||
@@ -333,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.
|
||||
@@ -383,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
|
||||
@@ -396,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));
|
||||
}
|
||||
@@ -434,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(
|
||||
@@ -590,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,
|
||||
@@ -665,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) {
|
||||
@@ -771,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
|
||||
@@ -783,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
|
||||
@@ -955,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";
|
||||
@@ -963,6 +1115,36 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::WetSponge => {
|
||||
// Direction.getRandom: DOWN, UP, NORTH, SOUTH, WEST, EAST.
|
||||
const DIRECTIONS: [[i32; 3]; 6] = [
|
||||
[0, -1, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, -1],
|
||||
[0, 0, 1],
|
||||
[-1, 0, 0],
|
||||
[1, 0, 0],
|
||||
];
|
||||
let d = DIRECTIONS[next_int(engine.random(), 6) as usize];
|
||||
// The sponge occludes, so only a sturdy neighbour face hides the drip.
|
||||
if d != [0, 1, 0] && !ctx.sturdy([pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]]) {
|
||||
let r = engine.random();
|
||||
let p = match d {
|
||||
[0, -1, 0] => [fx + r.next_double(), fy - 0.05, fz + r.next_double()],
|
||||
[dx, _, 0] => {
|
||||
let y = fy + r.next_double() * 0.8;
|
||||
let z = fz + r.next_double();
|
||||
[fx + if dx > 0 { 1.1 } else { 0.05 }, y, z]
|
||||
}
|
||||
[_, _, dz] => {
|
||||
let y = fy + r.next_double() * 0.8;
|
||||
let x = fx + r.next_double();
|
||||
[x, y, fz + if dz > 0 { 1.1 } else { 0.05 }]
|
||||
}
|
||||
};
|
||||
add(engine, T::DrippingWater, p, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
Animate::Mycelium => {
|
||||
let r = engine.random();
|
||||
if next_int(r, 10) == 0 {
|
||||
@@ -1021,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();
|
||||
@@ -1045,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.
|
||||
@@ -1062,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.
|
||||
@@ -1179,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);
|
||||
@@ -1192,7 +1568,7 @@ fn block_animate_tick(
|
||||
);
|
||||
}
|
||||
} else {
|
||||
add(
|
||||
add_visible(
|
||||
engine,
|
||||
T::BubbleColumnUp,
|
||||
[fx + 0.5, fy, fz + 0.5],
|
||||
@@ -1204,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);
|
||||
@@ -1282,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(),
|
||||
|
||||
@@ -234,3 +234,81 @@ impl RenderAssets {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::items::ConsumeEffect;
|
||||
|
||||
#[test]
|
||||
fn always_edible_and_consume_effects_loaded() {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
|
||||
|
||||
let apple = items.get("minecraft:golden_apple").expect("golden apple");
|
||||
assert!(apple.can_always_eat);
|
||||
assert!(apple.food.is_some());
|
||||
assert!(!apple.consumable.as_ref().unwrap().effects.is_empty());
|
||||
|
||||
let enchanted = items
|
||||
.get("minecraft:enchanted_golden_apple")
|
||||
.expect("enchanted golden apple");
|
||||
assert!(enchanted.can_always_eat);
|
||||
assert!(enchanted.food.is_some());
|
||||
assert!(!enchanted.consumable.as_ref().unwrap().effects.is_empty());
|
||||
|
||||
let beef = items.get("minecraft:cooked_beef").expect("cooked beef");
|
||||
assert!(!beef.can_always_eat);
|
||||
assert!(beef.food.is_some());
|
||||
|
||||
// Consumables without food, their style and their remainders.
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
let milk = items.get("minecraft:milk_bucket").expect("milk bucket");
|
||||
let drink = milk.consumable.as_ref().unwrap();
|
||||
assert!(milk.food.is_none());
|
||||
assert_eq!(drink.effects, [ConsumeEffect::ClearAllEffects]);
|
||||
assert_eq!((drink.ticks(), drink.animation), (32, UseAnimation::Drink));
|
||||
assert_eq!(drink.sound, "minecraft:entity.generic.drink");
|
||||
assert!(!drink.particles);
|
||||
assert_eq!(milk.use_remainder.as_deref(), Some("minecraft:bucket"));
|
||||
let honey = items.get("minecraft:honey_bottle").expect("honey bottle");
|
||||
let sip = honey.consumable.as_ref().unwrap();
|
||||
assert_eq!(sip.ticks(), 40);
|
||||
assert_eq!(sip.sound, "minecraft:item.honey_bottle.drink");
|
||||
assert_eq!(
|
||||
sip.effects,
|
||||
[ConsumeEffect::RemoveEffects(vec![
|
||||
"minecraft:poison".into()
|
||||
])]
|
||||
);
|
||||
assert_eq!(
|
||||
honey.use_remainder.as_deref(),
|
||||
Some("minecraft:glass_bottle")
|
||||
);
|
||||
let kelp = items.get("minecraft:dried_kelp").unwrap();
|
||||
let bite = kelp.consumable.as_ref().unwrap();
|
||||
assert_eq!((bite.ticks(), bite.animation), (16, UseAnimation::Eat));
|
||||
assert_eq!(bite.sound, "minecraft:entity.generic.eat");
|
||||
assert!(bite.particles && kelp.use_remainder.is_none());
|
||||
let chorus = items.get("minecraft:chorus_fruit").unwrap();
|
||||
assert_eq!(
|
||||
chorus.consumable.as_ref().unwrap().effects,
|
||||
[ConsumeEffect::TeleportRandomly(
|
||||
horizon_world::teleport_consume::TeleportConsume {
|
||||
diameter: 16.0,
|
||||
directional_particles: true,
|
||||
}
|
||||
)]
|
||||
);
|
||||
let chicken = items.get("minecraft:chicken").unwrap();
|
||||
assert!(matches!(
|
||||
chicken.consumable.as_ref().unwrap().effects[..],
|
||||
[ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -3,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,
|
||||
|
||||
@@ -719,11 +719,6 @@ impl Audio {
|
||||
self.play(event, at, volume, pitch);
|
||||
self.capture = capture;
|
||||
}
|
||||
/// `LivingEntity.handleDamageEvent`'s pitch, `(r - r) * 0.2 + 1`, drawn on the
|
||||
/// client (the entity's own random there, never the server's world random).
|
||||
pub fn hurt_pitch(&mut self) -> f32 {
|
||||
(self.random.next_float() - self.random.next_float()) * 0.2 + 1.0
|
||||
}
|
||||
/// `AbstractWidget.playButtonClickSound`: an unseeded, relative UI sound.
|
||||
pub fn play_button_click(&mut self) {
|
||||
self.play_instance(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,24 +3,10 @@
|
||||
//! banners, and the loom rules of `LoomMenu` (selectable patterns, the
|
||||
//! six-layer limit and the result stack).
|
||||
use crate::{block_entity_render::DYES, items::ItemStack, items::Items};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path, sync::Arc};
|
||||
|
||||
/// `BannerPatternLayers.Layer`: a pattern id and the dye name it is painted in.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct BannerLayer {
|
||||
pub pattern: Arc<str>,
|
||||
pub color: Arc<str>,
|
||||
}
|
||||
impl BannerLayer {
|
||||
pub fn new(pattern: &str, color: &str) -> Self {
|
||||
Self {
|
||||
pattern: Arc::from(pattern),
|
||||
color: Arc::from(color),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use horizon_items::banner_layer::{BannerLayer, layers_from_nbt, layers_to_nbt};
|
||||
|
||||
/// Layers a loom may stack on a banner (`LoomMenu`'s `hasMaxPatterns`).
|
||||
pub const MAX_LAYERS: usize = 6;
|
||||
@@ -224,44 +210,6 @@ pub fn has_max_layers(banner: &ItemStack) -> bool {
|
||||
layers(banner).len() >= MAX_LAYERS
|
||||
}
|
||||
|
||||
/// The `banner_patterns` NBT list of layers.
|
||||
pub fn layers_to_nbt(layers: &[BannerLayer]) -> Tag {
|
||||
Tag::List(
|
||||
layers
|
||||
.iter()
|
||||
.map(|l| {
|
||||
Tag::compound([
|
||||
("pattern", Tag::String((&*l.pattern).into())),
|
||||
("color", Tag::String((&*l.color).into())),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
pub fn layers_from_nbt(tag: Option<&Tag>) -> Vec<BannerLayer> {
|
||||
let Some(Tag::List(list)) = tag else {
|
||||
return Vec::new();
|
||||
};
|
||||
list.iter()
|
||||
.filter_map(|e| {
|
||||
let text = |k: &str| match e.get(k) {
|
||||
Some(Tag::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
let pattern = text("pattern")?;
|
||||
let color = text("color")?;
|
||||
Some(BannerLayer::new(
|
||||
&if pattern.contains(':') {
|
||||
pattern
|
||||
} else {
|
||||
format!("minecraft:{pattern}")
|
||||
},
|
||||
&color,
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! the rain, nor through a blocked front, unless the hive is emptied in an
|
||||
//! emergency), honey for every bee coming home with nectar. The host (the game
|
||||
//! or the mob AI probe) loads and adds the bees through [`HiveHost`].
|
||||
pub use crate::chunk::block_entity::{Beehive, Occupant};
|
||||
use crate::mobs::{Mob, Species, bee};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
@@ -38,20 +39,6 @@ const IGNORED_BEE_TAGS: [&str; 25] = [
|
||||
"UUID",
|
||||
];
|
||||
|
||||
/// `BeehiveBlockEntity`: the bees inside and the flower one of them knew.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Beehive {
|
||||
pub bees: Vec<Occupant>,
|
||||
pub flower: Option<[i32; 3]>,
|
||||
}
|
||||
/// `BeehiveBlockEntity.Occupant`: a bee's tag (with its `id`), its time in
|
||||
/// the hive and the time it stays at least.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Occupant {
|
||||
pub entity_data: Tag,
|
||||
pub ticks_in_hive: i32,
|
||||
pub min_ticks_in_hive: i32,
|
||||
}
|
||||
/// `BeehiveBlockEntity.BeeReleaseStatus`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Release {
|
||||
|
||||
@@ -450,6 +450,54 @@ mod tests {
|
||||
assert_eq!(view_chunks(32), 2573);
|
||||
assert_eq!(view_chunks(0), 0);
|
||||
}
|
||||
/// The status a fake control answers with: the player is where it should
|
||||
/// be, nothing is queued, and the world holds `chunks` resident chunks.
|
||||
fn status(chunks: u64) -> String {
|
||||
serde_json::json!({
|
||||
"player": {"position": [0.0, 64.0, 0.0]},
|
||||
"pending_chunks": 0,
|
||||
"screen": "playing",
|
||||
"render_distance": 12,
|
||||
"chunks": chunks,
|
||||
"generated_chunks": chunks,
|
||||
"meshed_sections": 0,
|
||||
"streaming": {},
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_load_waits_for_the_chunks_a_teleport_starts() {
|
||||
// Right after a teleport the game reports steady counts and nothing
|
||||
// queued for over a second, before the integrated server has sent the
|
||||
// first chunk: the quiet rule alone would call that loaded, and the
|
||||
// scene would be measured over an empty view (#1749).
|
||||
const LATE: usize = 30;
|
||||
let (tx, rx) = mpsc::channel::<Request>();
|
||||
let polls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let counted = std::sync::Arc::clone(&polls);
|
||||
let server = thread::spawn(move || {
|
||||
let mut seen = 0;
|
||||
while let Ok(request) = rx.recv() {
|
||||
if request.line == "status" {
|
||||
let chunks = if seen < LATE { 0 } else { 400 };
|
||||
seen += 1;
|
||||
counted.store(seen, std::sync::atomic::Ordering::SeqCst);
|
||||
let _ = request.reply.send(status(chunks));
|
||||
} else {
|
||||
let _ = request.reply.send("{}".to_owned());
|
||||
}
|
||||
}
|
||||
});
|
||||
let control = Control(tx);
|
||||
wait_loaded(&control, |_| true).expect("the scene loads");
|
||||
assert!(
|
||||
polls.load(std::sync::atomic::Ordering::SeqCst) > LATE,
|
||||
"the load finished before the chunks arrived"
|
||||
);
|
||||
drop(control);
|
||||
let _ = server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaw_matches_vanilla() {
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Right-to-left text (`FormattedBidiReorder.reorder`, behind
|
||||
//! `Language.getVisualOrder`): the logical text a player types becomes the
|
||||
//! glyphs a font draws left to right. Arabic letters are shaped into their
|
||||
//! joined presentation forms (ICU's `ArabicShaping` with `LETTERS_SHAPE`), the
|
||||
//! text is reordered by the Unicode bidirectional algorithm, and characters in
|
||||
//! right-to-left runs are mirrored. Left-to-right text comes back unchanged.
|
||||
use crate::text::{Span, Text};
|
||||
use std::borrow::Cow;
|
||||
use unicode_bidi::{
|
||||
BidiClass, Direction, Level, ParagraphBidiInfo, bidi_class, get_base_direction,
|
||||
};
|
||||
|
||||
/// How a letter joins its neighbours, as ICU's shaper sees it: `Right` joins
|
||||
/// the letter before it only, `Dual` both neighbours, `Mark` is skipped over
|
||||
/// (diacritics) and `None` never joins.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Join {
|
||||
None,
|
||||
Right,
|
||||
Dual,
|
||||
Mark,
|
||||
}
|
||||
use Join::{Dual, Mark, Right};
|
||||
|
||||
/// ICU's joining table (`araLink`): code point ranges and their class, with the
|
||||
/// lam-alef ligatures at the end. Anything else is `Join::None`.
|
||||
const JOINS: &[(u32, u32, Join)] = &[
|
||||
(0x0622, 0x0625, Right),
|
||||
(0x0626, 0x0626, Dual),
|
||||
(0x0627, 0x0627, Right),
|
||||
(0x0628, 0x0628, Dual),
|
||||
(0x0629, 0x0629, Right),
|
||||
(0x062A, 0x062E, Dual),
|
||||
(0x062F, 0x0632, Right),
|
||||
(0x0633, 0x063A, Dual),
|
||||
(0x0640, 0x0647, Dual),
|
||||
(0x0648, 0x0649, Right),
|
||||
(0x064A, 0x064A, Dual),
|
||||
(0x064B, 0x0656, Mark),
|
||||
(0x0670, 0x0670, Mark),
|
||||
(0x0671, 0x0673, Right),
|
||||
(0x0675, 0x0677, Right),
|
||||
(0x0678, 0x0687, Dual),
|
||||
(0x0688, 0x0699, Right),
|
||||
(0x069A, 0x06B9, Dual),
|
||||
(0x06BA, 0x06BA, Right),
|
||||
(0x06BB, 0x06BF, Dual),
|
||||
(0x06C0, 0x06C0, Right),
|
||||
(0x06C1, 0x06C1, Dual),
|
||||
(0x06C2, 0x06CB, Right),
|
||||
(0x06CC, 0x06CC, Dual),
|
||||
(0x06CD, 0x06CD, Right),
|
||||
(0x06CE, 0x06D1, Dual),
|
||||
(0x06D2, 0x06D3, Right),
|
||||
(0x200D, 0x200D, Dual),
|
||||
(0x206D, 0x206F, Mark),
|
||||
(0xFEF5, 0xFEFC, Right),
|
||||
];
|
||||
|
||||
/// Letters that have joined forms and the code point of the isolated one; the
|
||||
/// final, initial and medial forms follow it (a right-joining letter has the
|
||||
/// isolated and final only). The lam-alef ligatures are their own letters.
|
||||
/// ICU leaves the extended letters (پ چ ژ ک گ ی and the rest of U+0679 to
|
||||
/// U+06D3) unchanged, so they are in `JOINS` but not here.
|
||||
#[rustfmt::skip]
|
||||
const FORMS: &[(u32, u32)] = &[
|
||||
(0x0622, 0xFE81), (0x0623, 0xFE83), (0x0624, 0xFE85), (0x0625, 0xFE87), (0x0626, 0xFE89),
|
||||
(0x0627, 0xFE8D), (0x0628, 0xFE8F), (0x0629, 0xFE93), (0x062A, 0xFE95), (0x062B, 0xFE99),
|
||||
(0x062C, 0xFE9D), (0x062D, 0xFEA1), (0x062E, 0xFEA5), (0x062F, 0xFEA9), (0x0630, 0xFEAB),
|
||||
(0x0631, 0xFEAD), (0x0632, 0xFEAF), (0x0633, 0xFEB1), (0x0634, 0xFEB5), (0x0635, 0xFEB9),
|
||||
(0x0636, 0xFEBD), (0x0637, 0xFEC1), (0x0638, 0xFEC5), (0x0639, 0xFEC9), (0x063A, 0xFECD),
|
||||
(0x0641, 0xFED1), (0x0642, 0xFED5), (0x0643, 0xFED9), (0x0644, 0xFEDD), (0x0645, 0xFEE1),
|
||||
(0x0646, 0xFEE5), (0x0647, 0xFEE9), (0x0648, 0xFEED), (0x0649, 0xFEEF), (0x064A, 0xFEF1),
|
||||
(0xFEF5, 0xFEF5), (0xFEF7, 0xFEF7), (0xFEF9, 0xFEF9), (0xFEFB, 0xFEFB),
|
||||
];
|
||||
|
||||
/// `Bidi_Mirroring_Glyph` (`UCharacter.getMirror`): each pair is two
|
||||
/// neighbouring characters that mirror each other.
|
||||
const MIRRORED: &str = "\
|
||||
()<>[]{}«»༺༻༼༽᚛᚜‹›⁅⁆⁽⁾₍₎∈∋∉∌∊∍∕⧵∟⯾∠⦣∡⦛∢⦠∤⫮∼∽≃⋍≅≌≒≓\
|
||||
≔≕≤≥≦≧≨≩≪≫≮≯≰≱≲≳≴≵≶≷≸≹≺≻≼≽≾≿⊀⊁⊂⊃⊄⊅⊆⊇⊈⊉⊊⊋⊏⊐⊑⊒⊘⦸⊢⊣⊦⫞\
|
||||
⊨⫤⊩⫣⊫⫥⊰⊱⊲⊳⊴⊵⊶⊷⊸⟜⋉⋊⋋⋌⋐⋑⋖⋗⋘⋙⋚⋛⋜⋝⋞⋟⋠⋡⋢⋣⋤⋥⋦⋧⋨⋩⋪⋫⋬⋭⋰⋱⋲⋺\
|
||||
⋳⋻⋴⋼⋶⋽⋷⋾⌈⌉⌊⌋〈〉❨❩❪❫❬❭❮❯❰❱❲❳❴❵⟃⟄⟅⟆⟈⟉⟋⟍⟕⟖⟝⟞⟢⟣⟤⟥⟦⟧⟨⟩⟪⟫\
|
||||
⟬⟭⟮⟯⦃⦄⦅⦆⦇⦈⦉⦊⦋⦌⦍⦐⦎⦏⦑⦒⦓⦔⦕⦖⦗⦘⦤⦥⦨⦩⦪⦫⦬⦭⦮⦯⧀⧁⧄⧅⧏⧐⧑⧒⧔⧕⧘⧙⧚⧛\
|
||||
⧨⧩⧸⧹⧼⧽⨫⨬⨭⨮⨴⨵⨼⨽⩤⩥⩹⩺⩻⩼⩽⩾⩿⪀⪁⪂⪃⪄⪅⪆⪇⪈⪉⪊⪋⪌⪍⪎⪏⪐⪑⪒⪓⪔⪕⪖⪗⪘⪙⪚\
|
||||
⪛⪜⪝⪞⪟⪠⪡⪢⪦⪧⪨⪩⪪⪫⪬⪭⪯⪰⪱⪲⪳⪴⪵⪶⪷⪸⪹⪺⪻⪼⪽⪾⪿⫀⫁⫂⫃⫄⫅⫆⫇⫈⫉⫊⫋⫌⫍⫎⫏⫐\
|
||||
⫑⫒⫓⫔⫕⫖⫬⫭⫷⫸⫹⫺⸂⸃⸄⸅⸉⸊⸌⸍⸜⸝⸠⸡⸢⸣⸤⸥⸦⸧⸨⸩⹕⹖⹗⹘⹙⹚⹛⹜〈〉《》「」『』【】\
|
||||
〔〕〖〗〘〙〚〛﹙﹚﹛﹜﹝﹞﹤﹥()<>[]{}⦅⦆「」";
|
||||
|
||||
fn join(c: char) -> Join {
|
||||
let c = c as u32;
|
||||
JOINS
|
||||
.iter()
|
||||
.find(|&&(from, to, _)| (from..=to).contains(&c))
|
||||
.map_or(Join::None, |&(_, _, join)| join)
|
||||
}
|
||||
|
||||
/// The letter whose joined form `c` is (ICU shapes the forms again), or `c`.
|
||||
fn letter_of(c: char) -> char {
|
||||
let c32 = c as u32;
|
||||
if !matches!(c32, 0xFE70..=0xFEFC) {
|
||||
return c;
|
||||
}
|
||||
if (0xFE70..0xFE80).contains(&c32) {
|
||||
// The diacritics' forms pair up from fathatan on.
|
||||
return char::from_u32(0x064B + (c32 - 0xFE70) / 2).unwrap_or(c);
|
||||
}
|
||||
FORMS
|
||||
.iter()
|
||||
.find(|&&(letter, iso)| {
|
||||
let forms = if join(char::from_u32(letter).unwrap_or(c)) == Dual {
|
||||
4
|
||||
} else {
|
||||
2
|
||||
};
|
||||
(iso..iso + forms).contains(&c32)
|
||||
})
|
||||
.and_then(|&(letter, _)| char::from_u32(letter))
|
||||
.unwrap_or(c)
|
||||
}
|
||||
|
||||
/// `ArabicShaping(LETTERS_SHAPE).shape`: lam followed by an alef (diacritics
|
||||
/// may sit between) becomes one ligature, then every letter takes the isolated,
|
||||
/// final, initial or medial form its neighbours call for. Diacritics take the
|
||||
/// tatweel form between two joined letters. The text can only get shorter.
|
||||
fn shape(text: &[char]) -> Vec<char> {
|
||||
let letters: Vec<char> = text.iter().map(|&c| letter_of(c)).collect();
|
||||
let mut chars = Vec::with_capacity(letters.len());
|
||||
let mut i = 0;
|
||||
while i < letters.len() {
|
||||
if letters[i] == '\u{644}' {
|
||||
let mut at = i + 1;
|
||||
while letters.get(at).is_some_and(|&c| join(c) == Mark) {
|
||||
at += 1;
|
||||
}
|
||||
let alef = letters.get(at).and_then(|&c| {
|
||||
['\u{622}', '\u{623}', '\u{625}', '\u{627}']
|
||||
.iter()
|
||||
.position(|&a| a == c)
|
||||
});
|
||||
if let Some(alef) = alef {
|
||||
chars.push(char::from_u32(0xFEF5 + 2 * alef as u32).unwrap_or('\u{644}'));
|
||||
chars.extend_from_slice(&letters[i + 1..at]);
|
||||
i = at + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
chars.push(letters[i]);
|
||||
i += 1;
|
||||
}
|
||||
// Whether the nearest letter before joins the next one, and the joins of
|
||||
// the nearest letter after.
|
||||
let mut joins_next = false;
|
||||
let mut out = Vec::with_capacity(chars.len());
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
let next_joins = chars[i + 1..]
|
||||
.iter()
|
||||
.map(|&n| join(n))
|
||||
.find(|&j| j != Mark)
|
||||
.is_some_and(|j| j != Join::None);
|
||||
let kind = join(c);
|
||||
let shaped = match kind {
|
||||
Join::None => {
|
||||
joins_next = false;
|
||||
c
|
||||
}
|
||||
Mark => match c as u32 {
|
||||
m @ 0x064B..=0x0652 => {
|
||||
let iso = 0xFE70 + 2 * (m - 0x064B);
|
||||
let tatweel = joins_next && next_joins && !matches!(m, 0x064C | 0x064D);
|
||||
char::from_u32(iso + u32::from(tatweel)).unwrap_or(c)
|
||||
}
|
||||
_ => c,
|
||||
},
|
||||
Right | Dual => {
|
||||
let to_next = kind == Dual && next_joins;
|
||||
let form = match (joins_next, to_next) {
|
||||
(false, false) => 0,
|
||||
(true, false) => 1,
|
||||
(false, true) => 2,
|
||||
(true, true) => 3,
|
||||
};
|
||||
joins_next = kind == Dual;
|
||||
FORMS
|
||||
.binary_search_by_key(&(c as u32), |&(letter, _)| letter)
|
||||
.ok()
|
||||
.and_then(|at| char::from_u32(FORMS[at].1 + form))
|
||||
.unwrap_or(c)
|
||||
}
|
||||
};
|
||||
out.push(shaped);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `UCharacter.getMirror`.
|
||||
fn mirror(c: char) -> char {
|
||||
if bidi_class(c) != BidiClass::ON {
|
||||
return c;
|
||||
}
|
||||
let mut pairs = MIRRORED.chars();
|
||||
while let (Some(a), Some(b)) = (pairs.next(), pairs.next()) {
|
||||
if c == a {
|
||||
return b;
|
||||
}
|
||||
if c == b {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Whether a character can change anything: right-to-left letters and numbers,
|
||||
/// the explicit direction controls and the diacritics that shaping turns into
|
||||
/// letters. Without any, left-to-right text keeps its order.
|
||||
fn directional(c: char) -> bool {
|
||||
c as u32 >= 0x0590
|
||||
&& (matches!(c as u32, 0x064B..=0x0652)
|
||||
|| matches!(
|
||||
bidi_class(c),
|
||||
BidiClass::R
|
||||
| BidiClass::AL
|
||||
| BidiClass::AN
|
||||
| BidiClass::LRE
|
||||
| BidiClass::RLE
|
||||
| BidiClass::LRO
|
||||
| BidiClass::RLO
|
||||
| BidiClass::PDF
|
||||
| BidiClass::LRI
|
||||
| BidiClass::RLI
|
||||
| BidiClass::FSI
|
||||
| BidiClass::PDI
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether `chars` keep their order and shapes: left-to-right text without
|
||||
/// anything directional, in a language that reads left to right by default.
|
||||
fn unchanged(mut chars: impl Iterator<Item = char>, default_rtl: bool) -> bool {
|
||||
!default_rtl && !chars.any(directional)
|
||||
}
|
||||
|
||||
/// `FormattedBidiReorder.reorder`: `chars` shaped, in visual order, with each
|
||||
/// character's position in the shaped text. A character's style is the one at
|
||||
/// its position in the shaped text, as `SubStringSource` takes it (the text
|
||||
/// shrinks at a lam-alef ligature).
|
||||
fn reorder(chars: &[char], default_rtl: bool) -> Vec<(char, usize)> {
|
||||
let shaped = shape(chars);
|
||||
let plain: String = shaped.iter().collect();
|
||||
// DEFAULT_RTL only changes text without a strong character.
|
||||
let level =
|
||||
(default_rtl && get_base_direction(plain.as_str()) == Direction::Mixed).then(Level::rtl);
|
||||
let info = ParagraphBidiInfo::new(&plain, level);
|
||||
let levels = info.reordered_levels_per_char(0..plain.len());
|
||||
ParagraphBidiInfo::reorder_visual(&levels)
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
let c = if levels[i].is_rtl() {
|
||||
mirror(shaped[i])
|
||||
} else {
|
||||
shaped[i]
|
||||
};
|
||||
(c, i)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `Language.getVisualOrder` of a text (its `§` codes already resolved), for
|
||||
/// the current language's direction.
|
||||
pub fn visual(text: &Text) -> Cow<'_, Text> {
|
||||
let default_rtl = crate::lang::bidirectional();
|
||||
if unchanged(text.chars().map(|(c, _)| c), default_rtl) {
|
||||
return Cow::Borrowed(text);
|
||||
}
|
||||
let chars: Vec<char> = text.chars().map(|(c, _)| c).collect();
|
||||
let styles: Vec<_> = text.chars().map(|(_, style)| style).collect();
|
||||
let mut out = Text::default();
|
||||
for (c, at) in reorder(&chars, default_rtl) {
|
||||
match out.spans.last_mut() {
|
||||
Some(span) if span.style == *styles[at] => span.text.push(c),
|
||||
_ => out.spans.push(Span {
|
||||
text: c.to_string(),
|
||||
style: styles[at].clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// The same for a plain string.
|
||||
pub fn visual_str(s: &str) -> Cow<'_, str> {
|
||||
let default_rtl = crate::lang::bidirectional();
|
||||
if unchanged(s.chars(), default_rtl) {
|
||||
return Cow::Borrowed(s);
|
||||
}
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
Cow::Owned(
|
||||
reorder(&chars, default_rtl)
|
||||
.into_iter()
|
||||
.map(|(c, _)| c)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
@@ -66,6 +66,13 @@ impl BiomeInfo {
|
||||
}
|
||||
.height_adjusted(pos, sea_level)
|
||||
}
|
||||
/// `Biome.getPrecipitationAt` in the Overworld (sea level 63): None
|
||||
/// without precipitation, else whether it falls as snow
|
||||
/// (`getHeightAdjustedTemperature` below 0.15).
|
||||
pub fn snows_at(&self, x: i32, y: i32, z: i32) -> Option<bool> {
|
||||
self.precipitation
|
||||
.then(|| self.temperature_at(BlockPos::new(x, y, z), 63) < 0.15)
|
||||
}
|
||||
}
|
||||
pub struct Biomes {
|
||||
pub list: Vec<BiomeInfo>,
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
//! Which states tick is a table by state id, built by the game from the block
|
||||
//! registry; whether a slot is made is decided when the slot is promoted at
|
||||
//! the start of the next tick, from the block state then.
|
||||
use crate::containers::BlockEntity;
|
||||
use crate::chunk::block_entity::BlockEntity;
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
//! (`entity-models.json`); transforms, animation and textures follow each
|
||||
//! vanilla renderer. Faces are lit like the `entity` shader: the lightmap at
|
||||
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
|
||||
use crate::{hud::Rect, mesher::Vertex, world::World};
|
||||
use crate::{
|
||||
hud::Rect,
|
||||
mesher::{CULL_BACK, Vertex},
|
||||
world::World,
|
||||
};
|
||||
use glam::{DVec3, Mat4, Quat, Vec3};
|
||||
use horizon_core::geometry::{
|
||||
Direction,
|
||||
@@ -149,6 +153,8 @@ pub fn draw_part(
|
||||
layer: 0,
|
||||
color,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -610,7 +616,9 @@ fn sign_text_calls(
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, line)| {
|
||||
// The line that fits, in the order it is drawn (getVisualOrder).
|
||||
let shown = first_line(line, data.max_line_width(), &advance);
|
||||
let shown = crate::bidi::visual_str(&shown).into_owned();
|
||||
TextCall {
|
||||
matrix: m,
|
||||
x: -((width(&shown) as i32) / 2) as f32,
|
||||
@@ -648,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],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -734,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),
|
||||
}
|
||||
@@ -932,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)
|
||||
@@ -1286,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 {
|
||||
@@ -1385,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)
|
||||
{
|
||||
@@ -1402,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
|
||||
@@ -1432,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 => {
|
||||
@@ -1714,6 +1741,8 @@ impl BeamPart {
|
||||
layer: 6,
|
||||
color,
|
||||
light: self.wrap,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1784,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],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2108,4 +2138,70 @@ mod tests {
|
||||
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
|
||||
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
|
||||
}
|
||||
/// `DecoratedPotRenderer` submits every part with `RenderTypes.entitySolid`,
|
||||
/// whose pipeline culls back faces. The pot's `top` and `bottom` are flat
|
||||
/// boxes, so without that cull both sides of each plane are drawn and they
|
||||
/// z-fight: the pot renders wrong.
|
||||
#[test]
|
||||
fn the_decorated_pot_culls_its_back_faces() {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("entity-models.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let registry = assets.registry;
|
||||
let renderer = BlockEntityRenderer::load(&root, registry).unwrap();
|
||||
let state = registry.default_state("minecraft:decorated_pot").unwrap();
|
||||
let sprites: HashMap<String, Rect> = [
|
||||
"entity/decorated_pot/decorated_pot_base",
|
||||
"entity/decorated_pot/decorated_pot_side",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
(
|
||||
name.to_owned(),
|
||||
Rect {
|
||||
u0: 0.25,
|
||||
v0: 0.125,
|
||||
u1: 0.375,
|
||||
v1: 0.25,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
let scene = Scene {
|
||||
camera: DVec3::ZERO,
|
||||
orientation: Quat::IDENTITY,
|
||||
anim: &|_| BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: 0.5,
|
||||
game_time: 0,
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
renderer.render_block(&scene, [0; 3], state.0, Mat4::IDENTITY, 0xff, &mut verts);
|
||||
// Vanilla submits 28 quads for a plain pot, and the cull keeps them all.
|
||||
assert_eq!(
|
||||
verts.len() / 4,
|
||||
28,
|
||||
"the pot submits all of vanilla's quads"
|
||||
);
|
||||
assert!(
|
||||
verts.iter().all(|v| v.normal == CULL_BACK),
|
||||
"entitySolid culls the pot's back faces"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,8 @@ pub fn geometry(
|
||||
color: 0xffff_ffff,
|
||||
light: stage_light(stage),
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,239 +2,18 @@
|
||||
//! in vanilla, ported as tables), data-driven `minecraft:brewing` recipes from
|
||||
//! `data/minecraft/recipe/brewing`, potion colours and tooltips, and
|
||||
//! `BrewingStandBlockEntity.serverTick`.
|
||||
pub use crate::chunk::block_entity::BrewingStand;
|
||||
use crate::{
|
||||
containers::Providers,
|
||||
items::{ItemStack, Items},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashSet, fs, path::Path, sync::Arc};
|
||||
use std::{collections::HashSet, fs, path::Path};
|
||||
|
||||
#[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)
|
||||
}
|
||||
/// 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;
|
||||
@@ -412,18 +191,6 @@ impl Brewing {
|
||||
}
|
||||
}
|
||||
|
||||
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BrewingStand {
|
||||
pub items: [Option<ItemStack>; 5],
|
||||
pub brew_time: i32,
|
||||
pub total_brew_time: i32,
|
||||
pub fuel: i32,
|
||||
pub total_fuel: i32,
|
||||
pub speed: f32,
|
||||
pub ingredient: Option<Arc<str>>,
|
||||
pub last_bottles: Option<[bool; 3]>,
|
||||
}
|
||||
/// What a brewing tick asks the world to do.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct BrewTick {
|
||||
|
||||
@@ -40,6 +40,8 @@ pub struct Interaction {
|
||||
/// The cauldron's new state.
|
||||
pub block: Option<BlockState>,
|
||||
pub sound: Option<&'static str>,
|
||||
/// The custom statistic the player earns (`use_cauldron`, ...).
|
||||
pub stat: Option<&'static str>,
|
||||
}
|
||||
|
||||
fn state(name: &str, level: Option<u8>) -> BlockState {
|
||||
@@ -57,6 +59,7 @@ fn pass() -> Interaction {
|
||||
outcome: Outcome::Pass,
|
||||
block: None,
|
||||
sound: None,
|
||||
stat: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,11 +120,12 @@ fn lowered(cauldron: &BlockState) -> BlockState {
|
||||
}
|
||||
}
|
||||
|
||||
fn changed(block: BlockState, sound: Option<&'static str>) -> Interaction {
|
||||
fn changed(block: BlockState, sound: Option<&'static str>, stat: &'static str) -> Interaction {
|
||||
Interaction {
|
||||
outcome: Outcome::Success,
|
||||
block: Some(block),
|
||||
sound,
|
||||
stat: Some(stat),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,18 +183,22 @@ pub fn interact(
|
||||
stack.data_mut().dyed_color = None;
|
||||
stack.tidy();
|
||||
}
|
||||
return changed(lowered(cauldron), None);
|
||||
return changed(lowered(cauldron), None, "clean_armor");
|
||||
}
|
||||
let fill_bucket = |player: &mut dyn Player, new: &str, can_fill: bool, sound| {
|
||||
if !can_fill {
|
||||
return pass();
|
||||
}
|
||||
filled(player, ItemStack::new(new, 1), true);
|
||||
changed(state("minecraft:cauldron", None), Some(sound))
|
||||
changed(
|
||||
state("minecraft:cauldron", None),
|
||||
Some(sound),
|
||||
"use_cauldron",
|
||||
)
|
||||
};
|
||||
let empty_bucket = |player: &mut dyn Player, block: BlockState, sound| {
|
||||
filled(player, ItemStack::new("minecraft:bucket", 1), true);
|
||||
changed(block, Some(sound))
|
||||
changed(block, Some(sound), "fill_cauldron")
|
||||
};
|
||||
match item {
|
||||
"minecraft:lava_bucket" => {
|
||||
@@ -199,6 +207,7 @@ pub fn interact(
|
||||
outcome: Outcome::Consume,
|
||||
block: None,
|
||||
sound: None,
|
||||
stat: None,
|
||||
}
|
||||
} else {
|
||||
empty_bucket(
|
||||
@@ -221,6 +230,7 @@ pub fn interact(
|
||||
outcome: Outcome::Consume,
|
||||
block: None,
|
||||
sound: None,
|
||||
stat: None,
|
||||
}
|
||||
} else {
|
||||
empty_bucket(
|
||||
@@ -241,6 +251,7 @@ pub fn interact(
|
||||
changed(
|
||||
state("minecraft:water_cauldron", Some(1)),
|
||||
Some("minecraft:item.bottle.empty"),
|
||||
"use_cauldron",
|
||||
)
|
||||
}
|
||||
("water", "minecraft:bucket") => fill_bucket(
|
||||
@@ -255,7 +266,11 @@ pub fn interact(
|
||||
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"),
|
||||
true,
|
||||
);
|
||||
changed(lowered(cauldron), Some("minecraft:item.bottle.fill"))
|
||||
changed(
|
||||
lowered(cauldron),
|
||||
Some("minecraft:item.bottle.fill"),
|
||||
"use_cauldron",
|
||||
)
|
||||
}
|
||||
("water", "minecraft:potion") => {
|
||||
if level == 3 || !water_potion {
|
||||
@@ -266,7 +281,7 @@ pub fn interact(
|
||||
// BlockState.cycle on a 1..=3 property
|
||||
next.properties
|
||||
.insert("level".into(), (level % 3 + 1).to_string());
|
||||
changed(next, Some("minecraft:item.bottle.empty"))
|
||||
changed(next, Some("minecraft:item.bottle.empty"), "use_cauldron")
|
||||
}
|
||||
("water", name)
|
||||
if name
|
||||
@@ -283,7 +298,7 @@ pub fn interact(
|
||||
cleaned.data_mut().banner_patterns.pop();
|
||||
cleaned.tidy();
|
||||
filled(player, cleaned, false);
|
||||
changed(lowered(cauldron), None)
|
||||
changed(lowered(cauldron), None, "clean_banner")
|
||||
}
|
||||
("water", name)
|
||||
if name
|
||||
@@ -292,7 +307,7 @@ pub fn interact(
|
||||
.is_some_and(|c| COLORS.contains(&c)) =>
|
||||
{
|
||||
filled(player, held.transmute("minecraft:shulker_box", 1), false);
|
||||
changed(lowered(cauldron), None)
|
||||
changed(lowered(cauldron), None, "clean_shulker_box")
|
||||
}
|
||||
("lava", "minecraft:bucket") => fill_bucket(
|
||||
player,
|
||||
|
||||
@@ -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,9 +2,12 @@
|
||||
//! 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};
|
||||
|
||||
pub mod block_entity;
|
||||
|
||||
pub const MIN_Y: i32 = -64;
|
||||
pub const HEIGHT: i32 = 384;
|
||||
pub const SECTIONS: usize = (HEIGHT / 16) as usize;
|
||||
@@ -148,14 +151,14 @@ pub struct Chunk {
|
||||
/// Newly generated (not loaded from a save): eligible for generation-time spawns.
|
||||
pub fresh: bool,
|
||||
/// Block entities read from a save, moved into the world on arrival.
|
||||
pub block_entities: Vec<((i32, i32, i32), crate::containers::BlockEntity)>,
|
||||
pub block_entities: Vec<((i32, i32, i32), block_entity::BlockEntity)>,
|
||||
/// `CustomName` of the container block entities (shown as the screen title).
|
||||
pub block_names: Vec<((i32, i32, i32), String)>,
|
||||
/// Generation postprocessing marks (ProtoChunk.postProcessing) as world
|
||||
/// 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 +312,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::*;
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
//! Block entity data: what a chunk stores for its block entities (`BlockEntity`
|
||||
//! and its payloads). Plain data, as vanilla's block entities are world data;
|
||||
//! their behaviour lives with the features that use them (`containers`,
|
||||
//! `brewing`, `signs`, ...), which re-export these types under their old paths.
|
||||
use horizon_client_core::{text::Text, who::Who};
|
||||
use horizon_items::{banner_layer::BannerLayer, items::ItemStack};
|
||||
use horizon_world::{
|
||||
level::sculk::SculkSpreader,
|
||||
nbt::{Compound, Tag},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// An entity's UUID as its four ints (`UUIDUtil.CODEC`).
|
||||
pub type Uuid = [i32; 4];
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FurnaceKind {
|
||||
Furnace,
|
||||
BlastFurnace,
|
||||
Smoker,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Furnace {
|
||||
pub kind: FurnaceKind,
|
||||
/// input, fuel, result
|
||||
pub items: [Option<ItemStack>; 3],
|
||||
pub lit_remaining: i32,
|
||||
pub lit_total: i32,
|
||||
pub cooking_timer: i32,
|
||||
pub cooking_total: i32,
|
||||
pub speed: f32,
|
||||
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
|
||||
/// made since a player last took from its output. The experience is owed per recipe
|
||||
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
|
||||
pub recipes_used: HashMap<String, u32>,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum BlockEntity {
|
||||
Chest(Vec<Option<ItemStack>>),
|
||||
Furnace(Furnace),
|
||||
Brewing(Box<BrewingStand>),
|
||||
/// Signs and hanging signs (`SignBlockEntity`).
|
||||
Sign(SignData),
|
||||
/// `JukeboxBlockEntity`: the record and its `JukeboxSongPlayer`.
|
||||
Jukebox(Jukebox),
|
||||
/// `BeaconBlockEntity`.
|
||||
Beacon(Box<Beacon>),
|
||||
/// `LecternBlockEntity`: the book and the open page.
|
||||
Lectern(Lectern),
|
||||
/// Sculk sensors, shriekers and catalysts (`crate::sculk`).
|
||||
Sculk(Box<SculkEntity>),
|
||||
/// `CreakingHeartBlockEntity`: its protector and the hurt emitter.
|
||||
CreakingHeart(Box<CreakingHeart>),
|
||||
/// `BannerBlockEntity`: the pattern layers.
|
||||
Banner(Banner),
|
||||
/// `CrafterBlockEntity`.
|
||||
Crafter(Crafter),
|
||||
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
|
||||
Spawner(Box<Spawner>),
|
||||
/// `TrialSpawnerBlockEntity` (`crate::mobs::trial_spawner`).
|
||||
TrialSpawner(Box<TrialSpawner>),
|
||||
/// `VaultBlockEntity` (`crate::vault`).
|
||||
Vault(Box<Vault>),
|
||||
/// `BeehiveBlockEntity` of beehives and bee nests (`crate::beehive`).
|
||||
Beehive(Box<Beehive>),
|
||||
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
|
||||
CommandBlock(Box<CommandBlock>),
|
||||
/// `CampfireBlockEntity` of campfires and soul campfires.
|
||||
Campfire(Box<Campfire>),
|
||||
/// `SkullBlockEntity` of every skull and head block.
|
||||
Skull(Skull),
|
||||
/// `TheEndGatewayBlockEntity`: its age and configured destination.
|
||||
EndGateway(EndGateway),
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
|
||||
ChiseledBookshelf(Box<ChiseledBookshelf>),
|
||||
}
|
||||
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ChiseledBookshelf {
|
||||
pub items: [Option<ItemStack>; 6],
|
||||
pub last_slot: i32,
|
||||
}
|
||||
impl Default for ChiseledBookshelf {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
items: Default::default(),
|
||||
last_slot: -1,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `TheEndGatewayBlockEntity`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EndGateway {
|
||||
pub age: i64,
|
||||
pub exit_portal: Option<[i32; 3]>,
|
||||
pub exact_teleport: bool,
|
||||
}
|
||||
/// `CampfireBlockEntity`: four cooking slots, each with its
|
||||
/// `cookingProgress` and `cookingTime` (the recipe's).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Campfire {
|
||||
pub items: [Option<ItemStack>; 4],
|
||||
pub progress: [i32; 4],
|
||||
pub total: [i32; 4],
|
||||
}
|
||||
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
|
||||
/// a compound, or a string when it names a player only), the note block
|
||||
/// sound and `custom_name`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Skull {
|
||||
pub profile: Option<horizon_world::nbt::Tag>,
|
||||
pub note_block_sound: Option<String>,
|
||||
pub custom_name: Option<horizon_world::nbt::Tag>,
|
||||
/// What `profile` draws, read from it once rather than every frame.
|
||||
pub skin: Option<SkullSkin>,
|
||||
}
|
||||
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
|
||||
/// with the `skins/<sha1>` sprite it downloads to, and the
|
||||
/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
|
||||
/// profile with no id).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SkullSkin {
|
||||
pub download: Option<(String, String)>,
|
||||
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
|
||||
/// an id only): the key `skins::Resolver` resolves it by.
|
||||
pub dynamic: Option<NameOrId>,
|
||||
pub fallback: &'static str,
|
||||
}
|
||||
/// `Either<String, UUID>`: the key a `Dynamic` `ResolvableProfile` resolves
|
||||
/// by (`ResolvableProfile.nameOrId`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum NameOrId {
|
||||
Name(String),
|
||||
Id(u128),
|
||||
}
|
||||
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
|
||||
/// and the ticks left in the crafting animation.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Crafter {
|
||||
pub items: [Option<ItemStack>; 9],
|
||||
pub disabled: [bool; 9],
|
||||
pub triggered: bool,
|
||||
pub crafting_ticks: i32,
|
||||
/// The block state's `triggered` when last ticked (not saved): the block
|
||||
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
|
||||
pub state_triggered: Option<bool>,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Banner {
|
||||
pub layers: Vec<BannerLayer>,
|
||||
pub name: Option<String>,
|
||||
/// Components not consumed by BannerBlockEntity's implicit fields.
|
||||
pub components: horizon_world::nbt::Compound,
|
||||
}
|
||||
/// `CreakingHeartBlockEntity` (the logic is in `game/creaking.rs`).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CreakingHeart {
|
||||
/// The protector: its entity id once resolved, and its UUID (the saved form).
|
||||
pub creaking: Option<(Option<u32>, [i32; 4])>,
|
||||
pub ticks_existed: i64,
|
||||
pub ticker: i32,
|
||||
/// Ticks left of the hurt effect (`emitter`) and where it heads.
|
||||
pub emitter: i32,
|
||||
pub emitter_target: Option<[f64; 3]>,
|
||||
pub output_signal: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Lectern {
|
||||
pub book: Option<ItemStack>,
|
||||
pub page: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Beacon {
|
||||
pub levels: i32,
|
||||
/// Effect ids (`minecraft:speed`).
|
||||
pub primary: Option<String>,
|
||||
pub secondary: Option<String>,
|
||||
/// Beam sections (RGB colour, height) and the ones being rebuilt.
|
||||
pub sections: Vec<(u32, i32)>,
|
||||
pub checking: Vec<(u32, i32)>,
|
||||
pub last_check_y: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Jukebox {
|
||||
pub record: Option<ItemStack>,
|
||||
/// `ticks_since_song_started`, while a song plays.
|
||||
pub ticks: i64,
|
||||
pub playing: bool,
|
||||
}
|
||||
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BrewingStand {
|
||||
pub items: [Option<ItemStack>; 5],
|
||||
pub brew_time: i32,
|
||||
pub total_brew_time: i32,
|
||||
pub fuel: i32,
|
||||
pub total_fuel: i32,
|
||||
pub speed: f32,
|
||||
pub ingredient: Option<Arc<str>>,
|
||||
pub last_bottles: Option<[bool; 3]>,
|
||||
}
|
||||
/// One sign side (`SignText`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SignText {
|
||||
pub lines: [String; 4],
|
||||
/// `DyeColor` id.
|
||||
pub color: u8,
|
||||
pub glowing: bool,
|
||||
}
|
||||
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct SignData {
|
||||
pub hanging: bool,
|
||||
pub front: SignText,
|
||||
pub back: SignText,
|
||||
pub waxed: bool,
|
||||
}
|
||||
/// `VibrationInfo`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Info {
|
||||
pub event: &'static str,
|
||||
pub distance: f32,
|
||||
pub pos: [f64; 3],
|
||||
pub source: Option<Who>,
|
||||
pub owner: Option<Who>,
|
||||
}
|
||||
/// `VibrationSelector`: the closest, then highest-frequency candidate of a tick.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Selector {
|
||||
pub(crate) current: Option<(Info, i64)>,
|
||||
}
|
||||
/// `VibrationSystem.Data`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Data {
|
||||
pub current: Option<Info>,
|
||||
pub travel: i32,
|
||||
pub selector: Selector,
|
||||
}
|
||||
/// Which sculk block entity this is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SculkKind {
|
||||
Sensor,
|
||||
Calibrated,
|
||||
Shrieker,
|
||||
Catalyst,
|
||||
}
|
||||
/// `SculkSensorBlockEntity`, `CalibratedSculkSensorBlockEntity`,
|
||||
/// `SculkShriekerBlockEntity` and `SculkCatalystBlockEntity`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SculkEntity {
|
||||
pub kind: SculkKind,
|
||||
pub vibration: Data,
|
||||
/// `last_vibration_frequency` (sensors).
|
||||
pub last_frequency: i32,
|
||||
/// `warning_level` (shriekers).
|
||||
pub warning_level: i32,
|
||||
/// The catalyst's charge cursors.
|
||||
pub spreader: SculkSpreader,
|
||||
}
|
||||
/// `SpawnData.CustomSpawnRules`: the block and sky light the spawn block
|
||||
/// must have (inclusive ranges within 0..=15).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct CustomSpawnRules {
|
||||
pub block: (i32, i32),
|
||||
pub sky: (i32, i32),
|
||||
}
|
||||
/// `SpawnData`: the entity to spawn, optional light rules and an equipment
|
||||
/// table (kept as loaded).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct SpawnData {
|
||||
pub entity: Compound,
|
||||
pub custom_spawn_rules: Option<CustomSpawnRules>,
|
||||
pub equipment: Option<Tag>,
|
||||
}
|
||||
/// Which `SpawnData` is next: one of the potentials (the same object in
|
||||
/// vanilla, so changing it changes the potential) or its own.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum Next {
|
||||
Potential(usize),
|
||||
Own(SpawnData),
|
||||
}
|
||||
/// `BaseSpawner`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Spawner {
|
||||
pub spawn_delay: i32,
|
||||
/// `SpawnPotentials`: data and weight.
|
||||
pub potentials: Vec<(SpawnData, i32)>,
|
||||
pub(crate) next: Option<Next>,
|
||||
pub min_spawn_delay: i32,
|
||||
pub max_spawn_delay: i32,
|
||||
pub spawn_count: i32,
|
||||
pub max_nearby_entities: i32,
|
||||
pub required_player_range: i32,
|
||||
pub spawn_range: i32,
|
||||
/// The client copy's delay (`spawnDelay` on the client level), which
|
||||
/// speeds the spin up as the next spawn nears.
|
||||
pub client_delay: i32,
|
||||
pub spin: f64,
|
||||
pub o_spin: f64,
|
||||
}
|
||||
/// `TrialSpawnerConfig`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TrialConfig {
|
||||
pub spawn_range: i32,
|
||||
pub total_mobs: f32,
|
||||
pub simultaneous_mobs: f32,
|
||||
pub total_mobs_added_per_player: f32,
|
||||
pub simultaneous_mobs_added_per_player: f32,
|
||||
pub ticks_between_spawn: i32,
|
||||
pub spawn_potentials: Vec<(SpawnData, i32)>,
|
||||
pub loot_tables_to_eject: Vec<(String, i32)>,
|
||||
pub items_to_drop_when_ominous: String,
|
||||
}
|
||||
/// A `Holder<TrialSpawnerConfig>` as saved: a registry id
|
||||
/// (`minecraft:trial_chamber/breeze/normal`) or an inline config.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ConfigSlot {
|
||||
/// The value as loaded, written back as is; None for the default.
|
||||
pub saved: Option<Tag>,
|
||||
pub config: TrialConfig,
|
||||
}
|
||||
/// `TrialSpawner` with its `TrialSpawnerStateData`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TrialSpawner {
|
||||
pub normal: ConfigSlot,
|
||||
pub ominous: ConfigSlot,
|
||||
pub target_cooldown_length: i32,
|
||||
pub required_player_range: i32,
|
||||
pub detected_players: Vec<Uuid>,
|
||||
pub current_mobs: Vec<Uuid>,
|
||||
pub cooldown_ends_at: i64,
|
||||
pub next_mob_spawns_at: i64,
|
||||
pub total_mobs_spawned: i32,
|
||||
pub next_spawn_data: Option<SpawnData>,
|
||||
pub ejecting_loot_table: Option<String>,
|
||||
/// `isOminous`, set from the block on every server tick.
|
||||
pub is_ominous: bool,
|
||||
/// The server's display entity exists (`getOrCreateDisplayEntity`): it is
|
||||
/// made once per loaded block entity and kept.
|
||||
pub has_display: bool,
|
||||
pub spin: f64,
|
||||
pub o_spin: f64,
|
||||
/// `getDispensingItems`, kept once rolled (not saved): the ominous items
|
||||
/// and their weights (the rolled counts).
|
||||
pub dispensing: Option<Vec<ItemStack>>,
|
||||
}
|
||||
/// `VaultConfig`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct VaultConfig {
|
||||
pub loot_table: String,
|
||||
pub activation_range: f64,
|
||||
pub deactivation_range: f64,
|
||||
/// None when the config has no key (`ItemStack.EMPTY`): the vault never opens.
|
||||
pub key_item: Option<ItemStack>,
|
||||
pub override_loot_table_to_display: Option<String>,
|
||||
}
|
||||
/// `VaultBlockEntity`: its config, server data and shared data.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Vault {
|
||||
pub config: VaultConfig,
|
||||
/// `VaultServerData`, oldest rewarded player first.
|
||||
pub rewarded_players: Vec<Uuid>,
|
||||
pub state_updating_resumes_at: i64,
|
||||
/// Popped from the end.
|
||||
pub items_to_eject: Vec<ItemStack>,
|
||||
pub total_ejections_needed: i32,
|
||||
pub last_insert_fail_timestamp: i64,
|
||||
/// `VaultSharedData`.
|
||||
pub display_item: Option<ItemStack>,
|
||||
pub connected_players: Vec<Uuid>,
|
||||
pub connected_particles_range: f64,
|
||||
/// The shared data changed since the last tick sent it.
|
||||
pub shared_dirty: bool,
|
||||
/// `VaultClientData`: the display item's spin and the previous tick's, in degrees.
|
||||
pub spin: f32,
|
||||
pub o_spin: f32,
|
||||
}
|
||||
/// `BeehiveBlockEntity`: the bees inside and the flower one of them knew.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Beehive {
|
||||
pub bees: Vec<Occupant>,
|
||||
pub flower: Option<[i32; 3]>,
|
||||
}
|
||||
/// `BeehiveBlockEntity.Occupant`: a bee's tag (with its `id`), its time in
|
||||
/// the hive and the time it stays at least.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Occupant {
|
||||
pub entity_data: Tag,
|
||||
pub ticks_in_hive: i32,
|
||||
pub min_ticks_in_hive: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CommandBlock {
|
||||
pub command: String,
|
||||
pub success_count: i32,
|
||||
/// `CustomName` as saved (a text component).
|
||||
pub custom_name: Option<Tag>,
|
||||
pub track_output: bool,
|
||||
pub last_output: Option<Text>,
|
||||
pub update_last_execution: bool,
|
||||
/// The game time of the last run, -1 for none.
|
||||
pub last_execution: i64,
|
||||
pub powered: bool,
|
||||
pub auto: bool,
|
||||
pub condition_met: bool,
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! `CommandBlockEntity` with its `BaseCommandBlock`: the command, the success
|
||||
//! count comparators read, the output it keeps, and the powered, automatic
|
||||
//! and condition flags `CommandBlock.tick` runs on.
|
||||
pub use crate::chunk::block_entity::CommandBlock;
|
||||
use crate::{
|
||||
save::{number, text},
|
||||
text::Text,
|
||||
@@ -35,6 +36,23 @@ impl Mode {
|
||||
Self::Redstone => "minecraft:command_block",
|
||||
}
|
||||
}
|
||||
/// `Enum.ordinal`, which `ServerboundSetCommandBlockPacket` writes.
|
||||
pub fn ordinal(self) -> i32 {
|
||||
match self {
|
||||
Self::Sequence => 0,
|
||||
Self::Auto => 1,
|
||||
Self::Redstone => 2,
|
||||
}
|
||||
}
|
||||
/// The mode of a packet's ordinal; `handleSetCommandBlock` treats any
|
||||
/// other value as a plain command block (its `default` branch).
|
||||
pub fn from_ordinal(ordinal: i32) -> Self {
|
||||
match ordinal {
|
||||
0 => Self::Sequence,
|
||||
1 => Self::Auto,
|
||||
_ => Self::Redstone,
|
||||
}
|
||||
}
|
||||
/// `CycleButton`: the next value, wrapping round.
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
@@ -59,22 +77,6 @@ pub fn automatic_block(name: &str) -> bool {
|
||||
name == "minecraft:chain_command_block"
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CommandBlock {
|
||||
pub command: String,
|
||||
pub success_count: i32,
|
||||
/// `CustomName` as saved (a text component).
|
||||
pub custom_name: Option<Tag>,
|
||||
pub track_output: bool,
|
||||
pub last_output: Option<Text>,
|
||||
pub update_last_execution: bool,
|
||||
/// The game time of the last run, -1 for none.
|
||||
pub last_execution: i64,
|
||||
pub powered: bool,
|
||||
pub auto: bool,
|
||||
pub condition_met: bool,
|
||||
}
|
||||
|
||||
impl Default for CommandBlock {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
|
||||
@@ -63,13 +63,24 @@ impl Confirm {
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
labels: [&Text; 2],
|
||||
) {
|
||||
self.draw_active(batch, atlas, cursor, labels, true);
|
||||
}
|
||||
/// `draw` with the buttons greyed out while `setDelay` runs (`active` false).
|
||||
pub fn draw_active(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
labels: [&Text; 2],
|
||||
active: bool,
|
||||
) {
|
||||
for (line, x, y) in &self.lines {
|
||||
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
|
||||
}
|
||||
for ((bx, by), label) in self.buttons.into_iter().zip(labels) {
|
||||
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
|
||||
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, true, hover);
|
||||
let hover = active && ui::hovered(cursor, bx, by, 150.0, 20.0);
|
||||
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, active, hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,18 @@ impl Clicks {
|
||||
self.skip_next_release = true;
|
||||
}
|
||||
|
||||
/// The screen that takes the pointer over starts with its own state:
|
||||
/// `AbstractContainerScreen` holds `isQuickCrafting` and the drag it
|
||||
/// belongs to on the screen, so a screen that opens (or closes) drops
|
||||
/// them. A press whose release never arrived -- the pointer left the
|
||||
/// window -- would otherwise keep the drag running, and `press` answers
|
||||
/// `None` for every later press, so the quick move and the clicks go
|
||||
/// dead on every screen after it.
|
||||
pub fn cancel_drag(&mut self) {
|
||||
self.quick_crafting = None;
|
||||
self.skip_next_release = false;
|
||||
}
|
||||
|
||||
/// `mouseClicked` (no double click): the `slotClicked` input it sends on
|
||||
/// `target`, if any. A press holding a stack starts a quick craft drag
|
||||
/// instead. `cloning` is `keyPickItem` with infinite materials.
|
||||
@@ -301,4 +313,35 @@ mod tests {
|
||||
Some(Input::Pickup)
|
||||
);
|
||||
}
|
||||
|
||||
/// #1737: a press whose release never arrives (the pointer left the
|
||||
/// window) leaves `isQuickCrafting` set, and `press` answers `None` for
|
||||
/// every later press, so the Shift quick move and the clicks on the
|
||||
/// inventory go dead. The drag belongs to the screen that has it
|
||||
/// (`AbstractContainerScreen` holds `isQuickCrafting`), so the screen
|
||||
/// that takes the pointer over drops it and the quick move works again.
|
||||
#[test]
|
||||
fn a_lost_release_does_not_wedge_the_quick_move() {
|
||||
let mut clicks = Clicks::default();
|
||||
let slot = Target::<usize>::Slot(3);
|
||||
// Pressed with a stack carried: a quick craft drag starts, and its
|
||||
// release never arrives.
|
||||
assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None);
|
||||
assert!(clicks.quick_crafting().is_some());
|
||||
// The stuck drag swallows the next press: the Shift quick move stops
|
||||
// working and the inventory stops answering.
|
||||
assert_eq!(clicks.press(&slot, Button::Right, true, true, false), None);
|
||||
// The screen that takes the pointer over starts with its own state.
|
||||
clicks.cancel_drag();
|
||||
assert!(clicks.quick_crafting().is_none());
|
||||
// The quick move works again.
|
||||
assert_eq!(
|
||||
clicks.press(&slot, Button::Right, true, true, false),
|
||||
Some(Input::QuickMove)
|
||||
);
|
||||
assert_eq!(
|
||||
clicks.press(&slot, Button::Left, true, false, false),
|
||||
Some(Input::Pickup)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
//! Container block entities: chests/barrels (27 slots) and the three furnace
|
||||
//! kinds, ticking as `AbstractFurnaceBlockEntity.serverTick` with fuel times
|
||||
//! from the data-driven `context_int_provider`/`context_float_provider` files.
|
||||
pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
|
||||
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
|
||||
};
|
||||
use crate::{
|
||||
crafting::Recipes,
|
||||
items::{ItemStack, Items},
|
||||
@@ -8,12 +12,6 @@ use crate::{
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path, sync::Arc};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FurnaceKind {
|
||||
Furnace,
|
||||
BlastFurnace,
|
||||
Smoker,
|
||||
}
|
||||
impl FurnaceKind {
|
||||
pub fn recipe_type(self) -> &'static str {
|
||||
match self {
|
||||
@@ -34,21 +32,6 @@ impl FurnaceKind {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Furnace {
|
||||
pub kind: FurnaceKind,
|
||||
/// input, fuel, result
|
||||
pub items: [Option<ItemStack>; 3],
|
||||
pub lit_remaining: i32,
|
||||
pub lit_total: i32,
|
||||
pub cooking_timer: i32,
|
||||
pub cooking_total: i32,
|
||||
pub speed: f32,
|
||||
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
|
||||
/// made since a player last took from its output. The experience is owed per recipe
|
||||
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
|
||||
pub recipes_used: HashMap<String, u32>,
|
||||
}
|
||||
/// The key `recipes_used` keeps a recipe under: its full id, as vanilla's `ResourceKey<Recipe>`
|
||||
/// writes it in `RecipesUsed`. Horizon's own recipe ids are file names without the namespace, so
|
||||
/// keeping both forms would write a key twice, and vanilla refuses such a furnace
|
||||
@@ -63,53 +46,6 @@ pub fn recipe_used_key(id: &str) -> String {
|
||||
/// A hopper's slots: the only plain container with five.
|
||||
pub const HOPPER_SLOTS: usize = 5;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum BlockEntity {
|
||||
Chest(Vec<Option<ItemStack>>),
|
||||
Furnace(Furnace),
|
||||
Brewing(Box<crate::brewing::BrewingStand>),
|
||||
/// Signs and hanging signs (`SignBlockEntity`).
|
||||
Sign(crate::signs::SignData),
|
||||
/// `JukeboxBlockEntity`: the record and its `JukeboxSongPlayer`.
|
||||
Jukebox(Jukebox),
|
||||
/// `BeaconBlockEntity`.
|
||||
Beacon(Box<Beacon>),
|
||||
/// `LecternBlockEntity`: the book and the open page.
|
||||
Lectern(Lectern),
|
||||
/// Sculk sensors, shriekers and catalysts (`crate::sculk`).
|
||||
Sculk(Box<crate::sculk::SculkEntity>),
|
||||
/// `CreakingHeartBlockEntity`: its protector and the hurt emitter.
|
||||
CreakingHeart(Box<CreakingHeart>),
|
||||
/// `BannerBlockEntity`: the pattern layers.
|
||||
Banner(Banner),
|
||||
/// `CrafterBlockEntity`.
|
||||
Crafter(Crafter),
|
||||
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
|
||||
Spawner(Box<crate::mobs::spawner::Spawner>),
|
||||
/// `TrialSpawnerBlockEntity` (`crate::mobs::trial_spawner`).
|
||||
TrialSpawner(Box<crate::mobs::trial_spawner::TrialSpawner>),
|
||||
/// `VaultBlockEntity` (`crate::vault`).
|
||||
Vault(Box<crate::vault::Vault>),
|
||||
/// `BeehiveBlockEntity` of beehives and bee nests (`crate::beehive`).
|
||||
Beehive(Box<crate::beehive::Beehive>),
|
||||
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
|
||||
CommandBlock(Box<crate::command_block::CommandBlock>),
|
||||
/// `CampfireBlockEntity` of campfires and soul campfires.
|
||||
Campfire(Box<Campfire>),
|
||||
/// `SkullBlockEntity` of every skull and head block.
|
||||
Skull(Skull),
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
}
|
||||
/// `CampfireBlockEntity`: four cooking slots, each with its
|
||||
/// `cookingProgress` and `cookingTime` (the recipe's).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Campfire {
|
||||
pub items: [Option<ItemStack>; 4],
|
||||
pub progress: [i32; 4],
|
||||
pub total: [i32; 4],
|
||||
}
|
||||
impl Campfire {
|
||||
/// `placeFood`'s slot: the first empty one, given the item's
|
||||
/// `campfire_cooking` time; it takes one item of `held`.
|
||||
@@ -157,17 +93,6 @@ impl Campfire {
|
||||
changed
|
||||
}
|
||||
}
|
||||
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
|
||||
/// a compound, or a string when it names a player only), the note block
|
||||
/// sound and `custom_name`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Skull {
|
||||
pub profile: Option<horizon_world::nbt::Tag>,
|
||||
pub note_block_sound: Option<String>,
|
||||
pub custom_name: Option<horizon_world::nbt::Tag>,
|
||||
/// What `profile` draws, read from it once rather than every frame.
|
||||
pub skin: Option<SkullSkin>,
|
||||
}
|
||||
impl Skull {
|
||||
pub fn new(
|
||||
profile: Option<horizon_world::nbt::Tag>,
|
||||
@@ -183,25 +108,27 @@ impl Skull {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
|
||||
/// with the `skins/<sha1>` sprite it downloads to, and the
|
||||
/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
|
||||
/// profile with no id).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SkullSkin {
|
||||
pub download: Option<(String, String)>,
|
||||
pub fallback: &'static str,
|
||||
}
|
||||
impl SkullSkin {
|
||||
pub fn of(profile: &horizon_world::nbt::Tag) -> Self {
|
||||
let download = crate::skins::profile_skin_url_nbt(profile).and_then(|url| {
|
||||
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
|
||||
@@ -209,18 +136,6 @@ impl SkullSkin {
|
||||
fn is_skull(name: &str) -> bool {
|
||||
name.ends_with("_skull") || (name.ends_with("_head") && name != "minecraft:piston_head")
|
||||
}
|
||||
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
|
||||
/// and the ticks left in the crafting animation.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Crafter {
|
||||
pub items: [Option<ItemStack>; 9],
|
||||
pub disabled: [bool; 9],
|
||||
pub triggered: bool,
|
||||
pub crafting_ticks: i32,
|
||||
/// The block state's `triggered` when last ticked (not saved): the block
|
||||
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
|
||||
pub state_triggered: Option<bool>,
|
||||
}
|
||||
impl Crafter {
|
||||
/// `slotCanBeDisabled`: only empty slots.
|
||||
pub fn slot_can_be_disabled(&self, slot: usize) -> bool {
|
||||
@@ -260,48 +175,6 @@ impl Crafter {
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Banner {
|
||||
pub layers: Vec<crate::banner::BannerLayer>,
|
||||
pub name: Option<String>,
|
||||
/// Components not consumed by BannerBlockEntity's implicit fields.
|
||||
pub components: horizon_world::nbt::Compound,
|
||||
}
|
||||
/// `CreakingHeartBlockEntity` (the logic is in `game/creaking.rs`).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CreakingHeart {
|
||||
/// The protector: its entity id once resolved, and its UUID (the saved form).
|
||||
pub creaking: Option<(Option<u32>, [i32; 4])>,
|
||||
pub ticks_existed: i64,
|
||||
pub ticker: i32,
|
||||
/// Ticks left of the hurt effect (`emitter`) and where it heads.
|
||||
pub emitter: i32,
|
||||
pub emitter_target: Option<[f64; 3]>,
|
||||
pub output_signal: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Lectern {
|
||||
pub book: Option<ItemStack>,
|
||||
pub page: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Beacon {
|
||||
pub levels: i32,
|
||||
/// Effect ids (`minecraft:speed`).
|
||||
pub primary: Option<String>,
|
||||
pub secondary: Option<String>,
|
||||
/// Beam sections (RGB colour, height) and the ones being rebuilt.
|
||||
pub sections: Vec<(u32, i32)>,
|
||||
pub checking: Vec<(u32, i32)>,
|
||||
pub last_check_y: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Jukebox {
|
||||
pub record: Option<ItemStack>,
|
||||
/// `ticks_since_song_started`, while a song plays.
|
||||
pub ticks: i64,
|
||||
pub playing: bool,
|
||||
}
|
||||
impl BlockEntity {
|
||||
pub fn for_block(name: &str) -> Option<Self> {
|
||||
if let Some(hanging) = crate::signs::sign_kind(name) {
|
||||
@@ -366,6 +239,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 +257,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 +297,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 +317,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 +325,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 +339,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 +365,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(_)
|
||||
@@ -728,6 +615,18 @@ impl Furnace {
|
||||
.recipes_used
|
||||
.entry(recipe_used_key(&recipe.id))
|
||||
.or_insert(0) += 1;
|
||||
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
|
||||
// the bucket in the fuel slot.
|
||||
if self.items[0]
|
||||
.as_ref()
|
||||
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
|
||||
&& self.items[1]
|
||||
.as_ref()
|
||||
.is_some_and(|f| &*f.item == "minecraft:bucket")
|
||||
{
|
||||
self.items[1] =
|
||||
Some(ItemStack::new("minecraft:water_bucket", 1));
|
||||
}
|
||||
if let Some(i) = self.items[0].as_mut() {
|
||||
i.count -= 1;
|
||||
if i.count == 0 {
|
||||
|
||||
@@ -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,
|
||||
@@ -179,13 +180,14 @@ impl PlayerEffects {
|
||||
self.list.clear();
|
||||
}
|
||||
/// The effects of a saved player (`Player.active_effects`: id,
|
||||
/// duration, amplifier, ambient, visible).
|
||||
/// duration, amplifier, ambient, particles visible).
|
||||
pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self {
|
||||
let mut effects = Self::default();
|
||||
for (id, duration, amplifier, ambient, _) in saved {
|
||||
if let Some(def) = brewing::effect(id) {
|
||||
for (id, duration, amplifier, ambient, particles) in saved {
|
||||
if let Some(def) = potions::effect(id) {
|
||||
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
|
||||
e.ambient = *ambient;
|
||||
e.visible = *particles;
|
||||
effects.add(e);
|
||||
}
|
||||
}
|
||||
@@ -213,8 +215,9 @@ impl PlayerEffects {
|
||||
}
|
||||
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
|
||||
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
|
||||
/// (on its last tick). Returns the actions to apply, in order, and
|
||||
/// whether Raid Omen was newly added.
|
||||
/// (on its last tick); `absorption_empty` is `AbsorptionMobEffect`'s
|
||||
/// answer: the effect ends once the absorption is used up. Returns the
|
||||
/// actions to apply, in order, and whether Raid Omen was newly added.
|
||||
///
|
||||
/// Bad Omen turning into a new Raid Omen adds an entry to the map being
|
||||
/// iterated: vanilla's iterator then throws a
|
||||
@@ -224,6 +227,7 @@ impl PlayerEffects {
|
||||
pub fn tick_with(
|
||||
&mut self,
|
||||
omen: &mut dyn FnMut(&EffectInstance) -> OmenTick,
|
||||
absorption_empty: bool,
|
||||
) -> (Vec<EffectAction>, bool) {
|
||||
self.tick_count = self.tick_count.wrapping_add(1);
|
||||
let mut actions = Vec::new();
|
||||
@@ -246,6 +250,7 @@ impl PlayerEffects {
|
||||
let answer = match e.id {
|
||||
"bad_omen" => omen(e),
|
||||
"raid_omen" if t == 1 => omen(e),
|
||||
"absorption" if absorption_empty => OmenTick::Remove,
|
||||
_ => OmenTick::Continue,
|
||||
};
|
||||
match answer {
|
||||
@@ -302,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).
|
||||
@@ -465,13 +470,13 @@ mod tests {
|
||||
};
|
||||
// The first tick adds Raid Omen and stops there (the iterator's
|
||||
// ConcurrentModificationException): Bad Omen and Speed stay as they were.
|
||||
let (_, added) = fx.tick_with(&mut convert);
|
||||
let (_, added) = fx.tick_with(&mut convert, false);
|
||||
assert!(added);
|
||||
assert_eq!(fx.get("bad_omen").unwrap().duration, 100);
|
||||
assert_eq!(fx.get("speed").unwrap().duration, 50);
|
||||
assert_eq!(fx.get("raid_omen").unwrap().duration, 600);
|
||||
// The second refreshes Raid Omen and drops Bad Omen.
|
||||
let (_, added) = fx.tick_with(&mut convert);
|
||||
let (_, added) = fx.tick_with(&mut convert, false);
|
||||
assert!(!added);
|
||||
assert!(fx.get("bad_omen").is_none());
|
||||
assert_eq!(fx.get("speed").unwrap().duration, 49);
|
||||
@@ -486,9 +491,9 @@ mod tests {
|
||||
seen.push(e.duration);
|
||||
OmenTick::Remove
|
||||
};
|
||||
fx.tick_with(&mut watch);
|
||||
fx.tick_with(&mut watch, false);
|
||||
assert!(fx.get("raid_omen").is_some());
|
||||
fx.tick_with(&mut watch);
|
||||
fx.tick_with(&mut watch, false);
|
||||
assert!(fx.get("raid_omen").is_none());
|
||||
assert_eq!(seen, vec![1]);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -582,17 +587,21 @@ impl Experience {
|
||||
7 + level * 2
|
||||
}
|
||||
}
|
||||
/// Returns true when a level was gained.
|
||||
pub fn give(&mut self, points: i32) -> bool {
|
||||
let start = self.level;
|
||||
/// Returns the first multiple of 5 the player reached, where each level
|
||||
/// gained would play the level-up sound (`Player.giveExperienceLevels`).
|
||||
pub fn give(&mut self, points: i32) -> Option<i32> {
|
||||
let mut sound_level = None;
|
||||
self.progress += points as f32 / Self::needed(self.level) as f32;
|
||||
self.total = self.total.saturating_add(points).max(0);
|
||||
while self.progress >= 1.0 {
|
||||
self.progress = (self.progress - 1.0) * Self::needed(self.level) as f32;
|
||||
self.level += 1;
|
||||
if self.level % 5 == 0 {
|
||||
sound_level.get_or_insert(self.level);
|
||||
}
|
||||
self.progress /= Self::needed(self.level) as f32;
|
||||
}
|
||||
self.level > start
|
||||
sound_level
|
||||
}
|
||||
}
|
||||
/// A painting (`Painting`, a `HangingEntity`): the block it hangs in, the
|
||||
@@ -767,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,
|
||||
@@ -788,6 +812,7 @@ impl ArmorStand {
|
||||
custom_name: None,
|
||||
name_visible: false,
|
||||
last_hit: i64::MIN / 2,
|
||||
pose: Self::DEFAULT_POSE,
|
||||
}
|
||||
}
|
||||
pub fn environment_tick(
|
||||
@@ -931,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,
|
||||
@@ -1027,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 {
|
||||
// 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);
|
||||
if in.overlay == 7u || in.overlay == 8u || in.overlay == 33u {
|
||||
// 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])];
|
||||
|
||||
@@ -13,7 +13,6 @@ use crate::{
|
||||
doubles, float, floats, item_from_nbt, item_to_nbt, number, put, read_doubles, text,
|
||||
uuid_from_tag,
|
||||
},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
@@ -187,6 +186,31 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.remove(&horizon_world::nbt::JavaString::from(key));
|
||||
}
|
||||
}
|
||||
// LivingEntity.addAdditionalSaveData: `active_effects` only when there are
|
||||
// any (MobEffectInstance.CODEC; a mob's effects are never ambient, and one
|
||||
// given with `hideParticles` shows neither particles nor its icon).
|
||||
if m.extra.effects.is_empty() {
|
||||
map.remove(&horizon_world::nbt::JavaString::from("active_effects"));
|
||||
} else {
|
||||
let effects = m
|
||||
.extra
|
||||
.effects
|
||||
.iter()
|
||||
.map(|&(effect, duration, amplifier)| {
|
||||
let shown = !m.extra.hidden_effects.contains(&effect);
|
||||
let mut e = vec![
|
||||
("id", string(crate::brewing::namespaced(effect))),
|
||||
("amplifier", Tag::Byte(amplifier.clamp(0, 255) as u8 as i8)),
|
||||
("duration", Tag::Int(duration)),
|
||||
];
|
||||
if !shown {
|
||||
e.push(("show_particles", Tag::Byte(0)));
|
||||
}
|
||||
e.push(("show_icon", Tag::Byte(i8::from(shown))));
|
||||
Tag::compound(e)
|
||||
});
|
||||
map.insert("active_effects".into(), Tag::List(effects.collect()));
|
||||
}
|
||||
map.insert(
|
||||
"Invulnerable".into(),
|
||||
Tag::Byte(i8::from(m.invulnerable_flag)),
|
||||
@@ -314,6 +338,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" {
|
||||
@@ -466,7 +502,38 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
crate::mobs::villager::write_nbt(m, map);
|
||||
}
|
||||
"minecraft:wandering_trader" => put_offers(map, m),
|
||||
"minecraft:axolotl" => write_axolotl_memories(m, map),
|
||||
"minecraft:axolotl" => {
|
||||
write_axolotl_memories(m, map);
|
||||
map.insert(
|
||||
"Variant".into(),
|
||||
Tag::Int(i32::from(m.extra.variant.min(4))),
|
||||
);
|
||||
}
|
||||
"minecraft:parrot" => {
|
||||
map.insert(
|
||||
"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)];
|
||||
map.insert("type".into(), string(size));
|
||||
}
|
||||
// TropicalFish.addAdditionalSaveData: the packed variant.
|
||||
"minecraft:tropical_fish" => {
|
||||
let pattern = m.extra.variant.min(11);
|
||||
let packed = i32::from(pattern >= 6)
|
||||
| i32::from(pattern % 6) << 8
|
||||
| i32::from(m.extra.variant2 & 15) << 16
|
||||
| i32::from(m.extra.variant3 & 15) << 24;
|
||||
map.insert("Variant".into(), Tag::Int(packed));
|
||||
}
|
||||
"minecraft:allay" => {
|
||||
// InventoryCarrier: the fetched stack; the liked player memory.
|
||||
let mut inv = Vec::new();
|
||||
@@ -490,7 +557,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
"memories",
|
||||
Tag::compound([(
|
||||
"minecraft:liked_player",
|
||||
Tag::compound([("value", Tag::IntArray(vec![0, 0, 0, 1]))]),
|
||||
Tag::compound([(
|
||||
"value",
|
||||
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
|
||||
)]),
|
||||
)]),
|
||||
)]),
|
||||
);
|
||||
@@ -527,6 +597,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));
|
||||
@@ -540,6 +614,9 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.insert("life_ticks".into(), Tag::Int(m.extra.egg_time));
|
||||
}
|
||||
}
|
||||
"minecraft:endermite" => {
|
||||
map.insert("Lifetime".into(), Tag::Int(m.extra.counter));
|
||||
}
|
||||
"minecraft:shulker" => {
|
||||
map.insert("AttachFace".into(), Tag::Byte(m.extra.variant3 as i8));
|
||||
map.insert("Peek".into(), Tag::Byte(m.extra.counter as i8));
|
||||
@@ -646,6 +723,17 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
|
||||
continue;
|
||||
}
|
||||
// A wolf's armor keeps the durability it lost.
|
||||
if slot == "body"
|
||||
&& m.species() == crate::mobs::Species::Wolf
|
||||
&& let Some(id) = item
|
||||
{
|
||||
let mut stack = crate::items::ItemStack::new(id, 1);
|
||||
stack.damage = m.extra.body_damage.max(0) as u32;
|
||||
stack.extra = m.extra.body_extra.clone();
|
||||
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
|
||||
continue;
|
||||
}
|
||||
match item {
|
||||
Some(id) => {
|
||||
let keep = matches!(
|
||||
@@ -785,14 +873,7 @@ fn custom_name(tag: &Tag) -> Option<String> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
|
||||
/// else is a component (`ComponentSerialization`).
|
||||
pub fn name_text(tag: &Tag) -> Text {
|
||||
match tag {
|
||||
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
|
||||
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
|
||||
}
|
||||
}
|
||||
pub use horizon_client_core::component::name_text;
|
||||
/// Mob.finalizeSpawn's follow range bonus in the saved attributes.
|
||||
pub(crate) fn spawn_bonus(tag: &Tag) -> Option<f64> {
|
||||
let Some(Tag::List(attrs)) = tag.get("attributes") else {
|
||||
@@ -900,6 +981,25 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.no_gravity = flag(tag, "NoGravity");
|
||||
m.glowing = flag(tag, "Glowing");
|
||||
m.ticks_frozen = number(tag.get("TicksFrozen")).unwrap_or(0) as i32;
|
||||
// LivingEntity.readAdditionalSaveData: the saved `active_effects`, the last
|
||||
// entry of an effect winning.
|
||||
if let Some(Tag::List(effects)) = tag.get("active_effects") {
|
||||
for e in effects {
|
||||
let Some(def) = text(e.get("id")).and_then(|id| crate::brewing::effect(&id)) else {
|
||||
continue;
|
||||
};
|
||||
let effect = crate::brewing::namespaced(def.id);
|
||||
let duration = number(e.get("duration")).unwrap_or(0) as i32;
|
||||
let amplifier = number(e.get("amplifier")).unwrap_or(0) as i32;
|
||||
m.extra.effects.retain(|x| x.0 != effect);
|
||||
m.extra.effects.push((effect, duration, amplifier));
|
||||
// Details.CODEC: `show_particles` defaults to true.
|
||||
m.extra.hidden_effects.retain(|h| *h != effect);
|
||||
if number(e.get("show_particles")) == Some(0) {
|
||||
m.extra.hidden_effects.push(effect);
|
||||
}
|
||||
}
|
||||
}
|
||||
m.silent = flag(tag, "Silent");
|
||||
m.invulnerable_flag = flag(tag, "Invulnerable");
|
||||
if m.species() == crate::mobs::Species::Bat {
|
||||
@@ -1021,6 +1121,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"));
|
||||
@@ -1028,6 +1138,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
crate::mobs::tame::set_tame(&mut m, tame);
|
||||
m.health = health;
|
||||
m.extra.sitting = flag(tag, "Sitting");
|
||||
let armor = tag
|
||||
.get("equipment")
|
||||
.and_then(|e| e.get("body"))
|
||||
.and_then(crate::save::item_from_nbt);
|
||||
m.extra.body_damage = armor.as_ref().map_or(0, |s| s.damage as i32);
|
||||
m.extra.body_extra = armor.and_then(|s| s.extra);
|
||||
m.extra.color =
|
||||
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
|
||||
}
|
||||
@@ -1048,6 +1164,7 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
if m.species() == crate::mobs::Species::Axolotl {
|
||||
restore_axolotl_memories(&mut m, tag);
|
||||
}
|
||||
read_variant(&mut m, tag);
|
||||
m.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
|
||||
m.air = number(tag.get("Air")).unwrap_or(300) as i32;
|
||||
if m.species() == crate::mobs::Species::Allay {
|
||||
@@ -1057,11 +1174,14 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.ints[1] = number(first.get("count")).unwrap_or(1) as i32;
|
||||
}
|
||||
m.extra.egg_time = number(tag.get("DuplicationCooldown")).unwrap_or(0) as i32;
|
||||
m.extra.tame = tag
|
||||
let liked = tag
|
||||
.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
.and_then(|b| b.get("minecraft:liked_player"))
|
||||
.is_some();
|
||||
.and_then(|b| b.get("minecraft:liked_player"));
|
||||
m.extra.tame = liked.is_some();
|
||||
m.extra.owner = liked
|
||||
.and_then(|l| l.get("value"))
|
||||
.and_then(|v| crate::save::uuid_from_tag(Some(v)));
|
||||
}
|
||||
if crate::mobs::camel::is_camel(m.species()) {
|
||||
let sitting = matches!(tag.get("LastPoseTick"), Some(Tag::Long(v)) if *v < 0);
|
||||
@@ -1128,11 +1248,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
|
||||
@@ -1223,6 +1352,11 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"Thrower".into());
|
||||
}
|
||||
if let Some(target) = e.target {
|
||||
put(&mut tag, "Owner", Tag::IntArray(target.to_vec()));
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"Owner".into());
|
||||
}
|
||||
tag
|
||||
}
|
||||
pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemEntity> {
|
||||
@@ -1234,6 +1368,7 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
|
||||
id,
|
||||
stack,
|
||||
thrower: uuid_from_tag(tag.get("Thrower")),
|
||||
target: uuid_from_tag(tag.get("Owner")),
|
||||
position,
|
||||
previous: position,
|
||||
velocity: motion(tag),
|
||||
@@ -1752,6 +1887,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 [
|
||||
@@ -1791,6 +1935,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`...):
|
||||
@@ -2121,6 +2278,7 @@ mod tests {
|
||||
health: 2,
|
||||
fire_ticks: 73,
|
||||
thrower: Some([5, 6, 7, 8]),
|
||||
target: None,
|
||||
id: 7,
|
||||
stack: ItemStack {
|
||||
item: Arc::from("minecraft:diamond_pickaxe"),
|
||||
@@ -2229,6 +2387,41 @@ fn write_axolotl_memories(m: &Mob, map: &mut Compound) {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The variant tags `Axolotl`, `Rabbit`, `Parrot`, `Salmon` and
|
||||
/// `TropicalFish` read; a missing tag is the default variant (a medium
|
||||
/// salmon, otherwise index 0).
|
||||
fn read_variant(m: &mut Mob, tag: &Tag) {
|
||||
use crate::mobs::Species as S;
|
||||
let int = |key| number(tag.get(key)).unwrap_or(0) as i32;
|
||||
match m.species() {
|
||||
S::Axolotl | S::Parrot => m.extra.variant = int("Variant").clamp(0, 4) as u8,
|
||||
// Rabbit.Variant.LEGACY_CODEC: an unknown id is brown.
|
||||
S::Rabbit => {
|
||||
m.extra.variant = match int("RabbitType") {
|
||||
v @ (0..=5 | 99) => v as u8,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
S::Salmon => {
|
||||
m.extra.variant = text(tag.get("type"))
|
||||
.and_then(|t| SALMON_SIZES.iter().position(|v| *v == t))
|
||||
.unwrap_or(1) as u8;
|
||||
}
|
||||
S::TropicalFish => {
|
||||
let packed = int("Variant");
|
||||
let (size, index) = (packed & 0xFF, (packed >> 8) & 0xFF);
|
||||
// TropicalFish.Pattern.byId: an unknown id is a kob.
|
||||
m.extra.variant = if size <= 1 && index < 6 {
|
||||
(size * 6 + index) as u8
|
||||
} else {
|
||||
0
|
||||
};
|
||||
m.extra.variant2 = ((packed >> 16) & 15) as u8;
|
||||
m.extra.variant3 = ((packed >> 24) & 15) as u8;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn restore_axolotl_memories(m: &mut Mob, tag: &Tag) {
|
||||
let memories = tag.get("Brain").and_then(|b| b.get("memories"));
|
||||
let ttl = memories
|
||||
|
||||
@@ -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))
|
||||
@@ -426,18 +443,7 @@ impl UseResult {
|
||||
matches!(self, Self::Success { swing: true }) && before == after
|
||||
}
|
||||
}
|
||||
pub fn consumable_animation(v: &serde_json::Value) -> horizon_world::consumable::UseAnimation {
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
match v
|
||||
.get("animation")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("eat")
|
||||
{
|
||||
"eat" => UseAnimation::Eat,
|
||||
"drink" => UseAnimation::Drink,
|
||||
_ => UseAnimation::None,
|
||||
}
|
||||
}
|
||||
pub use horizon_items::use_animation::consumable_animation;
|
||||
/// What the click handling reads and does in the world.
|
||||
pub trait HandWorld {
|
||||
fn hit(&mut self) -> Hit;
|
||||
|
||||
@@ -889,12 +889,17 @@ impl Font {
|
||||
if s.contains('§') {
|
||||
return self.width_text(&Text::legacy(s));
|
||||
}
|
||||
s.chars().map(|c| self.glyph(c).advance).sum::<f32>().ceil()
|
||||
crate::bidi::visual_str(s)
|
||||
.chars()
|
||||
.map(|c| self.glyph(c).advance)
|
||||
.sum::<f32>()
|
||||
.ceil()
|
||||
}
|
||||
/// Font.width of a component: `§` codes in the spans are formatting,
|
||||
/// not glyphs.
|
||||
/// not glyphs. Right-to-left text is measured as it is drawn, shaped.
|
||||
pub fn width_text(&self, t: &Text) -> f32 {
|
||||
t.resolved()
|
||||
let t = t.resolved();
|
||||
crate::bidi::visual(&t)
|
||||
.chars()
|
||||
.map(|(c, s)| self.advance(c, s))
|
||||
.sum::<f32>()
|
||||
|
||||
+1033
-286
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,101 @@ impl Game<'_> {
|
||||
let it = item_view(stack);
|
||||
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
|
||||
}
|
||||
/// `ConstructBeaconTrigger`: the pyramid levels of the new beacon.
|
||||
pub(super) fn trigger_construct_beacon(&mut self, levels: i32) {
|
||||
let me = self.player_entity();
|
||||
self.trigger("minecraft:construct_beacon", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w) && adv::range_i(&c["level"], i64::from(levels))
|
||||
});
|
||||
}
|
||||
/// `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();
|
||||
let villager = villager.clone();
|
||||
let it = item_view(stack);
|
||||
self.trigger("minecraft:villager_trade", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let vctx = adv::Entities {
|
||||
this: Some(&villager),
|
||||
..pctx
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::conditions(&c["villager"], &vctx, w)
|
||||
&& adv::item(&c["item"], &it, w)
|
||||
});
|
||||
}
|
||||
/// `FishingRodHookedTrigger`: the rod, what the hook is in (or the hook)
|
||||
/// and the items it caught.
|
||||
pub(super) fn trigger_fishing_rod_hooked(
|
||||
&mut self,
|
||||
rod: &ItemStack,
|
||||
hooked: &EntityView,
|
||||
items: &[ItemStack],
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let rod = item_view(rod);
|
||||
let hooked = hooked.clone();
|
||||
let items: Vec<ItemView> = items.iter().map(item_view).collect();
|
||||
self.trigger("minecraft:fishing_rod_hooked", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let ectx = adv::Entities {
|
||||
this: Some(&hooked),
|
||||
..pctx
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::item(&c["rod"], &rod, w)
|
||||
&& adv::conditions(&c["entity"], &ectx, w)
|
||||
&& (c["item"].is_null() || items.iter().any(|i| adv::item(&c["item"], i, w)))
|
||||
});
|
||||
}
|
||||
/// `EnterBlockTrigger`: the block, its state properties and the player.
|
||||
pub(super) fn trigger_enter_block(&mut self, state: horizon_world::registry::BlockStateId) {
|
||||
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
|
||||
@@ -806,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),
|
||||
@@ -867,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
|
||||
@@ -1053,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,
|
||||
@@ -131,7 +162,8 @@ impl Game<'_> {
|
||||
let m = &mut self.mobs[i];
|
||||
m.extra.ints[1] -= 1;
|
||||
m.extra.ints[3] = 60;
|
||||
self.spawn_item(ItemStack::new(&item, 1), from, v, 40);
|
||||
// ItemEntity.setDefaultPickUpDelay.
|
||||
self.spawn_item(ItemStack::new(&item, 1), from, v, 10);
|
||||
if self.world.game_time % 7 == 0 && self.world.random.next_double() < 0.9 {
|
||||
const PITCHES: [f32; 16] = [
|
||||
0.5625, 0.625, 0.75, 0.9375, 1.0, 1.0, 1.125, 1.25, 1.5, 1.875, 2.0, 2.25, 2.5,
|
||||
@@ -160,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();
|
||||
@@ -185,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`).
|
||||
@@ -547,7 +553,7 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
/// `Entity.isInRain`: rain at the block position or at the top of the box.
|
||||
fn in_rain(&mut self, at: Vec3, top: f64) -> bool {
|
||||
pub(super) fn in_rain(&mut self, at: Vec3, top: f64) -> bool {
|
||||
let p = BlockPos::new(
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,60 @@
|
||||
//! shovels) put on a lit campfire puts it out.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, animate_tick::make_campfire_smoke, items::ItemStack};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::chunk::BlockState;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The lit campfire at `pos` that `held` puts out, when the item is in
|
||||
/// `#minecraft:douses_campfires` (`CampfireBlock.useItemOn`).
|
||||
fn campfire_to_douse(&self, pos: [i32; 3], held: Option<&ItemStack>) -> Option<BlockState> {
|
||||
held.filter(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:douses_campfires")
|
||||
.is_some_and(|members| members.contains(&*s.item))
|
||||
})?;
|
||||
self.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
|
||||
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// `CampfireBlock.useItemOn` as this client runs it before the server's
|
||||
/// answer (`MultiPlayerGameMode.useItemOn`): the twenty smoke puffs of
|
||||
/// `CampfireBlock.douse` and the campfire gone out, kept as predicted
|
||||
/// under `seq`. The level event's sound comes from the server. True when
|
||||
/// it doused (SUCCESS), which ends the click for both hands.
|
||||
pub(super) fn predict_campfire_douse(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
held: Option<&ItemStack>,
|
||||
seq: i32,
|
||||
) -> bool {
|
||||
// Player.isSecondaryUseActive: a held item means both hands count.
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) {
|
||||
return false;
|
||||
}
|
||||
let Some(mut state) = self.campfire_to_douse(pos, held) else {
|
||||
return false;
|
||||
};
|
||||
let signal = state.properties.get("signal_fire").map(String::as_str) == Some("true");
|
||||
for _ in 0..20 {
|
||||
make_campfire_smoke(&mut self.particles, pos, signal, true);
|
||||
}
|
||||
state.properties.insert("lit".into(), "false".into());
|
||||
if let Ok(unlit) = self.pack.assets.registry.resolve(&state) {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.predict_blocks(&vec![(at, unlit)], seq);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `CampfireBlock.useItemOn` with `held` in the hand that acts, the block
|
||||
/// at `pos` clicked: true when it doused a lit campfire (SUCCESS).
|
||||
pub(super) fn use_campfire_douse(
|
||||
@@ -11,24 +63,11 @@ impl Game<'_> {
|
||||
pos: [i32; 3],
|
||||
held: Option<&ItemStack>,
|
||||
) -> Result<bool> {
|
||||
let Some(tool) = held.filter(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:douses_campfires")
|
||||
.is_some_and(|members| members.contains(&*s.item))
|
||||
}) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some(mut state) = registry
|
||||
.state(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
|
||||
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
|
||||
.cloned()
|
||||
let (Some(used), Some(mut state)) = (held.cloned(), self.campfire_to_douse(pos, held))
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
let registry = self.pack.assets.registry;
|
||||
// Level event 1009 (SOUND_EXTINGUISH_FIRE) goes to every player.
|
||||
self.level_event_at(1009, pos, 0);
|
||||
// CampfireBlock.douse: the acting client's own smoke (twenty puffs),
|
||||
@@ -44,7 +83,6 @@ impl Game<'_> {
|
||||
if let Ok(unlit) = registry.resolve(&state) {
|
||||
self.level_set(pos, unlit)?;
|
||||
}
|
||||
let used = tool.clone();
|
||||
self.damage_held(1);
|
||||
// ServerPlayerGameMode.useItemOn: the action was consumed.
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, &used);
|
||||
|
||||
@@ -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) {
|
||||
@@ -789,6 +998,11 @@ impl Game<'_> {
|
||||
pub(super) fn sync_rules(&mut self) {
|
||||
self.world.health_rules.natural_regeneration =
|
||||
self.ui.rules.bool("natural_health_regeneration");
|
||||
// Player.isInvulnerableTo: the damage-type rules.
|
||||
self.world.health_rules.drowning_damage = self.ui.rules.bool("drowning_damage");
|
||||
self.world.health_rules.fall_damage = self.ui.rules.bool("fall_damage");
|
||||
self.world.health_rules.fire_damage = self.ui.rules.bool("fire_damage");
|
||||
self.world.health_rules.freeze_damage = self.ui.rules.bool("freeze_damage");
|
||||
use horizon_world::food::Difficulty;
|
||||
self.world.health_rules.difficulty = match self.ui.difficulty {
|
||||
0 => Difficulty::Peaceful,
|
||||
@@ -801,7 +1015,11 @@ impl Game<'_> {
|
||||
pub(super) fn load_world_state(&mut self) {
|
||||
self.ui.rules = GameRules::load(&self.world_root);
|
||||
self.ui.clock = WorldClock::default();
|
||||
self.ui.difficulty = read_difficulty(&self.world_root).unwrap_or(2);
|
||||
// PrimaryLevelData: the difficulty comes back from level.dat's difficulty_settings.
|
||||
self.ui.difficulty = horizon_client::title::DIFFICULTIES
|
||||
.iter()
|
||||
.position(|d| *d == self.level.difficulty)
|
||||
.map_or(2, |i| i as u8);
|
||||
if let Ok(bytes) = std::fs::read(self.world_root.join("data/minecraft/world_clocks.dat"))
|
||||
&& let Ok(raw) = horizon_world::compression::Compression::Gzip.decode(&bytes, 1 << 20)
|
||||
&& let Ok((root, _)) =
|
||||
@@ -826,9 +1044,9 @@ impl Game<'_> {
|
||||
self.build_registries();
|
||||
self.sync_rules();
|
||||
}
|
||||
/// Save the clock and the difficulty beside level.dat (Level.write saves
|
||||
/// the game rules: a second writer of game_rules.dat could leave it with
|
||||
/// the rules loaded when the world opened).
|
||||
/// Save the clock beside level.dat (Level.write saves the game rules: a
|
||||
/// second writer of game_rules.dat could leave it with the rules loaded
|
||||
/// when the world opened).
|
||||
pub(super) fn save_world_state(&mut self) {
|
||||
if self.world_root.as_os_str().is_empty() {
|
||||
return;
|
||||
@@ -876,9 +1094,6 @@ impl Game<'_> {
|
||||
if let Err(e) = horizon_client::save::write_dat(&path, root) {
|
||||
log::warn!("the world clocks were not written: {e}");
|
||||
}
|
||||
if let Err(e) = write_difficulty(&self.world_root, self.ui.difficulty) {
|
||||
log::warn!("the difficulty was not written: {e}");
|
||||
}
|
||||
}
|
||||
/// Death message in chat (CombatTracker.getDeathMessage, as far as the
|
||||
/// cause is known to the client).
|
||||
@@ -886,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;
|
||||
}
|
||||
@@ -988,60 +1204,9 @@ pub fn screenshot_name(dir: &std::path::Path) -> String {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
fn read_difficulty(world: &std::path::Path) -> Option<u8> {
|
||||
let bytes = std::fs::read(world.join("level.dat")).ok()?;
|
||||
let raw = horizon_world::compression::Compression::Gzip
|
||||
.decode(&bytes, 64 << 20)
|
||||
.ok()?;
|
||||
let (root, _) =
|
||||
horizon_world::nbt::read_named(&raw, horizon_world::nbt::Limits::default()).ok()?;
|
||||
match root.tag.get("Data")?.get("Difficulty")? {
|
||||
horizon_world::nbt::Tag::Byte(b) => Some((*b).clamp(0, 3) as u8),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Patch `Data.Difficulty` in level.dat (Level::write stores the default).
|
||||
fn write_difficulty(world: &std::path::Path, difficulty: u8) -> horizon_client::Result<()> {
|
||||
use horizon_world::{compression::Compression, nbt};
|
||||
let path = world.join("level.dat");
|
||||
let bytes = std::fs::read(&path)?;
|
||||
let raw = Compression::Gzip.decode(&bytes, 64 << 20)?;
|
||||
let (mut root, _) = nbt::read_named(&raw, nbt::Limits::default()).map_err(|e| e.to_string())?;
|
||||
if let Some(data) = root.tag.get_mut("Data")
|
||||
&& let Some(d) = data.get_mut("Difficulty")
|
||||
{
|
||||
*d = nbt::Tag::Byte(difficulty as i8);
|
||||
}
|
||||
let out = nbt::write_named(&root, nbt::Limits::default()).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&path, Compression::Gzip.encode(&out)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `IntegratedServer.getProfilePermissions` for the owner: `OWNER` (4) when
|
||||
/// commands are allowed, else `ALL` (0).
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
//! The player's `CombatTracker`: the damage taken lately, who dealt it and
|
||||
//! where the player fell from, turned into the vanilla death message.
|
||||
use super::{Game, chat::PLAYER_NAME};
|
||||
use horizon_client::{health::HealthEvent, motion::MotionEvent, text::Text};
|
||||
use horizon_client::{
|
||||
health::HealthEvent,
|
||||
motion::MotionEvent,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
/// `EntityEvent.DROWN_PARTICLES`: broadcast with the drowning damage.
|
||||
pub(super) const DROWN_EVENT: i8 = 67;
|
||||
|
||||
struct Entry {
|
||||
damage_type: String,
|
||||
@@ -33,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.
|
||||
@@ -45,6 +65,38 @@ pub(super) struct CombatTracker {
|
||||
fall_before: f64,
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `LivingEntity.makeDrownParticles`: eight bubbles around the entity,
|
||||
/// moving with it.
|
||||
pub(super) fn drown_particles(&mut self, at: [f64; 3], velocity: [f64; 3]) {
|
||||
let bubble = ParticleOptions::simple(ParticleType::Bubble);
|
||||
for _ in 0..8 {
|
||||
let mut triangle = || {
|
||||
let r = self.particles.random();
|
||||
r.next_double() - r.next_double()
|
||||
};
|
||||
let p = [at[0] + triangle(), at[1] + triangle(), at[2] + triangle()];
|
||||
self.particles.add(&bubble, p, velocity);
|
||||
}
|
||||
}
|
||||
/// Entity event 67 from a server, for this client's own player or an
|
||||
/// entity it draws.
|
||||
pub(super) fn remote_drown(&mut self, id: i32) {
|
||||
let own = self.remote.as_ref().is_some_and(|r| r.entity_id == id);
|
||||
let body = if own {
|
||||
self.player.as_ref().map(|p| {
|
||||
let (at, v) = (p.position, p.velocity);
|
||||
([at.x, at.y, at.z], [v.x, v.y, v.z])
|
||||
})
|
||||
} else {
|
||||
self.mobs.iter().find(|m| m.id == id as u32).map(|m| {
|
||||
let (at, v) = (m.position, m.velocity);
|
||||
([at.x, at.y, at.z], [v.x, v.y, v.z])
|
||||
})
|
||||
};
|
||||
if let Some((at, v)) = body {
|
||||
self.drown_particles(at, v);
|
||||
}
|
||||
}
|
||||
/// The display name of a mob: its custom name or its type's name.
|
||||
pub(super) fn mob_display_name(&self, id: u32) -> Option<Text> {
|
||||
let m = self.mobs.iter().find(|m| m.id == id)?;
|
||||
@@ -82,14 +134,27 @@ impl Game<'_> {
|
||||
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
|
||||
/// source's `DamageEffects` (`Player.getHurtSound`).
|
||||
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
|
||||
let pitch = self.audio.hurt_pitch();
|
||||
self.audio.play(sound, Some(at), 1.0, pitch);
|
||||
// Player.getVoicePitch: always 1.0.
|
||||
self.audio.play(sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
/// `CombatTracker.recordDamage` for the motion events of this tick.
|
||||
pub(super) fn record_combat(&mut self, events: &[MotionEvent]) {
|
||||
self.recheck_combat();
|
||||
for e in events {
|
||||
let (name, amount) = match e {
|
||||
// LivingEntity.getDamageAfterMagicAbsorb (Resistance) and
|
||||
// Player.actuallyHurt (absorption hearts) and
|
||||
// LivingEntity.hurtServer (the shield, only for an accepted
|
||||
// hit), in tenths.
|
||||
MotionEvent::Health(HealthEvent::Statistic { name, amount }) => {
|
||||
match *name {
|
||||
"resisted" => self.stats.custom("damage_resisted", *amount),
|
||||
"absorbed" => self.stats.custom("damage_absorbed", *amount),
|
||||
"blocked" => self.stats.custom("damage_blocked_by_shield", *amount),
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
MotionEvent::Health(HealthEvent::RecordedDamage { source, amount }) => {
|
||||
let Some(d) = self.motion.health.damage.definition(*source) else {
|
||||
continue;
|
||||
@@ -136,6 +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();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! tick (`execute`, `executeChain`), `BaseCommandBlock.performCommand`
|
||||
//! through the command system with the block's output, and
|
||||
//! `CommandBlockEditScreen` with what `handleSetCommandBlock` does with it.
|
||||
use super::{Game, Screen, commands::execute::Source};
|
||||
use super::{Game, Screen, commands::execute::Source, lan::LanHost};
|
||||
use horizon_client::{
|
||||
command_block::{self, CommandBlock, Mode},
|
||||
containers::BlockEntity,
|
||||
@@ -12,6 +12,7 @@ use horizon_client::{
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
use horizon_world::{
|
||||
chunk::BlockState,
|
||||
level::flags,
|
||||
@@ -35,6 +36,16 @@ pub(super) struct CommandBlockScreen {
|
||||
auto: bool,
|
||||
}
|
||||
|
||||
/// What `ServerboundSetCommandBlockPacket` carries.
|
||||
struct CommandBlockEdit {
|
||||
pos: Pos,
|
||||
command: String,
|
||||
track_output: bool,
|
||||
mode: Mode,
|
||||
conditional: bool,
|
||||
auto: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Button {
|
||||
Output,
|
||||
@@ -186,6 +197,18 @@ impl Game<'_> {
|
||||
pub(super) fn can_use_game_master_blocks(&self) -> bool {
|
||||
self.creative && self.ui.registries.permission >= 2
|
||||
}
|
||||
/// `CommandBlock.useWithoutItem` on a server's client, which opens the
|
||||
/// screen itself (`Player.openCommandBlock`) while the server only
|
||||
/// answers the click: the block at `pos`, when it is a command block the
|
||||
/// player may edit.
|
||||
pub(super) fn open_command_block_if_editable(&mut self, pos: Pos) {
|
||||
let is_command_block = self
|
||||
.block_state_at(pos)
|
||||
.is_some_and(|state| Mode::of_block(&state.name).is_some());
|
||||
if is_command_block && self.can_use_game_master_blocks() {
|
||||
self.open_command_block(pos);
|
||||
}
|
||||
}
|
||||
fn command_block(&self, pos: Pos) -> Option<&CommandBlock> {
|
||||
match self.world.block_entities.get(&pos)? {
|
||||
BlockEntity::CommandBlock(c) => Some(c),
|
||||
@@ -572,12 +595,66 @@ impl Game<'_> {
|
||||
s.command.insert_text(&allowed);
|
||||
}
|
||||
}
|
||||
/// `ServerGamePacketListenerImpl.handleSetCommandBlock`.
|
||||
/// The edit screen's Done: a server's client sends the packet, the
|
||||
/// window's own world applies it (`handleSetCommandBlock`).
|
||||
fn set_command_block(&mut self, s: &CommandBlockScreen) {
|
||||
if !self.can_use_game_master_blocks() {
|
||||
self.system_message(Text::translate("advMode.notAllowed", &[]));
|
||||
return;
|
||||
}
|
||||
let edit = CommandBlockEdit {
|
||||
pos: s.pos,
|
||||
command: s.command.value.clone(),
|
||||
track_output: s.track_output,
|
||||
mode: s.mode,
|
||||
conditional: s.conditional,
|
||||
auto: s.auto,
|
||||
};
|
||||
if self.remote.is_some() {
|
||||
self.send(S::SetCommandBlock(play::SetCommandBlock {
|
||||
pos: [edit.pos.0, edit.pos.1, edit.pos.2],
|
||||
command: edit.command,
|
||||
mode: edit.mode.ordinal(),
|
||||
flags: i8::from(edit.track_output)
|
||||
| (i8::from(edit.conditional) << 1)
|
||||
| (i8::from(edit.auto) << 2),
|
||||
}));
|
||||
} else {
|
||||
self.apply_command_block(&edit);
|
||||
}
|
||||
}
|
||||
/// `ServerGamePacketListenerImpl.handleSetCommandBlock` for a guest: the
|
||||
/// block is edited only by a creative player with game master commands.
|
||||
pub(super) fn lan_set_command_block(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
p: play::SetCommandBlock,
|
||||
) {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let allowed = g.creative() && g.permission >= 2;
|
||||
let edit = CommandBlockEdit {
|
||||
pos: (p.pos[0], p.pos[1], p.pos[2]),
|
||||
command: p.command,
|
||||
track_output: p.flags & 1 != 0,
|
||||
mode: Mode::from_ordinal(p.mode),
|
||||
conditional: p.flags & 2 != 0,
|
||||
auto: p.flags & 4 != 0,
|
||||
};
|
||||
self.as_guest(host, conn, |game| {
|
||||
if allowed {
|
||||
game.apply_command_block(&edit);
|
||||
// BaseCommandBlock.onUpdated: the clients get the new command.
|
||||
game.mark_block_entity_updated(edit.pos);
|
||||
} else {
|
||||
game.system_message(Text::translate("advMode.notAllowed", &[]));
|
||||
}
|
||||
});
|
||||
}
|
||||
/// What `handleSetCommandBlock` does once the player may edit the block.
|
||||
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
|
||||
let pos = s.pos;
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some(current) = self.block_state_at(pos) else {
|
||||
@@ -609,7 +686,7 @@ impl Game<'_> {
|
||||
}
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
let command = s.command.value.clone();
|
||||
let command = s.command.clone();
|
||||
let Some(c) = command_block_mut(&mut self.world, pos) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -42,9 +42,10 @@ pub(super) enum Target {
|
||||
Guest(u32),
|
||||
Mob(u32),
|
||||
Item(u32),
|
||||
Orb(usize),
|
||||
/// By index in `creatures.projectiles`.
|
||||
Projectile(usize),
|
||||
/// An experience orb, by its entity id.
|
||||
Orb(u32),
|
||||
/// A projectile, by its entity id.
|
||||
Projectile(u32),
|
||||
Stand(u32),
|
||||
Frame(u32),
|
||||
Painting(u32),
|
||||
@@ -53,6 +54,10 @@ pub(super) enum Target {
|
||||
/// An entity of a type Horizon does not simulate (markers, displays,
|
||||
/// interactions...): kept as its saved data at a position.
|
||||
Other(u32),
|
||||
/// An End crystal, by its entity id (`minecraft:end_crystal`).
|
||||
Crystal(u32),
|
||||
/// The Ender Dragon, by its entity id (`minecraft:ender_dragon`).
|
||||
Dragon(u32),
|
||||
}
|
||||
/// An entity of a type Horizon does not simulate: its compound lives in the
|
||||
/// entity memory (`persist.raw`), its position and rotation here.
|
||||
@@ -340,14 +345,16 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|i| i.id == id)
|
||||
.map(|i| [i.position.x, i.position.y, i.position.z]),
|
||||
Target::Orb(i) => self
|
||||
Target::Orb(id) => self
|
||||
.orbs
|
||||
.get(i)
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map(|o| [o.position.x, o.position.y, o.position.z]),
|
||||
Target::Projectile(i) => self
|
||||
Target::Projectile(id) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get(i)
|
||||
.iter()
|
||||
.find(|a| a.id == id)
|
||||
.map(|a| [a.position.x, a.position.y, a.position.z]),
|
||||
Target::Stand(id) => self
|
||||
.armor_stands
|
||||
@@ -385,6 +392,16 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map(|o| [o.position.x, o.position.y, o.position.z]),
|
||||
Target::Crystal(id) => self
|
||||
.crystals
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| [c.position.x, c.position.y, c.position.z]),
|
||||
Target::Dragon(id) => self
|
||||
.dragon
|
||||
.as_ref()
|
||||
.filter(|d| d.id == id)
|
||||
.map(|d| [d.position.x, d.position.y, d.position.z]),
|
||||
}
|
||||
}
|
||||
pub(super) fn target_type(&self, t: Target) -> String {
|
||||
@@ -399,10 +416,11 @@ impl Game<'_> {
|
||||
}),
|
||||
Target::Item(_) => "minecraft:item".into(),
|
||||
Target::Orb(_) => "minecraft:experience_orb".into(),
|
||||
Target::Projectile(i) => self
|
||||
Target::Projectile(id) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get(i)
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.map_or_else(String::new, |p| p.kind.id().into()),
|
||||
Target::Stand(_) => "minecraft:armor_stand".into(),
|
||||
Target::Frame(id) => {
|
||||
@@ -428,6 +446,8 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map_or_else(String::new, |o| o.kind.clone()),
|
||||
Target::Crystal(_) => "minecraft:end_crystal".into(),
|
||||
Target::Dragon(_) => "minecraft:ender_dragon".into(),
|
||||
}
|
||||
}
|
||||
/// The name of the player the game acts for: a guest's own while their
|
||||
@@ -512,50 +532,72 @@ impl Game<'_> {
|
||||
s.pos[2].unwrap_or(p[2]),
|
||||
]
|
||||
};
|
||||
let mut candidates: Vec<Target> = Vec::new();
|
||||
let mut source_candidates: Vec<Target> = Vec::new();
|
||||
match (s.kind, self.command_source) {
|
||||
// @s: the executing entity (none for a server source).
|
||||
('s', Some(source)) => candidates.extend(source.entity),
|
||||
('s', None) => candidates.extend(self.source_entity()),
|
||||
('s', Some(source)) => source_candidates.extend(source.entity),
|
||||
('s', None) => source_candidates.extend(self.source_entity()),
|
||||
// A guest in place is among the players, in join order.
|
||||
_ if self.player.is_some() && self.lan_acting.is_none() => {
|
||||
candidates.push(Target::Player);
|
||||
source_candidates.push(Target::Player);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if s.kind != 's' {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
candidates.extend(self.player_candidates(s));
|
||||
candidates.extend(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.death_time == 0)
|
||||
.map(|m| Target::Mob(m.id)),
|
||||
);
|
||||
candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id)));
|
||||
candidates.extend((0..self.orbs.len()).map(Target::Orb));
|
||||
candidates.extend(
|
||||
self.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
// The kinds /data can write (entity_nbt).
|
||||
.filter(|(_, p)| {
|
||||
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
|
||||
})
|
||||
.map(|(i, _)| Target::Projectile(i)),
|
||||
);
|
||||
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
|
||||
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
|
||||
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
|
||||
candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id)));
|
||||
candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id)));
|
||||
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
|
||||
source_candidates.extend(self.player_candidates(s));
|
||||
}
|
||||
let source_dim = self.dim;
|
||||
// A selector with a spatial constraint reaches the level the command
|
||||
// runs in only; an unbounded one reaches every loaded level
|
||||
// (EntitySelector.findEntities).
|
||||
let dims = if s.world_limited() {
|
||||
vec![source_dim]
|
||||
} else {
|
||||
self.loaded_dims()
|
||||
};
|
||||
let mut out: Vec<(Target, f64)> = Vec::new();
|
||||
for dim in dims {
|
||||
let back = self.enter_command_level(dim);
|
||||
let mut candidates = Vec::new();
|
||||
if dim == source_dim {
|
||||
candidates.append(&mut source_candidates);
|
||||
}
|
||||
if s.kind != 's' {
|
||||
candidates.extend(self.level_candidates());
|
||||
}
|
||||
out.extend(self.select_in_level(candidates, s, origin));
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
}
|
||||
match s.sort {
|
||||
Sort::Nearest => out.sort_by(|a, b| a.1.total_cmp(&b.1)),
|
||||
Sort::Furthest => out.sort_by(|a, b| b.1.total_cmp(&a.1)),
|
||||
Sort::Random => {
|
||||
for i in (1..out.len()).rev() {
|
||||
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
|
||||
out.swap(i, j);
|
||||
}
|
||||
}
|
||||
Sort::Arbitrary => {}
|
||||
}
|
||||
out.into_iter()
|
||||
.take(s.limit.max(1))
|
||||
.map(|(t, _)| t)
|
||||
.collect()
|
||||
}
|
||||
/// The candidates of the level in place that pass the selector
|
||||
/// (EntitySelector.getPredicate).
|
||||
fn select_in_level(
|
||||
&mut self,
|
||||
candidates: Vec<Target>,
|
||||
s: &Selector,
|
||||
origin: [f64; 3],
|
||||
) -> Vec<(Target, f64)> {
|
||||
let level = self.experience.level;
|
||||
let creative = self.creative;
|
||||
let passing = self.selector_extra_filter(&candidates, s);
|
||||
let mut out: Vec<(Target, f64)> = candidates
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter_map(|t| {
|
||||
let p = self.target_position(t)?;
|
||||
@@ -627,23 +669,102 @@ impl Game<'_> {
|
||||
}
|
||||
Some((t, d2))
|
||||
})
|
||||
.collect();
|
||||
match s.sort {
|
||||
Sort::Nearest => out.sort_by(|a, b| a.1.total_cmp(&b.1)),
|
||||
Sort::Furthest => out.sort_by(|a, b| b.1.total_cmp(&a.1)),
|
||||
Sort::Random => {
|
||||
for i in (1..out.len()).rev() {
|
||||
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
|
||||
out.swap(i, j);
|
||||
}
|
||||
}
|
||||
Sort::Arbitrary => {}
|
||||
}
|
||||
out.into_iter()
|
||||
.take(s.limit.max(1))
|
||||
.map(|(t, _)| t)
|
||||
.collect()
|
||||
}
|
||||
/// The entities of the level in place a selector can find.
|
||||
fn level_candidates(&self) -> Vec<Target> {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
let mut candidates = Vec::new();
|
||||
candidates.extend(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.death_time == 0)
|
||||
.map(|m| Target::Mob(m.id)),
|
||||
);
|
||||
candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id)));
|
||||
candidates.extend(self.orbs.iter().map(|o| Target::Orb(o.id)));
|
||||
candidates.extend(
|
||||
self.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
// The kinds /data can write (entity_nbt).
|
||||
.filter(|p| {
|
||||
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
|
||||
})
|
||||
.map(|p| Target::Projectile(p.id)),
|
||||
);
|
||||
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
|
||||
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
|
||||
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
|
||||
candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id)));
|
||||
candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id)));
|
||||
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
|
||||
// The End crystals and the dragon (EndCrystal and EnderDragon
|
||||
// are entities of their own).
|
||||
candidates.extend(self.crystals.iter().map(|c| Target::Crystal(c.id)));
|
||||
if let Some(d) = &self.dragon {
|
||||
candidates.push(Target::Dragon(d.id));
|
||||
}
|
||||
candidates
|
||||
}
|
||||
/// The loaded levels, in the order the server holds them: the
|
||||
/// overworld, the nether, the end (MinecraftServer.getAllLevels).
|
||||
fn loaded_dims(&self) -> Vec<Dim> {
|
||||
let mut dims = vec![self.dim];
|
||||
dims.extend(self.levels.parked_dims());
|
||||
dims.sort_by_key(|d| match d {
|
||||
Dim::Overworld => 0,
|
||||
Dim::Nether => 1,
|
||||
Dim::End => 2,
|
||||
});
|
||||
dims
|
||||
}
|
||||
/// Whether the level in place holds the entity a target names.
|
||||
fn level_holds(&self, t: Target) -> bool {
|
||||
match t {
|
||||
// A player is in no level of its own.
|
||||
Target::Player | Target::Guest(_) => true,
|
||||
Target::Mob(id) => self.mobs.iter().any(|m| m.id == id),
|
||||
Target::Item(id) => self.dropped.iter().any(|i| i.id == id),
|
||||
Target::Orb(id) => self.orbs.iter().any(|o| o.id == id),
|
||||
Target::Projectile(id) => self.creatures.projectiles.iter().any(|p| p.id == id),
|
||||
Target::Stand(id) => self.armor_stands.iter().any(|a| a.id == id),
|
||||
Target::Frame(id) => self.item_frames.iter().any(|f| f.id == id),
|
||||
Target::Painting(id) => self.paintings.iter().any(|p| p.id == id),
|
||||
Target::Boat(id) => self.boats.iter().any(|b| b.id == id),
|
||||
Target::Cart(id) => self.carts.iter().any(|c| c.id == id),
|
||||
Target::Other(id) => self.others.iter().any(|o| o.id == id),
|
||||
Target::Crystal(id) => self.crystals.iter().any(|c| c.id == id),
|
||||
Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id),
|
||||
}
|
||||
}
|
||||
/// Run `f` with the level that holds the entity a target names in place
|
||||
/// (the entity ids are unique across the loaded levels) and put the
|
||||
/// level back afterwards. `f` does not run when no level holds the
|
||||
/// entity.
|
||||
pub(in crate::game) fn in_target_level<R>(
|
||||
&mut self,
|
||||
t: Target,
|
||||
f: impl FnOnce(&mut Self) -> R,
|
||||
) -> Option<R> {
|
||||
if self.level_holds(t) {
|
||||
return Some(f(self));
|
||||
}
|
||||
for dim in self.levels.parked_dims() {
|
||||
let back = self.enter_command_level(dim);
|
||||
if self.level_holds(t) {
|
||||
let r = f(self);
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
return Some(r);
|
||||
}
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
fn entity_tag_matches(&self, tag: &str, ty: &str) -> bool {
|
||||
let path = self.options.resources.join(format!(
|
||||
"data/minecraft/tags/entity_type/{}.json",
|
||||
@@ -1041,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 {
|
||||
@@ -1057,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;
|
||||
@@ -1103,6 +1246,10 @@ impl Game<'_> {
|
||||
Ok(targets.len() as i32)
|
||||
}
|
||||
fn teleport_to(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
|
||||
// An entity in another level is teleported in the level that holds it.
|
||||
let _ = self.in_target_level(t, |g| g.teleport_to_in_level(t, pos, rotation));
|
||||
}
|
||||
fn teleport_to_in_level(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
|
||||
let v = Vec3::new(pos[0], pos[1], pos[2]);
|
||||
match t {
|
||||
Target::Guest(_) => {
|
||||
@@ -1151,14 +1298,14 @@ impl Game<'_> {
|
||||
i.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Orb(i) => {
|
||||
if let Some(o) = self.orbs.get_mut(i) {
|
||||
Target::Orb(id) => {
|
||||
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
|
||||
o.position = v;
|
||||
o.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Projectile(i) => {
|
||||
if let Some(a) = self.creatures.projectiles.get_mut(i) {
|
||||
Target::Projectile(id) => {
|
||||
if let Some(a) = self.creatures.projectiles.iter_mut().find(|a| a.id == id) {
|
||||
a.position = v;
|
||||
a.previous = v;
|
||||
}
|
||||
@@ -1198,6 +1345,17 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Crystal(id) => {
|
||||
if let Some(c) = self.crystals.iter_mut().find(|c| c.id == id) {
|
||||
c.position = v;
|
||||
}
|
||||
}
|
||||
Target::Dragon(id) => {
|
||||
if let Some(d) = self.dragon.as_mut().filter(|d| d.id == id) {
|
||||
d.position = v;
|
||||
d.previous = v;
|
||||
}
|
||||
}
|
||||
// Wall-mounted entities do not move.
|
||||
Target::Frame(_) | Target::Painting(_) => {}
|
||||
}
|
||||
@@ -1311,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,
|
||||
};
|
||||
@@ -1326,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.
|
||||
@@ -1350,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;
|
||||
}
|
||||
@@ -1557,6 +1726,10 @@ impl Game<'_> {
|
||||
Ok(duration.unwrap_or(-1))
|
||||
}
|
||||
fn kill_target(&mut self, t: Target) {
|
||||
// An entity in another level is killed in the level that holds it.
|
||||
let _ = self.in_target_level(t, |g| g.kill_target_in_level(t));
|
||||
}
|
||||
fn kill_target_in_level(&mut self, t: Target) {
|
||||
match t {
|
||||
Target::Guest(_) => {
|
||||
self.for_player(t, |g| g.kill_target(Target::Player));
|
||||
@@ -1576,16 +1749,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Target::Item(id) => self.dropped.retain(|i| i.id != id),
|
||||
Target::Orb(i) => {
|
||||
if i < self.orbs.len() {
|
||||
self.orbs[i].age = u32::MAX / 2;
|
||||
Target::Orb(id) => {
|
||||
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
|
||||
o.age = u32::MAX / 2;
|
||||
}
|
||||
}
|
||||
// cmd_kill goes from the highest index down.
|
||||
Target::Projectile(i) => {
|
||||
if i < self.creatures.projectiles.len() {
|
||||
self.creatures.projectiles.remove(i);
|
||||
}
|
||||
Target::Projectile(id) => {
|
||||
self.creatures.projectiles.retain(|p| p.id != id);
|
||||
}
|
||||
Target::Stand(id) => self.armor_stands.retain(|a| a.id != id),
|
||||
Target::Frame(id) => self.item_frames.retain(|f| f.id != id),
|
||||
@@ -1593,7 +1763,11 @@ impl Game<'_> {
|
||||
Target::Boat(id) => self.boats.retain(|b| b.id != id),
|
||||
Target::Cart(id) => self.carts.retain(|c| c.id != id),
|
||||
Target::Other(id) => self.others.retain(|o| o.id != id),
|
||||
Target::Crystal(id) => self.kill_crystal(id),
|
||||
Target::Dragon(id) => self.kill_dragon(id),
|
||||
}
|
||||
// The orbs marked for removal are gone (Entity.kill removes them).
|
||||
self.orbs.retain(|o| o.age < u32::MAX / 2);
|
||||
}
|
||||
fn cmd_kill(&mut self, ctx: &Context) -> R<i32> {
|
||||
let targets = if ctx.get("targets").is_some() {
|
||||
@@ -1601,19 +1775,18 @@ impl Game<'_> {
|
||||
} else {
|
||||
vec![self.implied_player()]
|
||||
};
|
||||
let names: Vec<Text> = targets.iter().map(|&t| self.target_name(t)).collect();
|
||||
// Remove indexed entities from the back so earlier indices stay valid.
|
||||
let mut ordered = targets.clone();
|
||||
ordered.sort_by_key(|t| {
|
||||
std::cmp::Reverse(match t {
|
||||
Target::Orb(i) | Target::Projectile(i) => *i,
|
||||
_ => 0,
|
||||
})
|
||||
});
|
||||
for t in ordered {
|
||||
let mut names: Vec<Text> = Vec::new();
|
||||
for &t in &targets {
|
||||
// The name of an entity in another level is read there.
|
||||
let name = match self.in_target_level(t, |g| g.target_name(t)) {
|
||||
Some(name) => name,
|
||||
None => self.target_name(t),
|
||||
};
|
||||
names.push(name);
|
||||
}
|
||||
for &t in &targets {
|
||||
self.kill_target(t);
|
||||
}
|
||||
self.orbs.retain(|o| o.age < u32::MAX / 2);
|
||||
let message = if targets.len() == 1 {
|
||||
Text::translate("commands.kill.success.single", &[names[0].clone()])
|
||||
} else {
|
||||
@@ -1705,6 +1878,20 @@ impl Game<'_> {
|
||||
self.spawn_bolt(pos, false);
|
||||
name = Text::translate("entity.minecraft.lightning_bolt", &[]);
|
||||
}
|
||||
"end_crystal" => {
|
||||
// EndCrystal's constructor takes an id from the entity
|
||||
// counter, and the crystal is shown with its base.
|
||||
self.next_entity += 1;
|
||||
self.crystals.push(super::dragon::Crystal {
|
||||
id: self.next_entity,
|
||||
position: Vec3::new(pos[0], pos[1], pos[2]),
|
||||
show_bottom: true,
|
||||
time: self.world.random.next_int_bound(100_000).unwrap_or(0),
|
||||
beam: None,
|
||||
invulnerable: false,
|
||||
});
|
||||
name = Text::translate("entity.minecraft.end_crystal", &[]);
|
||||
}
|
||||
k if super::minecarts::CartKind::from_id(&kind).is_some() => {
|
||||
let cart_kind = super::minecarts::CartKind::from_id(&kind)
|
||||
.unwrap_or(super::minecarts::CartKind::Rideable);
|
||||
@@ -1895,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
|
||||
@@ -2057,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>();
|
||||
@@ -2266,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 {
|
||||
@@ -2299,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 {
|
||||
@@ -2403,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());
|
||||
@@ -2597,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.
|
||||
@@ -2612,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());
|
||||
@@ -2645,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> {
|
||||
|
||||
@@ -31,8 +31,13 @@ fn set_key(tag: &mut Tag, key: &str, value: Tag) {
|
||||
impl Game<'_> {
|
||||
// ---- entity data ---------------------------------------------------
|
||||
|
||||
/// `NbtPredicate.getEntityTagToCompare`: the entity as saved.
|
||||
/// `NbtPredicate.getEntityTagToCompare`: the entity as saved. An entity
|
||||
/// in another level is read in the level that holds it.
|
||||
pub(in crate::game) fn entity_nbt(&mut self, t: Target) -> Option<Tag> {
|
||||
self.in_target_level(t, |g| g.entity_nbt_in_level(t))
|
||||
.flatten()
|
||||
}
|
||||
fn entity_nbt_in_level(&mut self, t: Target) -> Option<Tag> {
|
||||
let player_uuid = self.level.player_uuid;
|
||||
Some(match t {
|
||||
Target::Guest(_) => {
|
||||
@@ -113,9 +118,11 @@ impl Game<'_> {
|
||||
let o = self.others.iter().find(|o| o.id == id)?.clone();
|
||||
self.other_to_nbt(&o, uuid)
|
||||
}
|
||||
Target::Orb(i) => entity_nbt::orb_to_nbt(self.orbs.get(i)?, random_uuid()),
|
||||
Target::Projectile(i) => {
|
||||
let p = self.creatures.projectiles.get(i)?;
|
||||
Target::Orb(id) => {
|
||||
entity_nbt::orb_to_nbt(self.orbs.iter().find(|o| o.id == id)?, random_uuid())
|
||||
}
|
||||
Target::Projectile(id) => {
|
||||
let p = self.creatures.projectiles.iter().find(|p| p.id == id)?;
|
||||
let owner = if p.owner == Some(horizon_client::mobs::Who::Player) {
|
||||
player_uuid
|
||||
} else {
|
||||
@@ -129,6 +136,16 @@ impl Game<'_> {
|
||||
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)
|
||||
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))?
|
||||
}
|
||||
Target::Crystal(id) => {
|
||||
let c = self.crystals.iter().find(|c| c.id == id)?;
|
||||
let (uuid, raw) = self.persist_anon("minecraft:end_crystal", c.position);
|
||||
c.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
|
||||
}
|
||||
Target::Dragon(id) => {
|
||||
let d = self.dragon.as_ref().filter(|d| d.id == id)?;
|
||||
let (uuid, raw) = self.persist_anon("minecraft:ender_dragon", d.position);
|
||||
d.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
/// `Entity.load` of new data (`EntityDataAccessor.setData`): the entity is
|
||||
@@ -397,6 +414,19 @@ impl Game<'_> {
|
||||
}
|
||||
pub(super) fn cmd_data(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let target = self.data_ref(ctx, 0)?;
|
||||
// An entity in another level is read and written in the level that
|
||||
// holds it.
|
||||
if let DataRef::Entity(t) = &target {
|
||||
let t = *t;
|
||||
let target = target.clone();
|
||||
return match self.in_target_level(t, |g| g.cmd_data_in(id, ctx, target)) {
|
||||
Some(r) => r,
|
||||
None => Err(err("argument.entity.notfound.entity", &[])),
|
||||
};
|
||||
}
|
||||
self.cmd_data_in(id, ctx, target)
|
||||
}
|
||||
fn cmd_data_in(&mut self, id: &str, ctx: &Context, target: DataRef) -> R<i32> {
|
||||
match id {
|
||||
"data.merge" => {
|
||||
let Some(Arg::Str(nbt)) = ctx.get("nbt") else {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! `/effect` (EffectCommands) and `/enchant` (EnchantCommand).
|
||||
use super::{Arg, Context, Game, R, Target, err, texts};
|
||||
use horizon_client::{
|
||||
brewing,
|
||||
brewing::{self, namespaced},
|
||||
effects::EffectInstance,
|
||||
text::{self, Style, Text},
|
||||
};
|
||||
use std::{collections::HashMap, sync::OnceLock};
|
||||
|
||||
/// `CommandResponseTracker` over targets: elements with a non-zero result.
|
||||
#[derive(Default)]
|
||||
@@ -21,24 +20,6 @@ impl Tracker {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Namespaced effect ids for mob effect storage.
|
||||
fn namespaced(id: &'static str) -> &'static str {
|
||||
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
IDS.get_or_init(|| {
|
||||
brewing::EFFECTS
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
e.id,
|
||||
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.get(id)
|
||||
.copied()
|
||||
.unwrap_or(id)
|
||||
}
|
||||
fn effect_name(id: &str) -> Text {
|
||||
Text::translate(&format!("effect.minecraft.{id}"), &[])
|
||||
}
|
||||
|
||||
@@ -663,10 +663,11 @@ impl Game<'_> {
|
||||
// TraceableEntity.getOwner: who shot a projectile, who threw an
|
||||
// item, who summoned a vex.
|
||||
Some("origin") => match entity {
|
||||
Target::Projectile(i) => self
|
||||
Target::Projectile(id) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get(i)
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.and_then(|p| match (p.owner?, p.guest) {
|
||||
// A guest's shot: `Who::Player` is them.
|
||||
(horizon_client::mobs::Who::Player, Some(conn))
|
||||
|
||||
@@ -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,9 @@ 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,
|
||||
_ => DamageKind::Generic,
|
||||
@@ -1218,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;
|
||||
};
|
||||
|
||||
@@ -67,6 +67,17 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.burp", self.feet(), 0.5, pitch);
|
||||
}
|
||||
// SuspiciousStewEffects.onConsume, a ConsumableListener: each entry is
|
||||
// added as a `MobEffectInstance(effect, duration)` (amplifier 0).
|
||||
if let Some(data) = stack.extra.as_deref() {
|
||||
for (id, duration) in &data.stew_effects {
|
||||
if let Some(def) = horizon_client::brewing::effect(id) {
|
||||
self.add_player_effect(horizon_client::effects::EffectInstance::new(
|
||||
def.id, *duration, 0,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for effect in &consumable.effects {
|
||||
self.apply_consume_effect(effect);
|
||||
}
|
||||
|
||||
@@ -44,13 +44,13 @@ impl Game<'_> {
|
||||
slot: usize,
|
||||
stack: Option<ItemStack>,
|
||||
) {
|
||||
if let Some(e) = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_mut(&(chest[0], chest[1], chest[2]))
|
||||
let key = (chest[0], chest[1], chest[2]);
|
||||
if let Some(e) = self.world.block_entities.get_mut(&key)
|
||||
&& let Some(s) = e.slots_mut().get_mut(slot)
|
||||
{
|
||||
*s = stack;
|
||||
// TransportItemsBetweenContainers calls Container.setChanged.
|
||||
self.container_changed(key);
|
||||
}
|
||||
}
|
||||
/// `ContainerOpenersCounter.recheckOpeners` every five ticks: the golems
|
||||
@@ -204,6 +204,64 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// ---- interactions -----------------------------------------------------
|
||||
/// `WeatheringCopperGolemStatueBlock.useItemOn`: an axe on an unoxidized
|
||||
/// statue turns it back into a golem (`CopperGolemStatueBlockEntity.removeStatue`),
|
||||
/// wearing the axe. Weathered statues are scraped by the axe's own
|
||||
/// transform instead.
|
||||
pub(super) fn use_golem_statue(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
|
||||
if !held.is_some_and(|s| s.item.ends_with("_axe")) {
|
||||
return false;
|
||||
}
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some(state) = registry.state(self.world.block(pos[0], pos[1], pos[2])) else {
|
||||
return false;
|
||||
};
|
||||
if state.name != "minecraft:copper_golem_statue" {
|
||||
return false;
|
||||
}
|
||||
// `Direction.toYRot` of the statue's facing.
|
||||
let yaw = match state.properties.get("facing").map(String::as_str) {
|
||||
Some("west") => 90.0,
|
||||
Some("north") => 180.0,
|
||||
Some("east") => 270.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
let waterlogged = state
|
||||
.properties
|
||||
.get("waterlogged")
|
||||
.is_some_and(|w| w == "true");
|
||||
// initCopperGolem: at the block's feet, facing as the statue does.
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
let Some(id) = self.spawn_mob("minecraft:copper_golem", at, true) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.yaw = yaw;
|
||||
m.previous_yaw = yaw;
|
||||
m.body_yaw = yaw;
|
||||
m.previous_body_yaw = yaw;
|
||||
m.head_yaw = yaw;
|
||||
m.previous_head_yaw = yaw;
|
||||
}
|
||||
self.damage_held(1);
|
||||
self.audio
|
||||
.play("minecraft:entity.copper_golem.spawn", Some(at), 1.0, 1.0);
|
||||
// Level.removeBlock(pos, false): air, or the water the statue stood in.
|
||||
let replacement = registry.default_state(if waterlogged {
|
||||
"minecraft:water"
|
||||
} else {
|
||||
"minecraft:air"
|
||||
});
|
||||
if let Some(replacement) = replacement {
|
||||
let _ = self.level_set(pos, replacement);
|
||||
}
|
||||
self.world.block_entities.remove(&(pos[0], pos[1], pos[2]));
|
||||
true
|
||||
}
|
||||
/// `CopperGolem.mobInteract`; true when handled.
|
||||
pub(super) fn interact_copper_golem(&mut self, index: usize, item: &str) -> bool {
|
||||
if self.mobs[index].species() != Species::CopperGolem || !self.mobs[index].alive() {
|
||||
|
||||
@@ -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,10 +219,11 @@ 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);
|
||||
self.creatures.projectiles.push(arrow);
|
||||
self.add_projectile(arrow);
|
||||
}
|
||||
self.player_projectile_shot();
|
||||
let pitch = if index == 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ use horizon_client::{
|
||||
save::VERSION_NAME,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::geometry::Axis;
|
||||
use horizon_core::geometry::{Axis, Vec3};
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// `ChatFormatting.YELLOW` and `RED`.
|
||||
@@ -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,
|
||||
@@ -766,7 +767,8 @@ impl Game<'_> {
|
||||
}
|
||||
let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a);
|
||||
let lerpf = |a: f32, b: f32| a + alpha * (b - a);
|
||||
for m in &self.mobs {
|
||||
let guests = self.lan.iter().flat_map(|h| h.models.values());
|
||||
for m in self.mobs.iter().chain(guests) {
|
||||
if m.removed || m.invisible || m.has_effect("minecraft:invisibility") {
|
||||
continue;
|
||||
}
|
||||
@@ -796,6 +798,159 @@ impl Game<'_> {
|
||||
// EntityType.ITEM: 0.25 by 0.25, eyes at 0.2125.
|
||||
g.hitbox(pos, (0.25, 0.25), 0.2125, false, (0.0, 0.0));
|
||||
}
|
||||
// The other entities (EntityTypes sizes; eyes at 0.85 of the height
|
||||
// unless the type sets one). `at` is the interpolated position.
|
||||
let at = |previous: Vec3, now: Vec3| {
|
||||
[
|
||||
lerp(previous.x, now.x),
|
||||
lerp(previous.y, now.y),
|
||||
lerp(previous.z, now.z),
|
||||
]
|
||||
};
|
||||
for p in &self.paintings {
|
||||
let (min, max) = p.bounds();
|
||||
let centre = std::array::from_fn(|i| (min[i] + max[i]) / 2.0);
|
||||
g.entity_box(min, max, centre, 0.425, false, step_view(p.step()));
|
||||
}
|
||||
for f in self.item_frames.iter().filter(|f| !f.invisible) {
|
||||
let (min, max) = f.bounds();
|
||||
let centre = std::array::from_fn(|i| (min[i] + max[i]) / 2.0);
|
||||
g.entity_box(min, max, centre, 0.0, false, step_view(f.step()));
|
||||
}
|
||||
for c in &self.cushions {
|
||||
let (min, max) = c.bounds();
|
||||
let p = c.position;
|
||||
g.entity_box(min, max, [p.x, p.y, p.z], 0.2125, false, (0.0, c.yaw));
|
||||
}
|
||||
for a in self.armor_stands.iter().filter(|a| !a.invisible) {
|
||||
// ArmorStand.getDimensions: a marker has none, a small one is
|
||||
// half scale with its eyes at 0.9875.
|
||||
let (size, eye) = match (a.marker, a.small) {
|
||||
(true, _) => ((0.0, 0.0), 0.0),
|
||||
(_, true) => ((0.25, 0.9875), 0.9875),
|
||||
_ => ((0.5, 1.975), 1.7775),
|
||||
};
|
||||
let p = a.position;
|
||||
g.hitbox([p.x, p.y, p.z], size, eye, true, (0.0, a.yaw));
|
||||
}
|
||||
for b in &self.boats {
|
||||
let h = super::boats::HEIGHT as f32;
|
||||
g.hitbox(
|
||||
at(b.previous, b.position),
|
||||
(super::boats::WIDTH as f32, h),
|
||||
h,
|
||||
false,
|
||||
(0.0, b.yaw),
|
||||
);
|
||||
}
|
||||
for c in &self.carts {
|
||||
let (w, h) = (
|
||||
super::minecarts::WIDTH as f32,
|
||||
super::minecarts::HEIGHT as f32,
|
||||
);
|
||||
g.hitbox(
|
||||
at(c.previous, c.position),
|
||||
(w, h),
|
||||
h * 0.85,
|
||||
false,
|
||||
(0.0, c.yaw),
|
||||
);
|
||||
}
|
||||
for o in &self.orbs {
|
||||
g.hitbox(
|
||||
at(o.previous, o.position),
|
||||
(0.5, 0.5),
|
||||
0.425,
|
||||
false,
|
||||
(0.0, 0.0),
|
||||
);
|
||||
}
|
||||
for f in &self.falling {
|
||||
g.hitbox(
|
||||
at(f.previous, f.position),
|
||||
(0.98, 0.98),
|
||||
0.833,
|
||||
false,
|
||||
(0.0, 0.0),
|
||||
);
|
||||
}
|
||||
for t in &self.tnts {
|
||||
g.hitbox(
|
||||
at(t.previous, t.position),
|
||||
(0.98, 0.98),
|
||||
0.15,
|
||||
false,
|
||||
(0.0, 0.0),
|
||||
);
|
||||
}
|
||||
for c in &self.crystals {
|
||||
let p = c.position;
|
||||
g.hitbox([p.x, p.y, p.z], (2.0, 2.0), 1.7, false, (0.0, 0.0));
|
||||
}
|
||||
for p in &self.creatures.projectiles {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
let (size, eye) = match p.kind {
|
||||
K::Arrow { .. } | K::Trident => (0.5, 0.13),
|
||||
K::Snowball | K::Egg | K::EnderPearl | K::LlamaSpit | K::Potion { .. } => {
|
||||
(0.25, 0.2125)
|
||||
}
|
||||
K::SmallFireball | K::ShulkerBullet { .. } | K::WitherSkull { .. } => {
|
||||
(0.3125, 0.265_625)
|
||||
}
|
||||
K::WindCharge | K::BreezeWindCharge => (0.3125, 0.0),
|
||||
K::LargeFireball { .. } => (1.0, 0.85),
|
||||
};
|
||||
g.hitbox(
|
||||
at(p.previous, p.position),
|
||||
(size, size),
|
||||
eye,
|
||||
false,
|
||||
(p.pitch, p.yaw),
|
||||
);
|
||||
}
|
||||
for t in &self.tridents {
|
||||
g.hitbox(
|
||||
at(t.previous, t.position),
|
||||
(0.5, 0.5),
|
||||
0.13,
|
||||
false,
|
||||
(t.pitch, t.yaw),
|
||||
);
|
||||
}
|
||||
for r in &self.rockets {
|
||||
// Projectile.updateRotation: the look follows the motion.
|
||||
let v = r.velocity;
|
||||
let yaw = v.x.atan2(v.z).to_degrees() as f32;
|
||||
let pitch = v.y.atan2(v.x.hypot(v.z)).to_degrees() as f32;
|
||||
g.hitbox(
|
||||
at(r.previous, r.position),
|
||||
(0.25, 0.25),
|
||||
0.2125,
|
||||
false,
|
||||
(pitch, yaw),
|
||||
);
|
||||
}
|
||||
for h in self
|
||||
.hook
|
||||
.iter()
|
||||
.map(|h| (h.previous, h.position))
|
||||
.chain(self.remote_hooks.iter().map(|h| (h.previous, h.position)))
|
||||
{
|
||||
g.hitbox(at(h.0, h.1), (0.25, 0.25), 0.2125, false, (0.0, 0.0));
|
||||
}
|
||||
for b in &self.bolts {
|
||||
g.hitbox(b.at, (0.0, 0.0), 0.0, false, (0.0, 0.0));
|
||||
}
|
||||
// The dragon: 16 by 8, a living entity; its parts follow.
|
||||
if let Some(d) = &self.dragon {
|
||||
g.hitbox(
|
||||
at(d.previous, d.position),
|
||||
(16.0, 8.0),
|
||||
6.8,
|
||||
true,
|
||||
(0.0, d.yaw),
|
||||
);
|
||||
}
|
||||
// The dragon's parts.
|
||||
for (id, b) in self.special_targets() {
|
||||
if (super::dragon::SPECIAL..super::dragon::SPECIAL + 8).contains(&id) {
|
||||
@@ -828,3 +983,12 @@ impl Game<'_> {
|
||||
g
|
||||
}
|
||||
}
|
||||
|
||||
/// The (x, y) rotations whose view vector is the step of a direction.
|
||||
fn step_view(step: [i32; 3]) -> (f32, f32) {
|
||||
let [x, y, z] = step.map(f64::from);
|
||||
(
|
||||
(-y).asin().to_degrees() as f32,
|
||||
(-x.atan2(z)).to_degrees() as f32,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -120,6 +120,11 @@ impl Game<'_> {
|
||||
p.guest = guest;
|
||||
// Not into the one who turned it on its way out.
|
||||
p.left_owner = false;
|
||||
// `AbstractHurtingProjectile.onDeflection` by an attack, and the
|
||||
// `needsSync` that `AIM_DEFLECT` sets: a server's players see it turn
|
||||
// at once, not at the fireball's next update ten ticks later.
|
||||
p.acceleration = 0.1;
|
||||
p.needs_sync = true;
|
||||
self.reset_attack_strength();
|
||||
self.swing_arm();
|
||||
let from = self
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -1034,7 +1034,7 @@ impl Game<'_> {
|
||||
a.pickup = horizon_client::arrows::Pickup::Allowed;
|
||||
}
|
||||
shot.shoot(&mut own, dir, power, uncertainty);
|
||||
self.creatures.projectiles.push(shot);
|
||||
self.add_projectile(shot);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
@@ -291,11 +301,11 @@ pub(super) enum FightWho {
|
||||
}
|
||||
/// A player in the End as the dragon sees it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct FightPlayer {
|
||||
who: FightWho,
|
||||
position: Vec3,
|
||||
bounds: Aabb,
|
||||
alive: bool,
|
||||
pub(super) struct FightPlayer {
|
||||
pub(super) who: FightWho,
|
||||
pub(super) position: Vec3,
|
||||
pub(super) bounds: Aabb,
|
||||
pub(super) alive: bool,
|
||||
creative: bool,
|
||||
spectator: bool,
|
||||
}
|
||||
@@ -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;
|
||||
@@ -693,7 +709,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// The players in the level the dragon sees (`level.players()`): the
|
||||
/// window's own, then every guest in the level.
|
||||
fn fight_players(&self) -> Vec<FightPlayer> {
|
||||
pub(super) fn fight_players(&self) -> Vec<FightPlayer> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
out.push(FightPlayer {
|
||||
@@ -722,7 +738,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// Run `f` as the fight's player `who` (a guest's turn, its knockback
|
||||
/// sent to its client).
|
||||
fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) {
|
||||
pub(super) fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) {
|
||||
match who {
|
||||
FightWho::Host => f(self),
|
||||
FightWho::Guest(conn) => {
|
||||
@@ -828,8 +844,10 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
|
||||
// EndCrystal's constructor takes an id from the entity counter.
|
||||
self.next_entity += 1;
|
||||
self.crystals.push(Crystal {
|
||||
id: 0,
|
||||
id: self.next_entity,
|
||||
position: at,
|
||||
show_bottom: true,
|
||||
time,
|
||||
@@ -891,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();
|
||||
@@ -903,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
|
||||
{
|
||||
@@ -975,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
|
||||
@@ -994,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)> {
|
||||
@@ -1031,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()
|
||||
@@ -1374,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();
|
||||
@@ -1566,6 +1663,7 @@ impl Game<'_> {
|
||||
age: 0,
|
||||
duration: 200,
|
||||
from_dragon: true,
|
||||
potion: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1658,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;
|
||||
@@ -2078,12 +2194,18 @@ impl Game<'_> {
|
||||
age: 0,
|
||||
duration: 600,
|
||||
from_dragon: false,
|
||||
potion: None,
|
||||
});
|
||||
self.audio.play(
|
||||
"minecraft:entity.dragon_fireball.explode",
|
||||
Some([next.x, next.y, next.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
// DragonFireball.onHit: level event 2006 (the burst of dragon
|
||||
// breath and the explosion sound) at its block position.
|
||||
self.level_event_at(
|
||||
2006,
|
||||
[
|
||||
next.x.floor() as i32,
|
||||
next.y.floor() as i32,
|
||||
next.z.floor() as i32,
|
||||
],
|
||||
1,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -2110,6 +2232,10 @@ impl Game<'_> {
|
||||
let mut harmed: Vec<(FightWho, f32)> = Vec::new();
|
||||
let mut clouds = std::mem::take(&mut self.breath_clouds);
|
||||
clouds.retain_mut(|c| {
|
||||
// Lingering potions' clouds tick with the potions.
|
||||
if c.potion.is_some() {
|
||||
return true;
|
||||
}
|
||||
c.age += 1;
|
||||
if c.age >= c.duration {
|
||||
return false;
|
||||
@@ -2127,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.
|
||||
@@ -2257,8 +2385,22 @@ pub(super) struct Breath {
|
||||
pub(super) at: [f64; 3],
|
||||
pub(super) radius: f32,
|
||||
pub(super) age: i32,
|
||||
duration: i32,
|
||||
from_dragon: bool,
|
||||
pub(super) duration: i32,
|
||||
pub(super) from_dragon: bool,
|
||||
/// A lingering potion's cloud (`ThrownLingeringPotion`): what it applies.
|
||||
pub(super) potion: Option<super::potions::PotionCloud>,
|
||||
}
|
||||
impl Breath {
|
||||
/// `AreaEffectCloud.isWaiting`: before its wait time (20 ticks, 10 for a
|
||||
/// lingering potion's).
|
||||
pub(super) fn waiting(&self) -> bool {
|
||||
self.age
|
||||
< if self.potion.is_some() {
|
||||
super::potions::LINGERING_WAIT
|
||||
} else {
|
||||
20
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `EnderDragonRenderer` and `EnderDragonModel.setupAnim`.
|
||||
@@ -2485,6 +2627,8 @@ impl Game<'_> {
|
||||
layer: 2,
|
||||
color: c,
|
||||
light: 0,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2550,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);
|
||||
@@ -2595,6 +2741,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: 240 | 240 << 8,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2720,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2835,8 +2984,11 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
|
||||
// EndCrystal's constructor takes an id from the entity counter, so
|
||||
// the crystal is an entity a command can name (`@e[type=end_crystal]`).
|
||||
self.next_entity += 1;
|
||||
self.crystals.push(Crystal {
|
||||
id: 0,
|
||||
id: self.next_entity,
|
||||
position: Vec3::new(x + 0.5, y, z + 0.5),
|
||||
show_bottom: false,
|
||||
time,
|
||||
@@ -2853,20 +3005,32 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// `EndCrystal.kill` (`/kill`): the crystal is removed, without a blast
|
||||
/// (`Entity.kill` does not hurt it).
|
||||
pub(super) fn kill_crystal(&mut self, id: u32) {
|
||||
if let Some(i) = self.crystals.iter().position(|c| c.id == id) {
|
||||
self.destroy_crystal(i, None, CrystalDamage::Explosion);
|
||||
}
|
||||
}
|
||||
/// `EnderDragon.kill` (`/kill`): `Entity.kill` removes the entity, it
|
||||
/// does not hurt it (the dragon flies its death flight when its health
|
||||
/// runs out).
|
||||
pub(super) fn kill_dragon(&mut self, id: u32) {
|
||||
if self.dragon.as_ref().is_some_and(|d| d.id == id) {
|
||||
self.dragon = None;
|
||||
}
|
||||
}
|
||||
/// Any entity touching `room` (`Level.getEntities(null, box)`).
|
||||
fn entity_in(&self, room: Aabb) -> bool {
|
||||
let touches = |lo: Vec3, hi: Vec3| {
|
||||
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,141 @@ 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);
|
||||
}
|
||||
/// `EnchantmentHelper.doPostPiercingAttackEffects`: the
|
||||
/// `minecraft:post_piercing_attack` effects of the weapon in hand after a
|
||||
/// jab (Lunge's dash).
|
||||
pub(super) fn post_piercing_attack(&mut self) {
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return;
|
||||
};
|
||||
let mut effects = Vec::new();
|
||||
for (id, level) in held.enchantments() {
|
||||
let Some(def) = self.res.stations().enchantments.get(id) else {
|
||||
continue;
|
||||
};
|
||||
for entry in def.effects["minecraft:post_piercing_attack"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
effects.push((*level, entry["effect"].clone()));
|
||||
}
|
||||
}
|
||||
// Lunge's requirements: not riding, gliding or in water, and a
|
||||
// player outside creative with at least 7 food.
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if effects.is_empty()
|
||||
|| self.riding.is_some()
|
||||
|| p.fall_flying
|
||||
|| p.fluids.was_touching_water
|
||||
|| !(self.creative || p.health.food.food >= 7)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (level, effect) in effects {
|
||||
self.apply_piercing_effect(&effect, level);
|
||||
}
|
||||
}
|
||||
/// One `post_piercing_attack` effect on the player: `AllOf`,
|
||||
/// `ChangeItemDamage`, `ApplyExhaustion`, `ApplyEntityImpulse` and
|
||||
/// `PlaySoundEffect`.
|
||||
fn apply_piercing_effect(&mut self, effect: &serde_json::Value, level: u32) {
|
||||
let amount = || level_value(&effect["amount"], level);
|
||||
match effect["type"].as_str() {
|
||||
Some("minecraft:all_of") => {
|
||||
for e in effect["effects"].as_array().into_iter().flatten() {
|
||||
self.apply_piercing_effect(e, level);
|
||||
}
|
||||
}
|
||||
Some("minecraft:change_item_damage") => self.damage_held(amount() as u32),
|
||||
Some("minecraft:apply_exhaustion") => {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.cause_food_exhaustion(amount());
|
||||
}
|
||||
}
|
||||
// The direction turned by the look (`Entity.getLookQuaternion`:
|
||||
// pitch about x, then yaw about y), scaled by coordinate_scale
|
||||
// and the magnitude, added to the velocity (the guest's client
|
||||
// is told of it by `guest_hit_by`).
|
||||
Some("minecraft:apply_impulse") => {
|
||||
let vec3 = |key: &str| [0, 1, 2].map(|i| effect[key][i].as_f64().unwrap_or(0.0));
|
||||
let (d, scale) = (vec3("direction"), vec3("coordinate_scale"));
|
||||
let magnitude = f64::from(level_value(&effect["magnitude"], level));
|
||||
let (yaw, pitch) = (
|
||||
f64::from(self.yaw).to_radians(),
|
||||
f64::from(self.pitch).to_radians(),
|
||||
);
|
||||
let (y1, z1) = (
|
||||
d[1] * pitch.cos() - d[2] * pitch.sin(),
|
||||
d[1] * pitch.sin() + d[2] * pitch.cos(),
|
||||
);
|
||||
let impulse = horizon_core::geometry::Vec3::new(
|
||||
(d[0] * yaw.cos() - z1 * yaw.sin()) * scale[0] * magnitude,
|
||||
y1 * scale[1] * magnitude,
|
||||
(d[0] * yaw.sin() + z1 * yaw.cos()) * scale[2] * magnitude,
|
||||
);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.velocity = p.velocity.add_vector(impulse);
|
||||
}
|
||||
}
|
||||
// The sound for the level (a list picks by level), heard by all.
|
||||
Some("minecraft:play_sound") => {
|
||||
let sounds: Vec<&str> = match &effect["sound"] {
|
||||
serde_json::Value::Array(list) => {
|
||||
list.iter().filter_map(|s| s.as_str()).collect()
|
||||
}
|
||||
one => one.as_str().into_iter().collect(),
|
||||
};
|
||||
let index = (level as usize).saturating_sub(1);
|
||||
if let Some(sound) = sounds.get(index.min(sounds.len().saturating_sub(1))) {
|
||||
let float = |key: &str| effect[key].as_f64().unwrap_or(1.0) as f32;
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
self.audio.play(sound, at, float("volume"), float("pitch"));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `ApplyMobEffect.apply`: one of `to_apply` (a single id or a list) for a
|
||||
/// random duration and amplifier between the level-based bounds.
|
||||
fn apply_mob_effect(&mut self, mob: usize, effect: &serde_json::Value, level: u32) {
|
||||
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 +455,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 +740,173 @@ impl Game<'_> {
|
||||
self.apply_equipment();
|
||||
}
|
||||
}
|
||||
/// `EnchantmentHelper.runLocationChangedEffects` for the worn armor of the
|
||||
/// player, run on the server once the player has moved: `LivingEntity.baseTick`
|
||||
/// runs it when the block position changed, `checkFallDamage` when it lands.
|
||||
/// `from` and `was_on_ground` are where and how the tick began.
|
||||
pub(super) fn location_changed_effects(&mut self, from: [f64; 3], was_on_ground: bool) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let on_ground = p.ground.on_ground;
|
||||
let cell = |v: [f64; 3]| v.map(|c| c.floor() as i32);
|
||||
if cell(from) == cell(at) && !(on_ground && !was_on_ground) {
|
||||
return;
|
||||
}
|
||||
let riding = self.riding.is_some();
|
||||
let mut disks = Vec::new();
|
||||
for (stack, slot) in self.armor.iter().zip(["head", "chest", "legs", "feet"]) {
|
||||
let Some(stack) = stack else { continue };
|
||||
for (id, level) in stack.enchantments() {
|
||||
let Some(def) = self.res.stations().enchantments.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if !def
|
||||
.slots
|
||||
.iter()
|
||||
.any(|s| s == slot || s == "armor" || s == "any")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for effect in def.effects["minecraft:location_changed"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if effect["effect"]["type"] == "minecraft:replace_disk"
|
||||
&& location_requirements(&effect["requirements"], on_ground, riding)
|
||||
{
|
||||
disks.push((effect["effect"].clone(), *level));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (effect, level) in disks {
|
||||
self.replace_disk(&effect, level, at);
|
||||
}
|
||||
}
|
||||
/// `ReplaceDisk.apply`: the blocks of the disk around `at` that pass the
|
||||
/// predicate become the effect's state, and each placement is a game event.
|
||||
fn replace_disk(&mut self, effect: &Value, level: u32, at: [f64; 3]) {
|
||||
let registry = self.pack.assets.registry;
|
||||
let spec = &effect["block_state"];
|
||||
let Some(default) = spec["id"].as_str().and_then(|n| registry.default_state(n)) else {
|
||||
return;
|
||||
};
|
||||
let Some(mut state) = registry.state(default).cloned() else {
|
||||
return;
|
||||
};
|
||||
for (k, v) in spec["properties"].as_object().into_iter().flatten() {
|
||||
state
|
||||
.properties
|
||||
.insert(k.clone(), v.as_str().unwrap_or("").to_owned());
|
||||
}
|
||||
let new = registry.resolve(&state).unwrap_or(default);
|
||||
let radius = level_value(&effect["radius"], level) as i32;
|
||||
let height = level_value(&effect["height"], level) as i32;
|
||||
let offset = cell_offset(effect);
|
||||
let center = [0, 1, 2].map(|i| at[i].floor() as i32 + offset[i]);
|
||||
let event = effect["trigger_game_event"].as_str() == Some("minecraft:block_place");
|
||||
for dy in (height - 1).min(0)..=0 {
|
||||
for dz in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
let pos = [center[0] + dx, center[1] + dy, center[2] + dz];
|
||||
let (x, z) = (
|
||||
f64::from(pos[0]) + 0.5 - at[0],
|
||||
f64::from(pos[2]) + 0.5 - at[2],
|
||||
);
|
||||
if x * x + z * z >= f64::from(radius * radius)
|
||||
|| !self.disk_predicate(&effect["predicate"], pos, new)
|
||||
|| self.level_set(pos, new).is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if event {
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_place",
|
||||
pos,
|
||||
Some(Who::Player),
|
||||
Some(new),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The `BlockPredicate` subset the disk effect uses, tested at `pos`; no
|
||||
/// predicate matches everything.
|
||||
fn disk_predicate(&mut self, p: &Value, pos: [i32; 3], placed: BlockStateId) -> bool {
|
||||
if p.is_null() {
|
||||
return true;
|
||||
}
|
||||
let offset = cell_offset(p);
|
||||
let at = [0, 1, 2].map(|i| pos[i] + offset[i]);
|
||||
let state = self.world.block(at[0], at[1], at[2]);
|
||||
let b = self.res.behaviours();
|
||||
let names = |v: &Value| -> Vec<String> {
|
||||
match v {
|
||||
Value::String(s) => vec![s.clone()],
|
||||
Value::Array(a) => a
|
||||
.iter()
|
||||
.filter_map(|n| n.as_str().map(String::from))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
match p["type"].as_str().unwrap_or("") {
|
||||
"minecraft:all_of" => p["predicates"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.all(|q| self.disk_predicate(q, pos, placed)),
|
||||
// `MatchingBlockTagPredicate`
|
||||
"minecraft:matching_block_tag" => b
|
||||
.tag(p["tag"].as_str().unwrap_or("").trim_start_matches('#'))
|
||||
.is_some_and(|t| t.contains(b.block(state))),
|
||||
// `MatchingBlocksPredicate`
|
||||
"minecraft:matching_blocks" => {
|
||||
names(&p["blocks"]).iter().any(|n| n == b.table.name(state))
|
||||
}
|
||||
// `MatchingFluidsPredicate`: Fluids.WATER is the source.
|
||||
"minecraft:matching_fluids" => {
|
||||
let fluid = b.fluid(state);
|
||||
names(&p["fluids"]).iter().any(|n| match n.as_str() {
|
||||
"minecraft:water" => fluid.kind() == FluidKind::Water && fluid.is_source(),
|
||||
"minecraft:flowing_water" => {
|
||||
fluid.kind() == FluidKind::Water && !fluid.is_source()
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
// `UnobstructedPredicate`: no building-blocking entity in the block.
|
||||
"minecraft:unobstructed" => self.with_level(|_, level, _| {
|
||||
level.is_unobstructed(BlockPos::new(pos[0], pos[1], pos[2]), placed)
|
||||
}),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// Whether a worn enchantment gives immunity to `#burn_from_stepping`
|
||||
/// damage (frost walker's `minecraft:damage_immunity`).
|
||||
pub(crate) fn wears_burn_from_stepping_immunity(&self) -> bool {
|
||||
self.armor
|
||||
.iter()
|
||||
.zip(["head", "chest", "legs", "feet"])
|
||||
.any(|(stack, slot)| {
|
||||
stack
|
||||
.iter()
|
||||
.flat_map(ItemStack::enchantments)
|
||||
.any(|(id, _)| {
|
||||
self.res.stations().enchantments.get(id).is_some_and(|def| {
|
||||
def.effects.get("minecraft:damage_immunity").is_some()
|
||||
&& def
|
||||
.slots
|
||||
.iter()
|
||||
.any(|s| s == slot || s == "armor" || s == "any")
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
/// Enchantment attribute modifiers of worn armor (respiration, depth strider...).
|
||||
/// Each piece's modifier id ends with its slot (`EnchantmentAttributeEffect.idForSlot`),
|
||||
/// so the same enchantment on two pieces stacks instead of replacing itself.
|
||||
@@ -638,3 +955,35 @@ fn soul_speed_modifiers(
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An effect's or predicate's `offset` vector (zero when absent).
|
||||
fn cell_offset(v: &Value) -> [i32; 3] {
|
||||
let at = |i: usize| {
|
||||
v["offset"]
|
||||
.get(i)
|
||||
.and_then(Value::as_i64)
|
||||
.map_or(0, |n| n as i32)
|
||||
};
|
||||
[at(0), at(1), at(2)]
|
||||
}
|
||||
/// The loot-condition subset a `location_changed` effect requires of the
|
||||
/// wearer: standing on the ground and not riding (frost walker).
|
||||
fn location_requirements(req: &Value, on_ground: bool, riding: bool) -> bool {
|
||||
match req["type"].as_str().unwrap_or("") {
|
||||
"" if req.is_null() => true,
|
||||
"minecraft:all_of" => req["terms"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.all(|t| location_requirements(t, on_ground, riding)),
|
||||
"minecraft:inverted" => !location_requirements(&req["term"], on_ground, riding),
|
||||
"minecraft:entity_properties" => {
|
||||
let p = &req["predicate"];
|
||||
p["minecraft:flags"]["is_on_ground"]
|
||||
.as_bool()
|
||||
.is_none_or(|v| v == on_ground)
|
||||
&& (p["minecraft:vehicle"].is_null() || riding)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -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,16 +299,25 @@ impl Game<'_> {
|
||||
layer: 2,
|
||||
color,
|
||||
light: 0,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// LightmapRenderStateExtractor: End flashes add to the sky light factor.
|
||||
/// LightmapRenderStateExtractor: End flashes add to the sky light factor
|
||||
/// unless Hide Lightning Flashes is on (the sky sprite is not gated), a
|
||||
/// third of it under a boss bar's world fog.
|
||||
pub(super) fn end_flash_sky(&self, sky: &mut horizon_client::environment::Sky, partial: f64) {
|
||||
if self.dim == Dim::End {
|
||||
sky.sky_light_factor += self.end.flash.intensity(partial as f32);
|
||||
if self.dim == Dim::End && !self.settings.file.bool("hideLightningFlashes") {
|
||||
let intensity = self.end.flash.intensity(partial as f32);
|
||||
sky.sky_light_factor += if self.boss_overlay.should_create_world_fog() {
|
||||
intensity / 3.0
|
||||
} else {
|
||||
intensity
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,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;
|
||||
@@ -546,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]];
|
||||
@@ -566,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.
|
||||
@@ -599,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)))
|
||||
@@ -609,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"
|
||||
@@ -641,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)
|
||||
@@ -754,6 +870,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.world
|
||||
.block_entities
|
||||
.get_or_insert_with((origin[0], origin[1], origin[2]), || {
|
||||
BlockEntity::EndGateway(Default::default())
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -775,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()
|
||||
@@ -788,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<()> {
|
||||
@@ -807,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;
|
||||
@@ -882,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();
|
||||
@@ -947,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)
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1240,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") {
|
||||
|
||||
Loaded 100 of 409 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user