mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 22:43:47 -10:00
Compare commits
637
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b792957f7 | ||
|
|
2c6b60f3fb | ||
|
|
8f827db52d | ||
|
|
0e0b0c947f | ||
|
|
e065f2af6f | ||
|
|
8ccf20d909 | ||
|
|
7ace1e9e8f | ||
|
|
ebf2132885 | ||
|
|
4fe648b674 | ||
|
|
747c22a5c2 | ||
|
|
cab57a158f | ||
|
|
9cbf93a193 | ||
|
|
b1afe112a4 | ||
|
|
b107b8539d | ||
|
|
df518c1683 | ||
|
|
2aa41f867e | ||
|
|
8741f4d590 | ||
|
|
d880b23fec | ||
|
|
c97be88ae5 | ||
|
|
600b2a1dda | ||
|
|
03b52db24e | ||
|
|
27f9120510 | ||
|
|
0a699d34f5 | ||
|
|
96afc48ce2 | ||
|
|
804a288214 | ||
|
|
1851dc87a7 | ||
|
|
ba797f60a1 | ||
|
|
95f9ad56b2 | ||
|
|
528d65ab12 | ||
|
|
f8eb2f086e | ||
|
|
26a2aa2fdc | ||
|
|
006006f1d5 | ||
|
|
453ee22376 | ||
|
|
db4ea8a200 | ||
|
|
d48e56778e | ||
|
|
17e4d547c7 | ||
|
|
b242a5ac15 | ||
|
|
8975e2370b | ||
|
|
9a7f75b75d | ||
|
|
a4839d322b | ||
|
|
c5a2259d92 | ||
|
|
7ec3fd2b4c | ||
|
|
be73f4ac6a | ||
|
|
8027cb4176 | ||
|
|
91400aac5b | ||
|
|
26894ab3fd | ||
|
|
ae17e26009 | ||
|
|
ae3b844d74 | ||
|
|
03c8594494 | ||
|
|
9540734e29 | ||
|
|
601df6c22d | ||
|
|
3dd17ad7d7 | ||
|
|
394ce4928c | ||
|
|
0e4965845a | ||
|
|
8fa967ac07 | ||
|
|
aec52c85e9 | ||
|
|
a0a170064e | ||
|
|
caae026416 | ||
|
|
92ed365a19 | ||
|
|
c65338b3cb | ||
|
|
590ca775c2 | ||
|
|
38bea19137 | ||
|
|
282556870f | ||
|
|
a03940e0a8 | ||
|
|
4f70efcfa9 | ||
|
|
a83d6f33be | ||
|
|
9c26db87e6 | ||
|
|
f49d5d91e6 | ||
|
|
ec7a2e22a7 | ||
|
|
3caf8f7014 | ||
|
|
8d6d1f26a4 | ||
|
|
b78daab11c | ||
|
|
da54f988d4 | ||
|
|
455817204f | ||
|
|
aadb8b6727 | ||
|
|
7b94de404e | ||
|
|
9e76f8c4f0 | ||
|
|
8087d8bf58 | ||
|
|
659e6d7896 | ||
|
|
ae63b23dd6 | ||
|
|
a3f5615ec7 | ||
|
|
b244c5d26e | ||
|
|
d983ef89b4 | ||
|
|
9af6d0be5c | ||
|
|
034c0f0228 | ||
|
|
dec94b6c61 | ||
|
|
c1e7a86622 | ||
|
|
6ee319c607 | ||
|
|
fc5cd8060d | ||
|
|
0555120244 | ||
|
|
9feb4da17c | ||
|
|
f8f49a033f | ||
|
|
245206203d | ||
|
|
1da8c57e72 | ||
|
|
11635c3bdf | ||
|
|
ba6ca28577 | ||
|
|
612d5f33ea | ||
|
|
f6b3e9935d | ||
|
|
749a8a2a64 | ||
|
|
175419c6e7 | ||
|
|
aea0df9770 | ||
|
|
145c4c877b | ||
|
|
4b3af580d9 | ||
|
|
a34cad379f | ||
|
|
a27579dd72 | ||
|
|
23c748b2f3 | ||
|
|
ddbf2ce211 | ||
|
|
5a9e07301b | ||
|
|
e2e591858e | ||
|
|
6a7951e150 | ||
|
|
e944bd2a0e | ||
|
|
51425c9c22 | ||
|
|
dfefb6d46a | ||
|
|
02f147f137 | ||
|
|
54e4274b87 | ||
|
|
c2d137ed06 | ||
|
|
81e4272126 | ||
|
|
7e047c409c | ||
|
|
d3a1ce1d7a | ||
|
|
11eb74b0b6 | ||
|
|
68f23b9916 | ||
|
|
43929f3ff8 | ||
|
|
f362f15212 | ||
|
|
68fa7714bb | ||
|
|
f0649da024 | ||
|
|
4fbb0cde70 | ||
|
|
fcbadbaa30 | ||
|
|
b279758c7c | ||
|
|
5ec21ff5f1 | ||
|
|
db5c10ba20 | ||
|
|
f4b4f944e3 | ||
|
|
6ff335eacf | ||
|
|
c23b4e8e41 | ||
|
|
b8027d4b19 | ||
|
|
4d98e545eb | ||
|
|
a9453b7c93 | ||
|
|
fe19efb917 | ||
|
|
e34cb7c021 | ||
|
|
5d04a5bee8 | ||
|
|
b66861c721 | ||
|
|
27f63ce50c | ||
|
|
8682fb845d | ||
|
|
189b2c6b3c | ||
|
|
c048292f3f | ||
|
|
ee83d8f247 | ||
|
|
c8464c6bff | ||
|
|
b12c89650e | ||
|
|
990786901a | ||
|
|
1f51e3d18c | ||
|
|
49b167ce22 | ||
|
|
0a9a7c0d06 | ||
|
|
d104a350cd | ||
|
|
1e6884c591 | ||
|
|
cdbe1edcda | ||
|
|
2bfa7f57ba | ||
|
|
6cd4238930 | ||
|
|
76e2c84103 | ||
|
|
7abae79d41 | ||
|
|
3c1ab736d5 | ||
|
|
0a2de159fd | ||
|
|
f19390bae1 | ||
|
|
78bfe74465 | ||
|
|
d25d0f025b | ||
|
|
fb74b6038f | ||
|
|
bde76794ee | ||
|
|
a31a435525 | ||
|
|
2918f31811 | ||
|
|
52a0a5a89c | ||
|
|
c813261574 | ||
|
|
7650451b4f | ||
|
|
3c22ff0849 | ||
|
|
c0171fa6bc | ||
|
|
707348fcd5 | ||
|
|
8a458496c7 | ||
|
|
3144276935 | ||
|
|
8c6ec16c99 | ||
|
|
6b8fde092f | ||
|
|
7a2de4e328 | ||
|
|
08f4fd5c8e | ||
|
|
6c995bfa15 | ||
|
|
6b60056637 | ||
|
|
88b7bfafc0 | ||
|
|
34d8d94022 | ||
|
|
311c0f7392 | ||
|
|
83f0d5bf58 | ||
|
|
728c6a62b3 | ||
|
|
9b863d6093 | ||
|
|
c80b3b4950 | ||
|
|
2a0f4e39a8 | ||
|
|
acbcaad743 | ||
|
|
e7f41e7d13 | ||
|
|
e3e08911ba | ||
|
|
290e2e1ac7 | ||
|
|
87ff54e702 | ||
|
|
8cc8c6180d | ||
|
|
852e7cd524 | ||
|
|
28ef79ed66 | ||
|
|
f52d053817 | ||
|
|
941f148957 | ||
|
|
ad7203fc8a | ||
|
|
fa8291fc97 | ||
|
|
593b99a68b | ||
|
|
d64bd7c528 | ||
|
|
45bc45b396 | ||
|
|
6f0c0c13b6 | ||
|
|
b90ab85f44 | ||
|
|
b110608a93 | ||
|
|
c675329208 | ||
|
|
382380fc8b | ||
|
|
9e447a7e92 | ||
|
|
ad50996f7c | ||
|
|
b7b36836af | ||
|
|
9a0fcda3a9 | ||
|
|
df3ac51447 | ||
|
|
c776d6586e | ||
|
|
1bbe64265c | ||
|
|
3e79b22c43 | ||
|
|
4498df8be5 | ||
|
|
d1c1af598b | ||
|
|
57c5f81299 | ||
|
|
ce46917121 | ||
|
|
09d61ec866 | ||
|
|
427b48aa42 | ||
|
|
2b54c8a4ab | ||
|
|
ff26716cd1 | ||
|
|
71126d3e13 | ||
|
|
641ff51089 | ||
|
|
45bd296f14 | ||
|
|
87e3df5331 | ||
|
|
cd7b6e025e | ||
|
|
955b6c2134 | ||
|
|
cad817c82d | ||
|
|
a68c2f587d | ||
|
|
d31832eaf4 | ||
|
|
6c3473c6e0 | ||
|
|
e52f0fdcc2 | ||
|
|
3f5a323fd9 | ||
|
|
b6af258465 | ||
|
|
f4b04218f1 | ||
|
|
24ab642e7b | ||
|
|
c58af30ca6 | ||
|
|
4a7173918b | ||
|
|
6ace65fc18 | ||
|
|
301b006ef4 | ||
|
|
2d57f97869 | ||
|
|
8e71cb4754 | ||
|
|
c3b29a2652 | ||
|
|
73bbbd3ee8 | ||
|
|
3163c7867d | ||
|
|
b59d9ada27 | ||
|
|
f7d7c7c70a | ||
|
|
fdba6b4153 | ||
|
|
108ced5793 | ||
|
|
7cb390fbff | ||
|
|
c8d75a7892 | ||
|
|
828a2519e1 | ||
|
|
053291e339 | ||
|
|
130f1dc6a5 | ||
|
|
a1f1501305 | ||
|
|
dd45bf7740 | ||
|
|
cbdbf068bf | ||
|
|
9dc72bf3ac | ||
|
|
1e9644cc15 | ||
|
|
ee6dfd2b6c | ||
|
|
70b832d658 | ||
|
|
c1814283e0 | ||
|
|
4512105e85 | ||
|
|
c3763ff1b3 | ||
|
|
0958cb297a | ||
|
|
5bcc87983e | ||
|
|
a6c8e9070a | ||
|
|
77def1f2dd | ||
|
|
c68e55438f | ||
|
|
344ef96894 | ||
|
|
2f46365df1 | ||
|
|
886ecd9b20 | ||
|
|
2c5dac354e | ||
|
|
bea3165267 | ||
|
|
276f8d49c1 | ||
|
|
90a9d9c612 | ||
|
|
7ecb43ccc8 | ||
|
|
d113131f7e | ||
|
|
82deb28954 | ||
|
|
bf2961834b | ||
|
|
8fdb27f949 | ||
|
|
e372fce7ab | ||
|
|
1aa6f21620 | ||
|
|
3e3c0cebb5 | ||
|
|
3df60c21c2 | ||
|
|
8cf51888c6 | ||
|
|
03a3c48a71 | ||
|
|
b485c43928 | ||
|
|
f70b39290d | ||
|
|
70f1b71de1 | ||
|
|
d8aaf367eb | ||
|
|
3d401e55f4 | ||
|
|
5335354658 | ||
|
|
0cb1d46393 | ||
|
|
53289d3565 | ||
|
|
ab92ab346c | ||
|
|
88d061ec9b | ||
|
|
4231905b1e | ||
|
|
1114b4d734 | ||
|
|
c31e8b2381 | ||
|
|
2e05b11a4d | ||
|
|
23d4bc0a69 | ||
|
|
f1ab726503 | ||
|
|
061ae61465 | ||
|
|
5320391ff6 | ||
|
|
7e4012c83e | ||
|
|
c94c4e140f | ||
|
|
3d8cf1f53a | ||
|
|
5a79c8dede | ||
|
|
2979303263 | ||
|
|
ce266e60be | ||
|
|
e410a3977b | ||
|
|
d177a96be6 | ||
|
|
452c444933 | ||
|
|
a4eefb0730 | ||
|
|
f72a63ca01 | ||
|
|
5febf0d957 | ||
|
|
2256941af7 | ||
|
|
6e15257873 | ||
|
|
1d83584da4 | ||
|
|
8d6754226e | ||
|
|
2ae2e57c66 | ||
|
|
de294e34bb | ||
|
|
fed2bdb304 | ||
|
|
4aca4e151a | ||
|
|
1faafb9f90 | ||
|
|
6bbf40e345 | ||
|
|
4d460bfb3a | ||
|
|
c89434557b | ||
|
|
d208121c8e | ||
|
|
55c961b889 | ||
|
|
1880e12f2b | ||
|
|
d41aa95dbd | ||
|
|
7d4792fb31 | ||
|
|
dcc5899692 | ||
|
|
51911080f0 | ||
|
|
1dcb33f111 | ||
|
|
163f099ecb | ||
|
|
6d92727d9d | ||
|
|
0bfb011ee2 | ||
|
|
b4700c0a9d | ||
|
|
2b4e1fd03b | ||
|
|
2631eca00b | ||
|
|
355df65240 | ||
|
|
c7c4cfe996 | ||
|
|
ef4018805e | ||
|
|
905464e2b3 | ||
|
|
6c2effae82 | ||
|
|
9572330ee4 | ||
|
|
d322c4b870 | ||
|
|
4bfb7f6506 | ||
|
|
ba105e0784 | ||
|
|
980c619b81 | ||
|
|
9f42b39aca | ||
|
|
1e317cff5e | ||
|
|
0f47d2a028 | ||
|
|
0c73bb3c90 | ||
|
|
0c84eb3079 | ||
|
|
666a1ba6f7 | ||
|
|
a1ffa1ac11 | ||
|
|
50c54098ef | ||
|
|
4cc134d08a | ||
|
|
9bf96bcea5 | ||
|
|
4457d4e0ca | ||
|
|
96290f4b94 | ||
|
|
facc1d4d08 | ||
|
|
15d99a6856 | ||
|
|
8dfe518b60 | ||
|
|
2c612fd791 | ||
|
|
48db367d4a | ||
|
|
b7b7cd6c50 | ||
|
|
74b08e6310 | ||
|
|
1dff593ac3 | ||
|
|
68d1a8a388 | ||
|
|
4292806437 | ||
|
|
c841ab6d56 | ||
|
|
8b7315a6ff | ||
|
|
76be4d0e73 | ||
|
|
b2a7e77036 | ||
|
|
86a2f8e470 | ||
|
|
9d07d5a3e2 | ||
|
|
298a9778a7 | ||
|
|
ffda62db09 | ||
|
|
d4526191ce | ||
|
|
ae3bbe6bb4 | ||
|
|
b3fabc1743 | ||
|
|
34397fe09d | ||
|
|
c56102125b | ||
|
|
94c1b5bfaa | ||
|
|
eab457d0ae | ||
|
|
b3bc8e807e | ||
|
|
be42e67e9d | ||
|
|
02248f4314 | ||
|
|
dd1581c062 | ||
|
|
5450f5e872 | ||
|
|
b0fd6e660e | ||
|
|
5ba4793d35 | ||
|
|
5e2871dbfd | ||
|
|
f565f67ee5 | ||
|
|
91b0db48e7 | ||
|
|
01ef17a258 | ||
|
|
559ea59fcb | ||
|
|
09b3057610 | ||
|
|
2203c1777a | ||
|
|
04000cb4db | ||
|
|
3f10f57178 | ||
|
|
894576e1e9 | ||
|
|
2b05e2809b | ||
|
|
a83cf01169 | ||
|
|
b023ab934a | ||
|
|
834a84f198 | ||
|
|
181d188324 | ||
|
|
d3af1e04e0 | ||
|
|
93d58b7024 | ||
|
|
d4e1b3c773 | ||
|
|
f299e18fc6 | ||
|
|
1da949efbd | ||
|
|
0f1ab46921 | ||
|
|
f1fc01ffcf | ||
|
|
80e1eb20cc | ||
|
|
4ee54d84a8 | ||
|
|
e93aba600b | ||
|
|
24b26da5d6 | ||
|
|
3bea1cd023 | ||
|
|
e5f4d02549 | ||
|
|
a7d1d17ac9 | ||
|
|
3093f22a8a | ||
|
|
f1a3409721 | ||
|
|
7b0192118e | ||
|
|
ff3d57ea9c | ||
|
|
f10a7cd880 | ||
|
|
b73ca494a8 | ||
|
|
b10d927e5e | ||
|
|
a9b5ca3936 | ||
|
|
07f1bcff68 | ||
|
|
2a85b6a432 | ||
|
|
48121cb109 | ||
|
|
b222dd9194 | ||
|
|
35e047fb8e | ||
|
|
d782e455a4 | ||
|
|
ee403545ce | ||
|
|
160c1e8b7a | ||
|
|
98f5a811cc | ||
|
|
7bbd715c8c | ||
|
|
22980fc8cc | ||
|
|
49bbdf8954 | ||
|
|
258dd8561d | ||
|
|
bdbf67ef93 | ||
|
|
060a37dc85 | ||
|
|
9db6a0ee48 | ||
|
|
4c78d1b4f8 | ||
|
|
045bda8bfc | ||
|
|
8b26c4cf34 | ||
|
|
467413df5b | ||
|
|
bb3a289caa | ||
|
|
7dfd4c6280 | ||
|
|
31eecdc271 | ||
|
|
1dd47581de | ||
|
|
920a728989 | ||
|
|
40f79354d6 | ||
|
|
2d088739e5 | ||
|
|
0a8af38ac4 | ||
|
|
0b970c0c9a | ||
|
|
da66056bd5 | ||
|
|
d5699d5f90 | ||
|
|
7092d5f4d7 | ||
|
|
e5403c0224 | ||
|
|
1fd42f311f | ||
|
|
0baf6cbd62 | ||
|
|
ef5361e4c2 | ||
|
|
97e78e0f11 | ||
|
|
a6548089c4 | ||
|
|
bbc57aa838 | ||
|
|
d143666e46 | ||
|
|
ea1c0a4490 | ||
|
|
c7432c21e1 | ||
|
|
2c0d9cc30e | ||
|
|
f988ed417a | ||
|
|
3adff150cf | ||
|
|
1e6f05ed5a | ||
|
|
21019afc2b | ||
|
|
785e83feeb | ||
|
|
03190ec724 | ||
|
|
5e9d45bc9a | ||
|
|
f45e3f1237 | ||
|
|
9aa6d7723f | ||
|
|
fbd6bee3e3 | ||
|
|
e50a4c6443 | ||
|
|
6824224a33 | ||
|
|
c28bea8e96 | ||
|
|
2a576b5886 | ||
|
|
eb443cdacc | ||
|
|
4be53ee28a | ||
|
|
9d5301627b | ||
|
|
d5b88ff130 | ||
|
|
b7629cf656 | ||
|
|
a413812363 | ||
|
|
dd73faccbc | ||
|
|
072f5ce2ee | ||
|
|
401ac1322f | ||
|
|
28206976d8 | ||
|
|
d81aa97e84 | ||
|
|
8463325648 | ||
|
|
e3bd626688 | ||
|
|
0b49da2466 | ||
|
|
1a857b285d | ||
|
|
acb68ed163 | ||
|
|
b6efebc879 | ||
|
|
2cdb6362ca | ||
|
|
15877931db | ||
|
|
61c5f29ab7 | ||
|
|
fada660310 | ||
|
|
40b3c1e3d6 | ||
|
|
2fb0ac186a | ||
|
|
4a2b262343 | ||
|
|
820cc211d3 | ||
|
|
8dcd00fc62 | ||
|
|
95c336a387 | ||
|
|
b71623dfd7 | ||
|
|
3762ab7cda | ||
|
|
b3807d6e50 | ||
|
|
09ff66bfee | ||
|
|
61c44c22ef | ||
|
|
386912a76d | ||
|
|
72de6e0b70 | ||
|
|
ac47b988c9 | ||
|
|
a013e35a61 | ||
|
|
74e3b0adf6 | ||
|
|
de0f76476f | ||
|
|
200b756b4a | ||
|
|
bb9c474c64 | ||
|
|
4459f132fe | ||
|
|
bd09f8b03e | ||
|
|
89d6e1c2a5 | ||
|
|
84a3f1ee18 | ||
|
|
9c7909f770 | ||
|
|
481e75bc33 | ||
|
|
dab13669f5 | ||
|
|
e4a151c2e9 | ||
|
|
96199c23e7 | ||
|
|
2c8930c48d | ||
|
|
3a35e147e5 | ||
|
|
aa2f60b7bd | ||
|
|
c965aad8d3 | ||
|
|
a276f81aba | ||
|
|
1a39af6b62 | ||
|
|
71f0ffde4b | ||
|
|
df8b23637e | ||
|
|
390da26cf4 | ||
|
|
1285247230 | ||
|
|
6320718491 | ||
|
|
86d8d9e3a1 | ||
|
|
5dcbe222c9 | ||
|
|
bcd42f50e9 | ||
|
|
c9f554d1c4 | ||
|
|
749a2dc9bd | ||
|
|
260dd423c9 | ||
|
|
7bffc4c670 | ||
|
|
44ba133710 | ||
|
|
aa04a30ad2 | ||
|
|
612024eff4 | ||
|
|
c39579772c | ||
|
|
257654ef30 | ||
|
|
c4856e6feb | ||
|
|
90fe138e83 | ||
|
|
15e0fcb3d8 | ||
|
|
07668d2d1e | ||
|
|
202c421f6e | ||
|
|
90b5da76c4 | ||
|
|
57dca25dd6 | ||
|
|
dbc59979fb | ||
|
|
53db90264c | ||
|
|
27f68454c1 | ||
|
|
1aad193be0 | ||
|
|
c55f89ef98 | ||
|
|
2617b67c79 | ||
|
|
15cf80921e | ||
|
|
60e8ec326a | ||
|
|
bf5c7e4ff2 | ||
|
|
aebc95fd00 | ||
|
|
754437fac6 | ||
|
|
2f527aaaf2 | ||
|
|
0f2a497f02 | ||
|
|
319c7fcaee | ||
|
|
4cd1504513 | ||
|
|
35254c4f02 | ||
|
|
1506c1808a | ||
|
|
c756a372a3 | ||
|
|
c5cfab5e07 | ||
|
|
ec2f1b2a0c | ||
|
|
330a5205ae | ||
|
|
49dd31d36f | ||
|
|
e6b0f92427 | ||
|
|
224b83c6a6 | ||
|
|
18cac82d44 | ||
|
|
173463acdf | ||
|
|
abe7047d19 | ||
|
|
3bc38cebb4 | ||
|
|
3a7d00a74c | ||
|
|
ab289a2cf3 | ||
|
|
b5a791170d | ||
|
|
eb6c81945a | ||
|
|
8d9e1e95ac | ||
|
|
d89fb16f60 | ||
|
|
58e184f5d5 | ||
|
|
c43f4bdb2b | ||
|
|
aac2f3a903 | ||
|
|
f80f4e01e7 | ||
|
|
7b35ca2799 | ||
|
|
379692f355 | ||
|
|
9a8453c612 | ||
|
|
bf59c0c4f7 | ||
|
|
30c4b5e80d | ||
|
|
066f07968b | ||
|
|
732efe69bb | ||
|
|
441f749c25 | ||
|
|
b940754555 | ||
|
|
36f68ab838 | ||
|
|
230730d1f5 | ||
|
|
7d6583dc4d | ||
|
|
1fdb119587 | ||
|
|
a7f8650c6b | ||
|
|
b9b47f12da | ||
|
|
5e348aa479 | ||
|
|
1d90d72a64 | ||
|
|
b6345fc61d | ||
|
|
3e2c30a852 | ||
|
|
a2ec3fc791 | ||
|
|
505e8e960f | ||
|
|
6c8880fbd8 | ||
|
|
1fa46979c8 | ||
|
|
0f9de6dbb0 | ||
|
|
3d4b63c19e |
No files matched your search
@@ -2,7 +2,6 @@
|
||||
target/
|
||||
.local/
|
||||
**/node_modules/
|
||||
website/
|
||||
benchmarks/
|
||||
packs/
|
||||
mods/
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
# where Git for Windows' core.autocrlf would check them out with CRLF.
|
||||
docs/launcher-commands.md text eol=lf
|
||||
docs/horizon-ctl-commands.md text eol=lf
|
||||
website/src/tokens.css text eol=lf
|
||||
crates/horizon-theme/tokens.css text eol=lf
|
||||
docs/api/*-reference.md text eol=lf
|
||||
@@ -8,7 +8,7 @@ name: nightly audit
|
||||
# gh workflow run "nightly audit" -f dry_run=true # the audit, without touching issues
|
||||
on:
|
||||
schedule:
|
||||
# GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:00 in Paris.
|
||||
# GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:07 in Paris today.
|
||||
- cron: '7 1 * * *'
|
||||
- cron: '7 2 * * *'
|
||||
workflow_dispatch:
|
||||
@@ -29,15 +29,21 @@ env:
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: [self-hosted, horizon-game]
|
||||
timeout-minutes: 150
|
||||
# The Mac mini's runners only: the audit compares with the previous one, so it stays on one platform.
|
||||
runs-on: [self-hosted, horizon-game, macOS]
|
||||
timeout-minutes: 240 # every harness (~80 min) plus lan_integration (~40 min an attempt under load, retried)
|
||||
steps:
|
||||
- name: 03:00 in Paris
|
||||
- name: 03:07 in Paris
|
||||
id: when
|
||||
env:
|
||||
SCHEDULE: ${{ github.event.schedule }}
|
||||
run: |
|
||||
hour=$(TZ=Europe/Paris date +%H)
|
||||
if [[ "${{ github.event_name }}" == schedule && "$hour" != 03 ]]; then
|
||||
echo "It is ${hour}:00 in Paris: the other schedule runs the audit."
|
||||
# Which cron is 03:07 in Paris, from Paris's offset today: GitHub can start a schedule hours late, so the
|
||||
# clock at start time can't tell (on 2026-10-26 both started after 04:00 Paris and both skipped).
|
||||
offset=$(TZ=Europe/Paris date +%z)
|
||||
ours=$([ "$offset" = +0200 ] && echo '7 1 * * *' || echo '7 2 * * *')
|
||||
if [[ "${{ github.event_name }}" == schedule && "$SCHEDULE" != "$ours" ]]; then
|
||||
echo "Paris is at UTC${offset} today: the '$ours' schedule runs the audit."
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
@@ -65,7 +71,8 @@ jobs:
|
||||
run: |
|
||||
echo "since=$(date +%Y-%m-%dT%H:%M:%S)" >> "$GITHUB_OUTPUT"
|
||||
set +e
|
||||
python3 tools/parity.py --all --timeout 1800 2>&1 | tee "$RUNNER_TEMP/audit.log"
|
||||
# lan_integration (the whole lan_e2e suite) takes ~2,400 s under load, past the usual 1,800.
|
||||
python3 tools/parity.py --all --timeout 3000 2>&1 | tee "$RUNNER_TEMP/audit.log"
|
||||
echo "exit=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
|
||||
{ echo '```'; tail -40 "$RUNNER_TEMP/audit.log"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Issues for the harnesses that regressed
|
||||
|
||||
@@ -23,7 +23,11 @@ env:
|
||||
|
||||
jobs:
|
||||
parity-main:
|
||||
runs-on: [self-hosted, horizon-game]
|
||||
# A Mac mini runner of its own (ludovics-mac-mini-2 holds only this label since 2026-10-26): behind PR parity jobs
|
||||
# this run waited 2 h while every merge replaced the pending one, and on the release runner a release then waited
|
||||
# behind the next run. PR parity jobs have ludovics-mac-mini and the desktop. Not the desktop: a job slowed down
|
||||
# because the maintainer came back could time out, and a failure here bisects and comments on merges.
|
||||
runs-on: [self-hosted, macOS, horizon-parity-main]
|
||||
timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -44,6 +48,9 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: echo "sha=$(python3 tools/parity_main.py base)" >> "$GITHUB_OUTPUT"
|
||||
- name: The harnesses the merges since then can affect
|
||||
id: harnesses
|
||||
# A failure here is judged by the next step: the run passes when every failed harness passes again at HEAD.
|
||||
continue-on-error: true
|
||||
env:
|
||||
PARITY_HOSTS: ''
|
||||
BASE_SHA: ${{ steps.base.outputs.sha }}
|
||||
@@ -51,8 +58,8 @@ jobs:
|
||||
echo "Merges since ${BASE_SHA}:"
|
||||
git log --first-parent --format=' %h %s' "$BASE_SHA..HEAD"
|
||||
python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400
|
||||
- name: The merge that broke them
|
||||
if: failure()
|
||||
- name: A flake, or the merge that broke them
|
||||
if: steps.harnesses.outcome == 'failure'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PARITY_HOSTS: ''
|
||||
@@ -61,7 +68,7 @@ jobs:
|
||||
rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true
|
||||
python3 tools/parity_main.py bisect "$BASE_SHA" --comment
|
||||
- name: Logs of the failed harnesses
|
||||
if: failure()
|
||||
if: steps.harnesses.outcome == 'failure'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: parity-main-logs
|
||||
|
||||
@@ -36,11 +36,30 @@ jobs:
|
||||
- name: Tools from the runner
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Vanilla data, and resources prepared by this preparer
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base }}
|
||||
run: |
|
||||
# main's script: one written before a fix to it left a real .local/horizon-client without sounds.json on
|
||||
# the runner, and every later job there failed `subtitle`. A branch that changes the script runs its own.
|
||||
set -euo pipefail
|
||||
if git rev-parse --verify -q origin/main >/dev/null \
|
||||
&& git diff --quiet "$(git merge-base "$BASE_SHA" HEAD)" HEAD -- tools/ci/vanilla-data.sh; then
|
||||
git show origin/main:tools/ci/vanilla-data.sh > "$RUNNER_TEMP/vanilla-data.sh"
|
||||
bash "$RUNNER_TEMP/vanilla-data.sh"
|
||||
else
|
||||
bash tools/ci/vanilla-data.sh
|
||||
fi
|
||||
- name: The prepared output is the one the preparer's OUTPUT_VERSION says
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base }}
|
||||
run: python3 tools/prepare_output.py check --cache --base "$BASE_SHA"
|
||||
run: |
|
||||
# The job runs main's workflow on the head's files: a head made before OUTPUT_VERSION (#1947) has neither
|
||||
# the tool nor the number, so there is nothing to check.
|
||||
if [ ! -f tools/prepare_output.py ] || ! grep -q OUTPUT_VERSION crates/horizon-prepare/src/lib.rs; then
|
||||
echo "This head's preparer has no OUTPUT_VERSION: nothing to check"
|
||||
exit 0
|
||||
fi
|
||||
python3 tools/prepare_output.py check --cache --base "$BASE_SHA"
|
||||
- name: The version constants are the pinned jar's
|
||||
run: python3 tools/gen_version_consts.py --check
|
||||
- name: The harnesses this change can affect
|
||||
|
||||
@@ -79,9 +79,50 @@ 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
|
||||
|
||||
build:
|
||||
exporters:
|
||||
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
|
||||
@@ -220,6 +261,10 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -31,3 +31,6 @@
|
||||
/.horizon-cache/
|
||||
/crash-reports/
|
||||
/debug/
|
||||
|
||||
# Release-only compiled original exporter bundle.
|
||||
/exporters/
|
||||
@@ -54,7 +54,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
|
||||
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
|
||||
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
|
||||
`website/` (playhorizon.dev; its paths are relative to `website/`), `docs/`, `.github/workflows/`.
|
||||
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private ludovic111/horizon-site: its build
|
||||
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
|
||||
|
||||
## Build and test fast
|
||||
|
||||
@@ -66,7 +67,7 @@ cargo build --profile fast -p horizon-client # optimized, quick to link:
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
python3 tools/parity.py mob_ai sculk # named harnesses
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (host in-frame); --server-thread for the host on its server thread
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host); --in-frame compares the fallback
|
||||
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
```
|
||||
|
||||
@@ -156,7 +157,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
|
||||
`[server-thread]` issues list the gaps left.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
|
||||
the world itself, one level that follows the player (`fn enter_dimension`). Most harnesses test this path.
|
||||
the world itself, one level that follows the player (`fn enter_dimension`). Deterministic rule fixtures still use this path; player-facing integration uses the default server thread.
|
||||
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
|
||||
when you touch player-facing gameplay:
|
||||
@@ -164,8 +165,8 @@ when you touch player-facing gameplay:
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
|
||||
python3 tools/lan_e2e.py --check <name> # host in-frame, guest is a server client
|
||||
python3 tools/lan_e2e.py --server-thread --check <name> # host on its server thread (portals, levels, distance, lava need it)
|
||||
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
|
||||
python3 tools/lan_e2e.py --in-frame --check <name> # comparison (portals, levels, distance, lava need server-thread mode)
|
||||
python3 tools/parity.py dedicated_server server_entities server_player_damage
|
||||
```
|
||||
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, a
|
||||
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
|
||||
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
|
||||
|
||||
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
|
||||
release them. Anything else (videos, Discord and other community material) lives elsewhere.
|
||||
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
|
||||
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere.
|
||||
|
||||
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
|
||||
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
|
||||
|
||||
Generated
+301
-310
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "xtask"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-hdr", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -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 JDK, 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** 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>
|
||||
|
||||
+68
-54
@@ -134,7 +134,7 @@ Better tools for mod makers: a test runner that needs no game window, templates
|
||||
|
||||
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
|
||||
|
||||
More: [website/docs/test-a-mod.md](website/docs/test-a-mod.md)
|
||||
More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -160,6 +160,66 @@ Translatable components resolved through the selected language for items, contai
|
||||
|
||||
</details>
|
||||
|
||||
#### Cooldowns drawn on items
|
||||
|
||||
After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
|
||||
|
||||
More: [#1933](https://github.com/ludovic111/horizon/issues/1933)
|
||||
|
||||
</details>
|
||||
|
||||
#### Chat settings and team prefixes
|
||||
|
||||
The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
|
||||
|
||||
More: [#1932](https://github.com/ludovic111/horizon/issues/1932) · [#1841](https://github.com/ludovic111/horizon/issues/1841)
|
||||
|
||||
</details>
|
||||
|
||||
#### A pointing hand over buttons
|
||||
|
||||
The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
|
||||
|
||||
More: [#1897](https://github.com/ludovic111/horizon/issues/1897)
|
||||
|
||||
</details>
|
||||
|
||||
#### Ctrl+Backspace and scroll bars in menus
|
||||
|
||||
Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
|
||||
|
||||
More: [#1884](https://github.com/ludovic111/horizon/issues/1884) · [#1931](https://github.com/ludovic111/horizon/issues/1931)
|
||||
|
||||
</details>
|
||||
|
||||
#### Chickens flap while they fall
|
||||
|
||||
Chickens should flap their wings while they fall, not stand still in the air.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
|
||||
|
||||
More: [#1892](https://github.com/ludovic111/horizon/issues/1892)
|
||||
|
||||
</details>
|
||||
|
||||
### Rendering
|
||||
|
||||
#### Shader packs, written again from scratch
|
||||
@@ -250,7 +310,7 @@ Horizon should run well on small machines: integrated graphics, the Steam Deck,
|
||||
|
||||
Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported on the Benchmarks page.
|
||||
|
||||
More: [docs/benchmark.md](docs/benchmark.md) · [website/docs/performance.md](website/docs/performance.md)
|
||||
More: [docs/benchmark.md](docs/benchmark.md) · [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -540,7 +600,9 @@ You can port a mod to Horizon with your own AI agent on your own computer. Horiz
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide ([website/docs/agents.md](website/docs/agents.md), [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
|
||||
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (playhorizon.dev/docs/agents, [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
|
||||
|
||||
More: [https://playhorizon.dev/docs/agents](https://playhorizon.dev/docs/agents)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -876,7 +938,9 @@ Run Horizon's built-in benchmark and share the report: every new computer makes
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it ([website/docs/performance.md](website/docs/performance.md)). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
|
||||
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
|
||||
|
||||
More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -902,56 +966,6 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
|
||||
</details>
|
||||
|
||||
#### Cooldowns drawn on items
|
||||
|
||||
After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
|
||||
|
||||
</details>
|
||||
|
||||
#### Chat settings and team prefixes
|
||||
|
||||
The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
|
||||
|
||||
</details>
|
||||
|
||||
#### A pointing hand over buttons
|
||||
|
||||
The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
|
||||
|
||||
</details>
|
||||
|
||||
#### Ctrl+Backspace and scroll bars in menus
|
||||
|
||||
Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Chickens flap while they fall
|
||||
|
||||
Chickens should flap their wings while they fall, not stand still in the air.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
|
||||
|
||||
</details>
|
||||
|
||||
## Recently shipped
|
||||
|
||||
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/ludovic111/horizon/issues/1917) · [#1920](https://github.com/ludovic111/horizon/issues/1920) · [#1936](https://github.com/ludovic111/horizon/issues/1936)
|
||||
|
||||
@@ -17,4 +17,5 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
|
||||
|
||||
`crates/horizon-theme/icons/pixel/*.svg` are icons from
|
||||
[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT
|
||||
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`).
|
||||
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
|
||||
the same grid.
|
||||
@@ -1,10 +1,11 @@
|
||||
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
|
||||
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
|
||||
//! when given a key, and for a mod a trial run in the sandbox: it must start,
|
||||
//! survive ticks, frames, a chat line and each of its commands, and import
|
||||
//! no more than its capabilities.
|
||||
//! survive ticks, frames, a chat line and each of its commands (a server mod:
|
||||
//! a player joining, chatting, typing them and leaving), and import no more
|
||||
//! than its capabilities.
|
||||
use horizon_mods::{
|
||||
Clock, Mods, Output, Package, Player, Snapshot, Status,
|
||||
Clock, GameMode, Mods, Output, Package, Player, ServerPlayer, Snapshot, Status,
|
||||
index::{self, Facts, Kind},
|
||||
manifest::component_imports,
|
||||
};
|
||||
@@ -46,6 +47,7 @@ impl Report {
|
||||
"api": self.facts.api,
|
||||
"side": self.facts.side,
|
||||
"capabilities": self.facts.capabilities,
|
||||
"api_level": self.facts.api_level,
|
||||
"game": self.facts.game,
|
||||
"requires": requires,
|
||||
"sha256": self.sha256,
|
||||
@@ -69,6 +71,8 @@ impl Report {
|
||||
if let Some(side) = f.side {
|
||||
let side = serde_json::to_value(side).unwrap_or_default();
|
||||
out += &format!(", API {}, {}", f.api, side.as_str().unwrap_or("?"));
|
||||
} else if self.kind == Kind::Shader {
|
||||
out += &format!(", shader API {}", f.api);
|
||||
} else if !f.api.is_empty() {
|
||||
out += &format!(", format {}", f.api);
|
||||
}
|
||||
@@ -84,6 +88,9 @@ impl Report {
|
||||
")\n {} bytes, SHA-256 {}, {signed}\n",
|
||||
self.size, self.sha256
|
||||
);
|
||||
if let Some(level) = f.api_level {
|
||||
out += &format!(" needs mod API level {level} or newer\n");
|
||||
}
|
||||
for c in &f.capabilities {
|
||||
out += &format!(" may {}\n", c.describe());
|
||||
}
|
||||
@@ -113,6 +120,7 @@ pub fn kind_of(path: &Path) -> Option<Kind> {
|
||||
"hmod" => Some(Kind::Mod),
|
||||
"hpack" => Some(Kind::Modpack),
|
||||
"zip" => Some(Kind::ResourcePack),
|
||||
"hshader" => Some(Kind::Shader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -191,45 +199,81 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
if let Some(reason) = failed(&mods) {
|
||||
return Err(format!("it didn't start: {reason}"));
|
||||
}
|
||||
report.commands = mods.commands().map(str::to_owned).collect();
|
||||
let client: Vec<String> = mods.commands().map(str::to_owned).collect();
|
||||
let server: Vec<String> = mods.server_commands().map(str::to_owned).collect();
|
||||
report.commands = [client.clone(), server.clone()].concat();
|
||||
// A server mod plays with one player on, who joins, chats and leaves.
|
||||
let steve = ServerPlayer {
|
||||
id: "00000000-0000-4000-8000-000000000001".into(),
|
||||
name: "Steve".into(),
|
||||
x: 0.5,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
game_mode: GameMode::Survival,
|
||||
health: 20.0,
|
||||
};
|
||||
let snapshot = |t: u64| Snapshot {
|
||||
player: Some(Player {
|
||||
x: 0.5 + t as f64 * 0.1,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
yaw: 90.0,
|
||||
pitch: 0.0,
|
||||
health: 20.0,
|
||||
food: 20,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
}),
|
||||
clock: Some(Clock {
|
||||
game_time: t,
|
||||
day_time: 1000 + t,
|
||||
}),
|
||||
players: vec![steve.clone()],
|
||||
// The GUI of a 1280 x 720 window at scale 3.
|
||||
gui_size: (427.0, 240.0),
|
||||
..Snapshot::default()
|
||||
};
|
||||
mods.set_snapshot(snapshot(0));
|
||||
mods.player_joined(&steve);
|
||||
for t in 0..TICKS {
|
||||
mods.set_snapshot(Snapshot {
|
||||
player: Some(Player {
|
||||
x: 0.5 + t as f64 * 0.1,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
yaw: 90.0,
|
||||
pitch: 0.0,
|
||||
health: 20.0,
|
||||
food: 20,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
}),
|
||||
clock: Some(Clock {
|
||||
game_time: t,
|
||||
day_time: 1000 + t,
|
||||
}),
|
||||
});
|
||||
mods.set_snapshot(snapshot(t));
|
||||
mods.tick(t);
|
||||
mods.server_tick(t);
|
||||
for _ in 0..FRAMES_PER_TICK {
|
||||
mods.frame(1.0 / 60.0);
|
||||
}
|
||||
}
|
||||
mods.chat("<Steve> hello");
|
||||
for name in report.commands.clone() {
|
||||
mods.command(&name);
|
||||
mods.player_chat(&steve, "hello");
|
||||
for name in &client {
|
||||
mods.command(name);
|
||||
}
|
||||
for name in &server {
|
||||
mods.server_command(&steve, name);
|
||||
}
|
||||
mods.set_snapshot(Snapshot::default());
|
||||
mods.player_left(&steve);
|
||||
for out in mods.drain() {
|
||||
report.output.push(match out {
|
||||
Output::Chat { text, .. } => format!("chat: {text}"),
|
||||
Output::Feedback { text, error } => {
|
||||
Output::Tell { text, .. } => format!("tell: {text}"),
|
||||
Output::Broadcast { text, .. } => format!("broadcast: {text}"),
|
||||
Output::Feedback { text, error } | Output::Reply { text, error, .. } => {
|
||||
format!("{}: {text}", if error { "error" } else { "feedback" })
|
||||
}
|
||||
Output::Command { line } => format!("command: /{line}"),
|
||||
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
|
||||
Output::OpenScreen { .. } => "opened a screen".to_string(),
|
||||
Output::CloseScreen { .. } => "closed its screen".to_string(),
|
||||
Output::Shake { strength, seconds } => {
|
||||
format!("shook the camera: {strength:.2} for {seconds:.1} s")
|
||||
}
|
||||
});
|
||||
}
|
||||
let shapes = mods.hud_shapes().count();
|
||||
if shapes > 0 {
|
||||
report.output.push(format!("drew {shapes} HUD shape(s)"));
|
||||
}
|
||||
match failed(&mods) {
|
||||
Some(reason) => Err(format!("it stopped: {reason}")),
|
||||
None => Ok(()),
|
||||
@@ -275,7 +319,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(e.contains("signature"), "{e}");
|
||||
let e = check(&pack, Kind::Shader, None, None).err().unwrap();
|
||||
assert!(e.contains("shader packs open"), "{e}");
|
||||
assert!(e.contains("horizon-shaders.toml"), "{e}");
|
||||
assert!(check(&pack, Kind::Mod, None, None).is_err());
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,15 @@
|
||||
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
//! the mod; run `build` again and the game reloads it.
|
||||
//! - `test [--debug] [--hmod <file>] [<name>]`: build, then run the mod's
|
||||
//! `tests/*.toml` scripts in the sandbox, with no game.
|
||||
//! - `check <file> [--json]`: what the registry checks of a submission
|
||||
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
|
||||
//! - `registry build|review|check|list`: the registry repository's CI.
|
||||
use horizon_mods::{
|
||||
Capability, Content, Manifest, Package,
|
||||
manifest::{MANIFEST, component_imports, componentize},
|
||||
testing::{Script, TestGame},
|
||||
};
|
||||
mod check;
|
||||
mod registry;
|
||||
@@ -36,9 +39,10 @@ fn main() -> ExitCode {
|
||||
println!("Built {}", p.display());
|
||||
}),
|
||||
Some("run") => run(&args[1..]),
|
||||
Some("test") => test(&args[1..]),
|
||||
Some("check") => check(&args[1..]),
|
||||
Some("registry") => registry::main(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -97,6 +101,10 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
(MANIFEST, include_str!("../template/horizon-mod.toml")),
|
||||
("src/lib.rs", include_str!("../template/src/lib.rs")),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
(
|
||||
"tests/hello.toml",
|
||||
include_str!("../template/tests/hello.toml"),
|
||||
),
|
||||
];
|
||||
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
|
||||
let content = [
|
||||
@@ -270,6 +278,63 @@ fn check(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `test`: every `tests/*.toml` script (those whose name holds `<name>`)
|
||||
/// against the built mod, or the `.hmod` given with `--hmod`.
|
||||
fn test(args: &[String]) -> Result<(), String> {
|
||||
let hmod = match flag(args, "--hmod") {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => build(!args.iter().any(|a| a == "--debug"))?,
|
||||
};
|
||||
let filter = args
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(i, a)| !a.starts_with("--") && (*i == 0 || args[i - 1] != "--hmod"))
|
||||
.map(|(_, a)| a.as_str());
|
||||
let mut scripts: Vec<PathBuf> = std::fs::read_dir("tests")
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"tests/: {e} (a mod's tests are tests/<name>.toml: docs/mods.md, \"Testing a mod\")"
|
||||
)
|
||||
})?
|
||||
.filter_map(|e| Some(e.ok()?.path()))
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
|
||||
.filter(|p| filter.is_none_or(|f| p.to_string_lossy().contains(f)))
|
||||
.collect();
|
||||
scripts.sort();
|
||||
if scripts.is_empty() {
|
||||
return Err("no tests/*.toml to run".into());
|
||||
}
|
||||
println!(
|
||||
"running {} scripts against {}",
|
||||
scripts.len(),
|
||||
hmod.display()
|
||||
);
|
||||
let mut failed = 0;
|
||||
for path in &scripts {
|
||||
let result = std::fs::read_to_string(path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|text| Script::parse(&text))
|
||||
.and_then(|script| {
|
||||
let mut game = TestGame::load(&hmod)?;
|
||||
script.run(&mut game)?;
|
||||
Ok(script.steps.len())
|
||||
});
|
||||
match result {
|
||||
Ok(steps) => println!("test {} ... ok ({steps} steps)", path.display()),
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
println!("test {} ... FAILED\n {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
let passed = scripts.len() - failed;
|
||||
if failed > 0 {
|
||||
return Err(format!("{failed} failed, {passed} passed"));
|
||||
}
|
||||
println!("test result: ok. {passed} passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let path = build(true)?;
|
||||
let game = flag(args, "--game")
|
||||
@@ -312,6 +377,8 @@ mod tests {
|
||||
);
|
||||
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
|
||||
assert!(cargo.contains("name = \"my-mod\"") && cargo.contains(SDK_GIT));
|
||||
let script = std::fs::read_to_string(path.join("tests/hello.toml")).unwrap();
|
||||
assert_eq!(Script::parse(&script).unwrap().steps.len(), 3);
|
||||
assert!(
|
||||
new(&[path.display().to_string()]).is_err(),
|
||||
"never overwrites"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# `cargo horizon-mod test` runs every tests/*.toml against the built mod, with no game:
|
||||
# steps happen in order, and `expect` checks what the mod showed during the step.
|
||||
# Every key: docs/mods.md, "Testing a mod".
|
||||
|
||||
[[step]]
|
||||
expect.commands = ["hello"]
|
||||
|
||||
[[step]]
|
||||
command = "hello world"
|
||||
expect.chat = ["Hello world! (1 so far)"]
|
||||
|
||||
[[step]]
|
||||
command = "hello again"
|
||||
expect.chat = ["Hello again! (2 so far)"]
|
||||
@@ -12,16 +12,19 @@ horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
# server.properties, eula.txt and the user lists, shared with horizon-server.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
# Their runtime is the `mods` feature.
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false }
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
|
||||
@@ -367,6 +367,7 @@ pub fn main() -> Result<()> {
|
||||
lid: &|_| open,
|
||||
sign: &|_| sign.as_ref(),
|
||||
banner: &|_| layers.clone(),
|
||||
skull: &|_| None,
|
||||
glyph: &|c| atlas.glyph_cell(c),
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! usage: chat_screen_fixture RESOURCES REGISTRIES CASES OUTPUT
|
||||
use crate::pack_screen_fixture;
|
||||
use horizon_client::{
|
||||
chat::{ChatAction, ChatScreen, Commands},
|
||||
chat::{ChatAbilities, ChatAction, ChatScreen, ChatVisibility, Commands},
|
||||
command::vanilla,
|
||||
hud::HudAtlas,
|
||||
lang,
|
||||
@@ -53,6 +53,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
history.len(),
|
||||
w,
|
||||
h,
|
||||
ChatAbilities::new(ChatVisibility::Full),
|
||||
);
|
||||
screen.auto_suggestions = case["autoSuggest"] == true;
|
||||
let mut closed = false;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
use horizon_client::{
|
||||
Result,
|
||||
cushion::{self, World},
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
struct Cell {
|
||||
outline: VoxelShape,
|
||||
collision: VoxelShape,
|
||||
suffocating: bool,
|
||||
}
|
||||
struct Fixture(HashMap<[i32; 3], Cell>);
|
||||
impl World for Fixture {
|
||||
fn outline_bounds(&self, p: [i32; 3]) -> Option<Aabb> {
|
||||
self.0
|
||||
.get(&p)?
|
||||
.outline
|
||||
.bounds()
|
||||
.map(|b| b.move_by(Vec3::new(p[0] as f64, p[1] as f64, p[2] as f64)))
|
||||
}
|
||||
fn collision_shapes(&self, _: Aabb) -> Vec<VoxelShape> {
|
||||
self.0
|
||||
.iter()
|
||||
.map(|(p, c)| {
|
||||
c.collision
|
||||
.moved(Vec3::new(p[0] as f64, p[1] as f64, p[2] as f64))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn suffocating(&self, p: [i32; 3]) -> bool {
|
||||
self.0.get(&p).is_some_and(|c| c.suffocating)
|
||||
}
|
||||
}
|
||||
fn shape(value: &Value) -> Result<VoxelShape> {
|
||||
let mut shape = VoxelShape::empty();
|
||||
for b in value.as_array().unwrap() {
|
||||
let f = |i: usize| b[i].as_f64().unwrap();
|
||||
shape = shape.join(
|
||||
&VoxelShape::from_box(Aabb::new(
|
||||
Vec3::new(f(0), f(1), f(2)),
|
||||
Vec3::new(f(3), f(4), f(5)),
|
||||
))?,
|
||||
ShapeOp::OR,
|
||||
)?;
|
||||
}
|
||||
Ok(shape)
|
||||
}
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<_> = crate::args().collect();
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for line in BufReader::new(File::open(&args[1])?).lines() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
let mut world = Fixture(HashMap::new());
|
||||
for c in row["cells"].as_array().unwrap() {
|
||||
let p = std::array::from_fn(|i| c[i].as_i64().unwrap() as i32);
|
||||
world.0.insert(
|
||||
p,
|
||||
Cell {
|
||||
outline: shape(&c[3])?,
|
||||
collision: shape(&c[4])?,
|
||||
suffocating: c[5].as_bool().unwrap(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let p = &row["pos"];
|
||||
let (x, y, z) = (
|
||||
p[0].as_f64().unwrap(),
|
||||
p[1].as_f64().unwrap(),
|
||||
p[2].as_f64().unwrap(),
|
||||
);
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(x - 0.5, y, z - 0.5),
|
||||
Vec3::new(x + 0.5, y + 0.25, z + 0.5),
|
||||
);
|
||||
writeln!(
|
||||
out,
|
||||
"{} {}",
|
||||
cushion::can_place(&world, bounds),
|
||||
cushion::survives(&world, bounds)
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use horizon_client::mesher::Vertex;
|
||||
use horizon_client::mobs::{
|
||||
Mob, MobData, MobTypes, Part, Species, allay,
|
||||
anim::{self, Poses, State},
|
||||
aquatic, body_anim as b_anim, equine, guardian,
|
||||
aquatic, bat, body_anim as b_anim, equine, guardian,
|
||||
keyframes::AnimationState,
|
||||
render, shulker, sniffer, species, tame,
|
||||
};
|
||||
@@ -108,6 +108,22 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
|
||||
let flag = |k: &str| case[k].as_bool().unwrap_or(false);
|
||||
let num = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32;
|
||||
match species {
|
||||
Species::Bat => {
|
||||
mob.extra.flag = flag("isResting");
|
||||
mob.anim.states[0].started = case["flyAnimationState"].as_f64().map(|v| v as f32);
|
||||
mob.anim.states[1].started = case["restAnimationState"].as_f64().map(|v| v as f32);
|
||||
if let Some(ticks) = case["@batTicks"].as_array() {
|
||||
for (i, flags) in ticks.iter().enumerate() {
|
||||
mob.age = i as u32 + 1;
|
||||
bat::set_resting(mob, flags.as_i64().unwrap_or(0) & 1 != 0);
|
||||
if flag("@batInFrame") {
|
||||
bat::tick(mob);
|
||||
} else {
|
||||
bat::setup_animation_states(mob);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Species::Sheep => mob.extra.anim_ticks = num("eatTick") as i32,
|
||||
Species::Guardian | Species::ElderGuardian => {
|
||||
mob.anim.v[guardian::TAIL] = num("tailAnimation");
|
||||
@@ -377,7 +393,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
mob.extra.age = -24000;
|
||||
}
|
||||
mob.age = f("ageInTicks") as u32;
|
||||
let state = State {
|
||||
let mut state = State {
|
||||
pos: f("walkAnimationPos"),
|
||||
speed: f("walkAnimationSpeed"),
|
||||
age: f("ageInTicks"),
|
||||
@@ -389,6 +405,20 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
};
|
||||
let mut poses = Poses::new(&model);
|
||||
entity_state(&mut mob, def.species, case);
|
||||
if let Some(ticks) = case["@walkTicks"].as_array() {
|
||||
for speed in ticks {
|
||||
mob.previous_limb_speed = mob.limb_speed;
|
||||
mob.limb_speed += (speed.as_f64().unwrap() as f32 - mob.limb_speed) * 0.4;
|
||||
mob.limb_position += mob.limb_speed;
|
||||
}
|
||||
if !case["@alive"].as_bool().unwrap() {
|
||||
mob.health = 0.0;
|
||||
}
|
||||
let extracted = anim::state(&mob, f("@partial"));
|
||||
state.pos = extracted.pos;
|
||||
state.speed = extracted.speed;
|
||||
}
|
||||
|
||||
let flag = |k: &str| case[k].as_bool().unwrap_or(false);
|
||||
let num = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32;
|
||||
let started = |k: &str| AnimationState {
|
||||
@@ -456,14 +486,6 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
roll_amount: num("rollAmount"),
|
||||
},
|
||||
),
|
||||
Species::Bat => b_anim::bat(
|
||||
p,
|
||||
s,
|
||||
flag("isResting"),
|
||||
&started("flyAnimationState"),
|
||||
&started("restAnimationState"),
|
||||
&animations,
|
||||
),
|
||||
Species::Armadillo => b_anim::armadillo(
|
||||
p,
|
||||
s,
|
||||
@@ -590,7 +612,11 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
_ => anim::setup(&mob, s, p, &animations),
|
||||
}
|
||||
out.push_str(&format!("{}\n", emit(&model, &mut poses, case, layer)));
|
||||
let mut result = emit(&model, &mut poses, case, layer);
|
||||
if case["@walkTicks"].is_array() {
|
||||
result["walk"] = json!([state.pos, state.speed]);
|
||||
}
|
||||
out.push_str(&format!("{result}\n"));
|
||||
}
|
||||
fs::write(&args[2], out)?;
|
||||
Ok(())
|
||||
|
||||
@@ -319,6 +319,7 @@ fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
atlas,
|
||||
comp(&m["text"]),
|
||||
indicator,
|
||||
horizon_client::chat::ChatSource::Client,
|
||||
m["at"].as_u64().unwrap_or(0),
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ mod cloud_fixture;
|
||||
mod command_parse_fixture;
|
||||
mod container_screen_probe;
|
||||
mod creative_search_fixture;
|
||||
mod cushion_placement_probe;
|
||||
mod emitter_particles_probe;
|
||||
mod enchanting_fixture;
|
||||
mod entity_anim_fixture;
|
||||
@@ -107,6 +108,7 @@ probes! {
|
||||
block_transform_fixture,
|
||||
breaking_fixture,
|
||||
camera_probe,
|
||||
cushion_placement_probe,
|
||||
cauldron_fixture,
|
||||
chat_screen_fixture,
|
||||
cloud_fixture,
|
||||
|
||||
@@ -49,6 +49,7 @@ struct Scenario {
|
||||
name: String,
|
||||
ticks: i32,
|
||||
time: i64,
|
||||
difficulty: u8,
|
||||
rain: bool,
|
||||
thunder: bool,
|
||||
origin: [i32; 2],
|
||||
@@ -85,11 +86,13 @@ fn parse(text: &str) -> Vec<Scenario> {
|
||||
"scenario" => {
|
||||
s.name = f[1].to_owned();
|
||||
s.time = 6000;
|
||||
s.difficulty = 2;
|
||||
for kv in &f[2..] {
|
||||
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
|
||||
match k {
|
||||
"ticks" => s.ticks = v.parse().unwrap(),
|
||||
"time" => s.time = v.parse().unwrap(),
|
||||
"difficulty" => s.difficulty = v.parse().unwrap(),
|
||||
"rain" => s.rain = true,
|
||||
"thunder" => s.thunder = true,
|
||||
"levelseed" => {}
|
||||
@@ -598,6 +601,7 @@ fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool, now: i6
|
||||
let _ = writeln!(out, " removed {reason}");
|
||||
return;
|
||||
}
|
||||
let _ = write!(out, "\tleashed {}", u8::from(m.extra.leash.is_some()));
|
||||
let p = m.position;
|
||||
let v = m.velocity;
|
||||
let _ = write!(
|
||||
@@ -953,6 +957,10 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
for flag in &spec.flags {
|
||||
match flag.as_str() {
|
||||
"persistent" => m.persistent = true,
|
||||
"named" => {
|
||||
m.custom_name = Some("Peaceful regression".into());
|
||||
m.persistent = true;
|
||||
}
|
||||
"ridden" => {
|
||||
m.extra.head = Some("minecraft:red_harness".into());
|
||||
m.rider = Some(horizon_client::mobs::equine::Rider::default());
|
||||
@@ -977,6 +985,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
// Set once the brain exists (below).
|
||||
x if x.starts_with("cooldown=") => {}
|
||||
"dead" => m.health = 0.0,
|
||||
"leashed" => m.extra.leash = Some(horizon_client::mobs::leash::Holder::Player),
|
||||
x if x.starts_with("leashto=") => {
|
||||
let n: u32 = x[8..].parse().unwrap();
|
||||
@@ -1088,7 +1097,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
block_tags: &items.block_tags,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
difficulty: LocalDifficulty::new(2, world.day_time, 0, 0.0),
|
||||
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
|
||||
end: false,
|
||||
world_seed: 0,
|
||||
// FlatLevelSource.getSeaLevel.
|
||||
@@ -1097,6 +1106,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
};
|
||||
}
|
||||
let mut dropped_leads = 0;
|
||||
for t in 1..=s.ticks {
|
||||
world.game_time += 1;
|
||||
if s.daycycle {
|
||||
@@ -1208,6 +1218,16 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
if !alive {
|
||||
m.removed = true;
|
||||
}
|
||||
// Apply the same detach/drop event as Game::break_leash.
|
||||
for event in &events[from..] {
|
||||
if let MobEvent::LeashBreak { mob, drop, .. } = event
|
||||
&& let Some(m) = mobs.iter_mut().find(|m| m.id == *mob)
|
||||
&& horizon_client::mobs::leash::remove(m).is_some()
|
||||
&& *drop
|
||||
{
|
||||
dropped_leads += 1;
|
||||
}
|
||||
}
|
||||
horizon_client::mobs::apply_brain_events(&mut mobs, &mut events, from);
|
||||
villager_events(
|
||||
&mut mobs,
|
||||
@@ -1427,6 +1447,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
world.game_time,
|
||||
);
|
||||
}
|
||||
if s.name.starts_with("leash_death_") {
|
||||
let _ = writeln!(out, "{t} leads {dropped_leads}");
|
||||
}
|
||||
for e in &events {
|
||||
if let MobEvent::Sound(name, _, volume, pitch) = e {
|
||||
let _ = writeln!(
|
||||
|
||||
@@ -7,7 +7,7 @@ use horizon_client::{
|
||||
biome::{BiomeInfo, Biomes, GrassModifier},
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, Light, MIN_Y, index},
|
||||
mesher::{MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
|
||||
mesher::{EMISSION_BITS, MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
|
||||
models,
|
||||
};
|
||||
use horizon_core::biome::BiomeZoom;
|
||||
@@ -161,7 +161,15 @@ fn scene(
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let ([x, y, z], [u, w]) = (v.pos, v.uv());
|
||||
json!([x, y, z, u, w, argb(v.color), light_coords(v.light)])
|
||||
json!([
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
u,
|
||||
w,
|
||||
argb(v.color | EMISSION_BITS),
|
||||
light_coords(v.light)
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
quads.push(json!({"layer": layer, "sprite": sprite, "vertices": vertices}));
|
||||
|
||||
@@ -58,7 +58,10 @@ enum Animate {
|
||||
BubbleColumn {
|
||||
drag: bool,
|
||||
},
|
||||
Fire,
|
||||
/// `BaseFireBlock` (`SoulFireBlock.canBurn` is always true).
|
||||
Fire {
|
||||
soul: bool,
|
||||
},
|
||||
/// An active sculk sensor.
|
||||
SculkSensor,
|
||||
/// Potent sulfur that is not dry.
|
||||
@@ -140,7 +143,8 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
"bubble_column" => Animate::BubbleColumn {
|
||||
drag: prop("drag").as_deref() == Some("true"),
|
||||
},
|
||||
"fire" | "soul_fire" => Animate::Fire,
|
||||
"fire" => Animate::Fire { soul: false },
|
||||
"soul_fire" => Animate::Fire { soul: true },
|
||||
"dried_ghast" => Animate::DriedGhast {
|
||||
waterlogged: prop("waterlogged").as_deref() == Some("true"),
|
||||
},
|
||||
@@ -190,6 +194,8 @@ pub struct AnimateTables {
|
||||
power_transmitter: Vec<bool>,
|
||||
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
|
||||
dried_ghast_sounds: 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)>>,
|
||||
}
|
||||
@@ -220,6 +226,21 @@ impl AnimateTables {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let behaviour: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(root.join("block-behaviours.json"))?)?;
|
||||
let ignite_odds = behaviour["fire"]["igniteOdds"].as_object();
|
||||
let burnable = (0..registry.len() as u32)
|
||||
.map(|id| {
|
||||
// FireBlock.getIgniteOdds: a waterlogged state never burns.
|
||||
registry.state(BlockStateId(id)).is_some_and(|s| {
|
||||
s.properties.get("waterlogged").map(String::as_str) != Some("true")
|
||||
&& ignite_odds
|
||||
.and_then(|m| m.get(s.name.as_str()))
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.is_some_and(|odds| odds > 0)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut ambient = vec![Vec::new(); biomes.list.len()];
|
||||
for (name, &id) in &biomes.index {
|
||||
let path = root.join(format!(
|
||||
@@ -248,6 +269,7 @@ impl AnimateTables {
|
||||
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"),
|
||||
burnable,
|
||||
ambient,
|
||||
})
|
||||
}
|
||||
@@ -1103,7 +1125,7 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::Fire => {
|
||||
Animate::Fire { soul } => {
|
||||
let r = engine.random();
|
||||
if next_int(r, 24) == 0 {
|
||||
let (volume, pitch) = (1.0 + r.next_float(), r.next_float() * 0.7 + 0.3);
|
||||
@@ -1115,6 +1137,55 @@ fn block_animate_tick(
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
// BaseFireBlock.animateTick: three large smoke above the fire, or
|
||||
// two at each burnable neighbour's face when nothing burnable or
|
||||
// sturdy supports it (SoulFireBlock.canBurn is always true).
|
||||
let can_burn = |p: [i32; 3]| {
|
||||
soul || tables
|
||||
.burnable
|
||||
.get(ctx.state(p).0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let below = [pos[0], pos[1] - 1, pos[2]];
|
||||
let sturdy = ctx.facts.get(ctx.state(below)).sturdy_full & (1 << 1) != 0;
|
||||
if !can_burn(below) && !sturdy {
|
||||
// (neighbour, coordinate axis, face at the axis' high end): the
|
||||
// west, east, north, south and above faces, in vanilla order.
|
||||
for (at, axis, high) in [
|
||||
([pos[0] - 1, pos[1], pos[2]], 0usize, false),
|
||||
([pos[0] + 1, pos[1], pos[2]], 0, true),
|
||||
([pos[0], pos[1], pos[2] - 1], 2, false),
|
||||
([pos[0], pos[1], pos[2] + 1], 2, true),
|
||||
([pos[0], pos[1] + 1, pos[2]], 1, true),
|
||||
] {
|
||||
if !can_burn(at) {
|
||||
continue;
|
||||
}
|
||||
for _ in 0..2 {
|
||||
let r = engine.random();
|
||||
let d = [r.next_double(), r.next_double(), r.next_double()];
|
||||
let mut p = [fx + d[0], fy + d[1], fz + d[2]];
|
||||
// The face coordinate hugs the edge: `* 0.1F` (float).
|
||||
p[axis] = if high {
|
||||
f64::from(pos[axis]) + 1.0 - d[axis] * f64::from(0.1f32)
|
||||
} else {
|
||||
f64::from(pos[axis]) + d[axis] * f64::from(0.1f32)
|
||||
};
|
||||
add(engine, T::LargeSmoke, p, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _ in 0..3 {
|
||||
let r = engine.random();
|
||||
let p = [
|
||||
fx + r.next_double(),
|
||||
fy + r.next_double() * 0.5 + 0.5,
|
||||
fz + r.next_double(),
|
||||
];
|
||||
add(engine, T::LargeSmoke, p, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,10 +323,15 @@ const QUIET_MESHES_PER_S: f64 = 100.0;
|
||||
/// happened (`tools/perf_bench.py`'s `wait_loaded`). With `status.streaming`, the sections are
|
||||
/// drawn once no stage has held work for half a second: blocks changing after the load (fluids
|
||||
/// settling, random ticks) only remesh in bursts. Without it, meshing has to go quiet.
|
||||
///
|
||||
/// Right after a teleport (and at spawn) the game reports nothing queued and steady counts for over
|
||||
/// a second before it starts loading: the counts must change once, or stay full for 5 s, and the
|
||||
/// resident chunks reach [`view_chunks`], before "steady" means loaded.
|
||||
fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result<Instant, String> {
|
||||
let deadline = Instant::now() + LOAD_LIMIT;
|
||||
let (mut last, mut changed, mut meshing) = (None, Instant::now(), Instant::now());
|
||||
let mut previous: Option<(Instant, f64)> = None;
|
||||
let (mut first, mut moved, began) = (None, false, Instant::now());
|
||||
while Instant::now() < deadline {
|
||||
let s = control.status();
|
||||
let now = Instant::now();
|
||||
@@ -339,6 +344,19 @@ fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result<Insta
|
||||
(last, changed, previous, meshing) = (None, now, None, now);
|
||||
} else {
|
||||
let counts = (s["chunks"].as_u64(), s["generated_chunks"].as_u64());
|
||||
let resident = counts.0.unwrap_or(0);
|
||||
let min_chunks = view_chunks(s["render_distance"].as_u64().unwrap_or(0));
|
||||
let first = *first.get_or_insert(counts);
|
||||
moved = moved
|
||||
|| counts != first
|
||||
|| (min_chunks > 0
|
||||
&& resident >= min_chunks
|
||||
&& now - began > Duration::from_secs(5));
|
||||
if min_chunks > 0 && (!moved || resident < min_chunks) {
|
||||
(last, changed, previous, meshing) = (None, now, None, now);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
continue;
|
||||
}
|
||||
if last != Some(counts) {
|
||||
(last, changed) = (Some(counts), now);
|
||||
}
|
||||
@@ -370,6 +388,12 @@ fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result<Insta
|
||||
Err(format!("not loaded after {} s", LOAD_LIMIT.as_secs()))
|
||||
}
|
||||
|
||||
/// 80% of the view circle (pi x RD^2): the game keeps 637 columns at RD 12 and 3725 at RD 32
|
||||
/// (`tools/perf_bench.py`'s `view_chunks`).
|
||||
fn view_chunks(distance: u64) -> u64 {
|
||||
(0.8 * std::f64::consts::PI * (distance * distance) as f64) as u64
|
||||
}
|
||||
|
||||
/// Vanilla's yaw facing from `start` to `end`.
|
||||
fn yaw(start: [f64; 3], end: [f64; 3]) -> f64 {
|
||||
(-(end[0] - start[0])).atan2(end[2] - start[2]).to_degrees()
|
||||
@@ -420,6 +444,13 @@ mod tests {
|
||||
assert!(scenes("nether").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_chunks_matches_perf_bench() {
|
||||
assert_eq!(view_chunks(12), 361);
|
||||
assert_eq!(view_chunks(32), 2573);
|
||||
assert_eq!(view_chunks(0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaw_matches_vanilla() {
|
||||
// South (+Z) is 0, west (-X) 90, east (+X) -90.
|
||||
|
||||
@@ -143,6 +143,7 @@ const PAGES: Ty = Ty::Enum(&[
|
||||
"font",
|
||||
"packs",
|
||||
"mods",
|
||||
"shader-packs",
|
||||
"graphics",
|
||||
"graphics-advanced",
|
||||
"extras",
|
||||
|
||||
@@ -610,12 +610,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unreachable_games_fail_with_status_3() {
|
||||
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-nobody.sock"), None);
|
||||
let home =
|
||||
std::env::temp_dir().join(format!("horizon-ctl-unreachable-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
let mut s = Session::new(home.join("nobody.sock"), None);
|
||||
s.home = home.clone();
|
||||
let f = s.run(&["state".to_owned()]).unwrap_err();
|
||||
assert_eq!(f.status, UNREACHABLE);
|
||||
assert_eq!(f.reply["kind"], "connection");
|
||||
let f = s.run(&["sleep".to_owned(), "x".to_owned()]).unwrap_err();
|
||||
assert_eq!(f.status, USAGE);
|
||||
std::fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -664,7 +664,9 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
|
||||
}
|
||||
"minecraft:trial_spawner" => {
|
||||
// `TrialSpawnerStateData.Packed` and `FullConfig`: every key is optional
|
||||
// and defaulted by vanilla; they are kept as loaded.
|
||||
// and defaulted by vanilla; they are kept as loaded. No `spawn_data` is
|
||||
// added: an absent one loads as Optional.empty and is not stored, and
|
||||
// an empty entity would keep the spawner from ever picking a mob.
|
||||
if let Tag::Compound(c) = tag {
|
||||
for (k, v) in c {
|
||||
let key = k.to_string_lossy();
|
||||
@@ -676,12 +678,6 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if !f.out.contains_key(&"spawn_data".into()) {
|
||||
f.put(
|
||||
"spawn_data",
|
||||
Tag::compound([("entity", Tag::Compound(Compound::new()))]),
|
||||
);
|
||||
}
|
||||
}
|
||||
"minecraft:vault" => {
|
||||
// `VaultConfig.DEFAULT` (when the config is absent): the trial key and the
|
||||
@@ -827,6 +823,21 @@ mod tests {
|
||||
canonical(&snbt(s)).expect("known type")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trial_spawner_without_spawn_data_saves_none() {
|
||||
let fresh = canon(
|
||||
r#"{id:"minecraft:trial_spawner",x:1,y:2,z:3,normal_config:"minecraft:trial_chamber/ranged/skeleton/normal"}"#,
|
||||
);
|
||||
assert_eq!(fresh.get("spawn_data"), None);
|
||||
let picked = canon(
|
||||
r#"{id:"minecraft:trial_spawner",x:1,y:2,z:3,spawn_data:{entity:{id:"minecraft:skeleton"}}}"#,
|
||||
);
|
||||
assert_eq!(
|
||||
picked.get("spawn_data"),
|
||||
Some(&snbt(r#"{entity:{id:"minecraft:skeleton"}}"#))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_type_has_defaults() {
|
||||
for id in TYPES.iter().chain(NEWEST.iter()) {
|
||||
|
||||
@@ -558,12 +558,11 @@ fn frac(x: f32) -> f32 {
|
||||
/// `StringSplitter.splitLines(...).get(0)` for plain text: the first line,
|
||||
/// wrapped at the last space that fits or mid-word when none does.
|
||||
fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> String {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let mut width = 0.0;
|
||||
let mut last_space = None;
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
for (i, c) in line.char_indices() {
|
||||
if c == '\n' {
|
||||
return chars[..i].iter().collect();
|
||||
return line[..i].to_owned();
|
||||
}
|
||||
if c == ' ' {
|
||||
last_space = Some(i);
|
||||
@@ -572,9 +571,11 @@ fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> Stri
|
||||
if width > max_width {
|
||||
let end = match last_space {
|
||||
Some(s) if s > 0 => s,
|
||||
_ => i.max(1),
|
||||
_ if i > 0 => i,
|
||||
// The first char alone is too wide: take that one char.
|
||||
_ => c.len_utf8(),
|
||||
};
|
||||
return chars[..end].iter().collect();
|
||||
return line[..end].to_owned();
|
||||
}
|
||||
}
|
||||
line.to_owned()
|
||||
@@ -730,6 +731,10 @@ pub struct Scene<'a> {
|
||||
pub sign: &'a dyn Fn([i32; 3]) -> Option<&'a crate::signs::SignData>,
|
||||
/// Banner layers at a position: pattern texture and RGB tint, bottom first.
|
||||
pub banner: &'a dyn Fn([i32; 3]) -> Vec<(String, u32)>,
|
||||
/// 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>,
|
||||
/// Font glyph cell (none for blank glyphs such as the space) and advance.
|
||||
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
|
||||
}
|
||||
@@ -1279,6 +1284,13 @@ impl BlockEntityRenderer {
|
||||
SkullType::Piglin => ("piglin_head", "entity/piglin/piglin"),
|
||||
SkullType::Player => ("player_head", "entity/player/slim/steve"),
|
||||
};
|
||||
// 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)
|
||||
} else {
|
||||
texture
|
||||
};
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
|
||||
else {
|
||||
@@ -1886,6 +1898,7 @@ mod tests {
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
};
|
||||
// Compare the cached world path with the original model renderer,
|
||||
@@ -2086,4 +2099,13 @@ mod tests {
|
||||
assert_eq!(out[1].pos, [2.0, 0.0, 0.0]);
|
||||
assert_eq!(out[2].uv, [0.5, 0.5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_line_wraps_on_bytes_without_splitting_a_char() {
|
||||
let advance = |c: char| if c == ' ' { 4.0 } else { 6.0 };
|
||||
assert_eq!(first_line("ab cd ef", 13.0, &advance), "ab");
|
||||
assert_eq!(first_line("abcdef", 13.0, &advance), "ab");
|
||||
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
|
||||
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,38 @@ impl Contents {
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
/// The stacks inside, in display order (the first is the next out).
|
||||
pub fn items(&self) -> &[ItemStack] {
|
||||
&self.items
|
||||
}
|
||||
|
||||
/// `getNumberOfItemsToShow`: a full grid shows 11 and a `+N`, so the last
|
||||
/// row keeps its fourth cell for the count.
|
||||
pub fn shown_count(&self) -> usize {
|
||||
let count = self.items.len();
|
||||
let available: usize = if count > 12 { 11 } else { 12 };
|
||||
let on_last_row = count % 4;
|
||||
let empty = if on_last_row == 0 { 0 } else { 4 - on_last_row };
|
||||
count.min(available.saturating_sub(empty))
|
||||
}
|
||||
|
||||
/// The items the tooltip shows, and how many sit beyond them.
|
||||
pub fn shown(&self) -> (&[ItemStack], u32) {
|
||||
let n = self.shown_count();
|
||||
let hidden = self.items[n..].iter().map(|s| s.count).sum();
|
||||
(&self.items[..n], hidden)
|
||||
}
|
||||
|
||||
/// The `Fraction` weight as the float the progress bar reads.
|
||||
pub fn weight_fraction(&self) -> f32 {
|
||||
self.weight.n as f32 / self.weight.d as f32
|
||||
}
|
||||
|
||||
/// `Fraction.ONE`: a full bundle draws the full bar and says so.
|
||||
pub fn is_full(&self) -> bool {
|
||||
self.weight.n >= self.weight.d
|
||||
}
|
||||
|
||||
/// `toImmutable`, written back into the bundle's component.
|
||||
pub fn store(self, bundle: &mut ItemStack) {
|
||||
bundle.data_mut().bundle_contents = self.items;
|
||||
@@ -190,3 +222,174 @@ mod tests {
|
||||
assert_eq!(over.how_many(Weight::new(1, 64)), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- the tooltip
|
||||
|
||||
/// `ClientBundleTooltip`'s geometry, relative to the tooltip box's top-left:
|
||||
/// the 96 pixel column of a 4 wide slot grid and the progress bar under it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TooltipLayout {
|
||||
/// The component's own width (96) and height, the text's excluded.
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
/// Each slot's top-left and the stack in it, in reading order.
|
||||
pub slots: Vec<([f32; 2], ItemStack)>,
|
||||
/// The `+N` of the items past the grid: its top-left and the count.
|
||||
pub overflow: Option<([f32; 2], u32)>,
|
||||
/// The progress bar's top-left, its fill's width out of 94, and whether
|
||||
/// the weight fills it (the full sprite).
|
||||
pub bar: [f32; 2],
|
||||
pub fill: f32,
|
||||
pub full: bool,
|
||||
/// `item.minecraft.bundle.empty` / `.full`, centred over the bar.
|
||||
pub label: Option<&'static str>,
|
||||
/// An empty bundle's description, wrapped at `width` from here.
|
||||
pub empty_description: Option<[f32; 2]>,
|
||||
}
|
||||
|
||||
/// The tooltip of a bundle's contents (`ClientBundleTooltip`), or None when
|
||||
/// the stack is not a bundle. `x`/`y` are the box's top-left, `box_width` the
|
||||
/// tooltip's width, which the component is centred in.
|
||||
pub fn tooltip_layout(
|
||||
items: &Items,
|
||||
stack: &ItemStack,
|
||||
x: f32,
|
||||
y: f32,
|
||||
box_width: f32,
|
||||
) -> Option<TooltipLayout> {
|
||||
let contents = Contents::of(items, stack)?;
|
||||
let offset = ((box_width - 96.0) / 2.0).floor();
|
||||
if contents.items().is_empty() {
|
||||
// The description takes the top; the bar follows it.
|
||||
let description_height = 9.0 * 2.0;
|
||||
return Some(TooltipLayout {
|
||||
width: 96.0,
|
||||
height: description_height + 13.0 + 8.0,
|
||||
slots: Vec::new(),
|
||||
overflow: None,
|
||||
bar: [x + offset, y + description_height + 4.0],
|
||||
fill: 0.0,
|
||||
full: false,
|
||||
label: Some("item.minecraft.bundle.empty"),
|
||||
empty_description: Some([x + offset, y]),
|
||||
});
|
||||
}
|
||||
let (shown, hidden) = contents.shown();
|
||||
// `gridSizeY` counts `slotCount()` (at most twelve), not the slots the
|
||||
// overflow count leaves room for.
|
||||
let rows = contents.items().len().min(12).div_ceil(4).max(1);
|
||||
let grid_height = rows as f32 * 24.0;
|
||||
let x_start = x + offset + 96.0;
|
||||
let y_start = y + grid_height;
|
||||
let mut slots = Vec::with_capacity(shown.len());
|
||||
let mut overflow = None;
|
||||
// Rows fill bottom-up and columns right-to-left, as vanilla draws them.
|
||||
let mut slot = 0;
|
||||
for row in 1..=rows {
|
||||
for column in 1..=4 {
|
||||
let at = [x_start - column as f32 * 24.0, y_start - row as f32 * 24.0];
|
||||
if hidden > 0 && row == 1 && column == 1 {
|
||||
overflow = Some((at, hidden));
|
||||
} else if let Some(stack) = shown.get(slot) {
|
||||
slots.push((at, stack.clone()));
|
||||
slot += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let weight = contents.weight_fraction();
|
||||
Some(TooltipLayout {
|
||||
width: 96.0,
|
||||
height: grid_height + 13.0 + 8.0,
|
||||
slots,
|
||||
overflow,
|
||||
bar: [x + offset, y + grid_height + 4.0],
|
||||
// `Mth.mulAndTruncate(weight, 94)`, kept within the bar.
|
||||
fill: (weight * 94.0).trunc().clamp(0.0, 94.0),
|
||||
full: contents.is_full(),
|
||||
label: if contents.is_full() {
|
||||
Some("item.minecraft.bundle.full")
|
||||
} else {
|
||||
None
|
||||
},
|
||||
empty_description: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tooltip_tests {
|
||||
use super::*;
|
||||
|
||||
/// The prepared resources, when they are on this machine: the tooltip
|
||||
/// asks the item registry whether a stack is a bundle.
|
||||
fn items() -> Option<Items> {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("items.json").exists() {
|
||||
return None;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).ok()?;
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).ok()?;
|
||||
Items::load(&root, assets.registry, &facts).ok()
|
||||
}
|
||||
|
||||
fn bundle_with(counts: &[(&str, u32)]) -> ItemStack {
|
||||
let mut bundle = ItemStack::new("minecraft:bundle", 1);
|
||||
bundle.data_mut().bundle_contents = counts
|
||||
.iter()
|
||||
.map(|(item, n)| ItemStack::new(item, *n))
|
||||
.collect();
|
||||
bundle
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grid_shows_twelve_stacks_and_counts_the_rest() {
|
||||
let Some(items) = items() else { return };
|
||||
let twelve: Vec<(&str, u32)> = (0..12).map(|_| ("minecraft:stone", 1)).collect();
|
||||
let layout = tooltip_layout(&items, &bundle_with(&twelve), 0.0, 0.0, 96.0).unwrap();
|
||||
assert_eq!(layout.slots.len(), 12);
|
||||
assert_eq!(layout.overflow, None);
|
||||
assert_eq!(layout.height, 3.0 * 24.0 + 13.0 + 8.0);
|
||||
// Rows fill bottom-up and columns right-to-left: the first item is in
|
||||
// the bottom-right cell, the twelfth in the top-left.
|
||||
assert_eq!(layout.slots[0].0, [96.0 - 24.0, 3.0 * 24.0 - 24.0]);
|
||||
assert_eq!(&*layout.slots[0].1.item, "minecraft:stone");
|
||||
assert_eq!(layout.slots[11].0, [0.0, 0.0]);
|
||||
// A wider tooltip centres the 96 pixel column in it.
|
||||
let centred = tooltip_layout(&items, &bundle_with(&twelve), 0.0, 0.0, 120.0).unwrap();
|
||||
assert_eq!(centred.slots[0].0[0], 12.0 + 96.0 - 24.0);
|
||||
// Thirteen stacks: `getNumberOfItemsToShow` leaves the last row room
|
||||
// for the count, so 8 slots show and `+6` (four stones and two dirt)
|
||||
// takes the first cell.
|
||||
let mut counts = twelve;
|
||||
counts.push(("minecraft:dirt", 2));
|
||||
let layout = tooltip_layout(&items, &bundle_with(&counts), 0.0, 0.0, 96.0).unwrap();
|
||||
assert_eq!(layout.slots.len(), 8);
|
||||
assert_eq!(layout.overflow, Some(([96.0 - 24.0, 48.0], 6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bar_fills_with_the_weight_and_an_empty_bundle_explains_itself() {
|
||||
let Some(items) = items() else { return };
|
||||
let layout = tooltip_layout(&items, &bundle_with(&[]), 4.0, 8.0, 96.0).unwrap();
|
||||
assert!(layout.slots.is_empty());
|
||||
assert_eq!(layout.fill, 0.0);
|
||||
assert_eq!(layout.label, Some("item.minecraft.bundle.empty"));
|
||||
assert_eq!(layout.empty_description, Some([4.0, 8.0]));
|
||||
assert_eq!(layout.bar, [4.0, 8.0 + 18.0 + 4.0]);
|
||||
// A bundle holding one 64 stack is full: the whole bar, the full label.
|
||||
let layout = tooltip_layout(
|
||||
&items,
|
||||
&bundle_with(&[("minecraft:stone", 64)]),
|
||||
0.0,
|
||||
0.0,
|
||||
96.0,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(layout.full);
|
||||
assert_eq!(layout.fill, 94.0);
|
||||
assert_eq!(layout.label, Some("item.minecraft.bundle.full"));
|
||||
// Something that is not a bundle has no component.
|
||||
let dirt = ItemStack::new("minecraft:dirt", 1);
|
||||
assert!(tooltip_layout(&items, &dirt, 0.0, 0.0, 96.0).is_none());
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,19 @@ fn filled(player: &mut dyn Player, new: ItemStack, limit_creative: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `LayeredCauldronBlock.handleEntityOnFireInside`: a burning entity in a
|
||||
/// water cauldron lowers it a level; a powder snow cauldron becomes a water
|
||||
/// cauldron of its level first, then lowers.
|
||||
pub fn extinguished(cauldron: &BlockState) -> BlockState {
|
||||
if cauldron.name == "minecraft:powder_snow_cauldron" {
|
||||
let mut water = cauldron.clone();
|
||||
water.name = "minecraft:water_cauldron".into();
|
||||
lowered(&water)
|
||||
} else {
|
||||
lowered(cauldron)
|
||||
}
|
||||
}
|
||||
|
||||
/// `LayeredCauldronBlock.lowerFillLevel`.
|
||||
fn lowered(cauldron: &BlockState) -> BlockState {
|
||||
let level: u8 = cauldron
|
||||
@@ -296,3 +309,24 @@ pub fn interact(
|
||||
_ => pass(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_burning_entity_lowers_water_and_melts_powder_snow() {
|
||||
let water = |l| state("minecraft:water_cauldron", Some(l));
|
||||
assert_eq!(extinguished(&water(3)), water(2));
|
||||
assert_eq!(extinguished(&water(1)), state("minecraft:cauldron", None));
|
||||
// Powder snow turns to water of its level, then lowers.
|
||||
assert_eq!(
|
||||
extinguished(&state("minecraft:powder_snow_cauldron", Some(3))),
|
||||
water(2)
|
||||
);
|
||||
assert_eq!(
|
||||
extinguished(&state("minecraft:powder_snow_cauldron", Some(1))),
|
||||
state("minecraft:cauldron", None)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,92 @@ mod tests;
|
||||
/// GuiMessageTag.systemSinglePlayer's indicator colour.
|
||||
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
|
||||
|
||||
/// `ChatVisiblity`: the Chat option's three values.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ChatVisibility {
|
||||
Full,
|
||||
System,
|
||||
Hidden,
|
||||
}
|
||||
impl ChatVisibility {
|
||||
/// Options.chatVisibility's saved value (0 full, 1 system, 2 hidden).
|
||||
pub fn from_option(value: u8) -> Self {
|
||||
match value {
|
||||
0 => Self::Full,
|
||||
1 => Self::System,
|
||||
_ => Self::Hidden,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChatAbilities`: what the Chat option allows, as `Minecraft.computeChatAbilities`
|
||||
/// derives it from the visibility alone (`ChatRestriction`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ChatAbilities {
|
||||
pub send_messages: bool,
|
||||
pub send_commands: bool,
|
||||
pub receive_player_messages: bool,
|
||||
pub receive_system_messages: bool,
|
||||
}
|
||||
impl ChatAbilities {
|
||||
/// `ChatRestriction.CHAT_AND_COMMANDS_DISABLED_BY_OPTIONS` (Hidden) takes
|
||||
/// chat, commands, player messages and system messages;
|
||||
/// `CHAT_DISABLED_BY_OPTIONS` (System) takes the chat and player messages.
|
||||
pub fn new(visibility: ChatVisibility) -> Self {
|
||||
match visibility {
|
||||
ChatVisibility::Full => Self {
|
||||
send_messages: true,
|
||||
send_commands: true,
|
||||
receive_player_messages: true,
|
||||
receive_system_messages: true,
|
||||
},
|
||||
ChatVisibility::System => Self {
|
||||
send_messages: false,
|
||||
send_commands: true,
|
||||
receive_player_messages: false,
|
||||
receive_system_messages: true,
|
||||
},
|
||||
ChatVisibility::Hidden => Self {
|
||||
send_messages: false,
|
||||
send_commands: false,
|
||||
receive_player_messages: false,
|
||||
receive_system_messages: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
/// `visibleMessagesFilter`: the client's own lines always show.
|
||||
pub fn shows(&self, source: ChatSource) -> bool {
|
||||
match source {
|
||||
ChatSource::Player => self.receive_player_messages,
|
||||
ChatSource::Server => self.receive_system_messages,
|
||||
ChatSource::Client => true,
|
||||
}
|
||||
}
|
||||
/// Whether the chat screen may send `input` (`hasAllowedInput`).
|
||||
pub fn allows(&self, input: &str) -> bool {
|
||||
if input.starts_with('/') {
|
||||
self.send_commands
|
||||
} else {
|
||||
self.send_messages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChatAbilities {
|
||||
fn default() -> Self {
|
||||
Self::new(ChatVisibility::Full)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a chat line came from (`GuiMessageSource`): the visibility filters
|
||||
/// player messages and the server's, while the client's own always show.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ChatSource {
|
||||
Player,
|
||||
Server,
|
||||
Client,
|
||||
}
|
||||
|
||||
/// The chat options (doubles, as vanilla keeps them).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ChatOptions {
|
||||
@@ -71,6 +157,7 @@ struct Message {
|
||||
added: u64,
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
source: ChatSource,
|
||||
}
|
||||
/// A wrapped line (`GuiMessage.Line`).
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -105,6 +192,8 @@ pub struct Chat {
|
||||
pub log: bool,
|
||||
/// Messages added since the start, for the control socket's events.
|
||||
pub received: u64,
|
||||
/// The Chat option's `visibleMessageFilter` (`set_abilities`).
|
||||
abilities: ChatAbilities,
|
||||
}
|
||||
impl Chat {
|
||||
pub fn lines_per_page(&self, focused: bool) -> usize {
|
||||
@@ -114,16 +203,32 @@ impl Chat {
|
||||
fn max_width(&self) -> i32 {
|
||||
(f64::from(self.options.width_px()) / self.options.scale).floor() as i32
|
||||
}
|
||||
/// ChatComponent.addMessage: wrap, prepend and trim to 100 lines.
|
||||
/// ChatComponent.addMessage: wrap, prepend and trim to 100 lines. A
|
||||
/// message the Chat option hides is dropped, unlogged.
|
||||
pub fn add(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
source: ChatSource,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
// ChatComponent.logChatMessage: every message is in the log.
|
||||
self.add_message(&atlas.font, content, indicator, source, ticks, focused);
|
||||
}
|
||||
fn add_message(
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
source: ChatSource,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
if !self.abilities.shows(source) {
|
||||
return;
|
||||
}
|
||||
// ChatComponent.logChatMessage: every message shown is in the log.
|
||||
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {}", content.plain()));
|
||||
if self.log {
|
||||
println!("[CHAT] {}", content.plain());
|
||||
@@ -132,16 +237,17 @@ impl Chat {
|
||||
added: ticks,
|
||||
content,
|
||||
indicator,
|
||||
source,
|
||||
};
|
||||
self.add_lines(atlas, &message, focused);
|
||||
self.add_lines(font, &message, focused);
|
||||
self.messages.insert(0, message);
|
||||
self.messages.truncate(100);
|
||||
self.received += 1;
|
||||
}
|
||||
fn add_lines(&mut self, atlas: &HudAtlas, message: &Message, focused: bool) {
|
||||
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) {
|
||||
let width = self.max_width();
|
||||
let content = self.displayed(&message.content);
|
||||
for line in atlas.font.wrap_components(&content, width) {
|
||||
for line in font.wrap_components(&content, width) {
|
||||
if focused && self.scroll > 0 {
|
||||
self.new_since_scroll = true;
|
||||
self.scroll_by(1, focused);
|
||||
@@ -167,16 +273,38 @@ impl Chat {
|
||||
content.strip_legacy()
|
||||
}
|
||||
}
|
||||
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat).
|
||||
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat),
|
||||
/// keeping only the messages the Chat option shows.
|
||||
pub fn rescale(&mut self, atlas: &HudAtlas) {
|
||||
self.rewrap(&atlas.font);
|
||||
}
|
||||
fn rewrap(&mut self, font: &crate::font::Font) {
|
||||
self.scroll = 0;
|
||||
self.new_since_scroll = false;
|
||||
self.lines.clear();
|
||||
let messages: Vec<Message> = self.messages.iter().rev().cloned().collect();
|
||||
let messages: Vec<Message> = self
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|m| self.abilities.shows(m.source))
|
||||
.cloned()
|
||||
.collect();
|
||||
for m in &messages {
|
||||
self.add_lines(atlas, m, false);
|
||||
self.add_lines(font, m, false);
|
||||
}
|
||||
}
|
||||
/// `ChatComponent.setVisibleMessageFilter`: the Chat option changed;
|
||||
/// the messages it now hides leave the chat, those it shows again return.
|
||||
pub fn set_abilities(&mut self, font: &crate::font::Font, abilities: ChatAbilities) {
|
||||
if self.abilities != abilities {
|
||||
self.abilities = abilities;
|
||||
self.rewrap(font);
|
||||
}
|
||||
}
|
||||
/// Whether a message from `source` would show (`visibleMessagesFilter`).
|
||||
pub fn shows(&self, source: ChatSource) -> bool {
|
||||
self.abilities.shows(source)
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.messages.clear();
|
||||
self.lines.clear();
|
||||
@@ -437,6 +565,8 @@ pub struct ChatScreen {
|
||||
allow_suggestions: bool,
|
||||
keep_suggestions: bool,
|
||||
pub auto_suggestions: bool,
|
||||
/// `CommandSuggestions.setRestrictions`: what the Chat option allows.
|
||||
pub abilities: ChatAbilities,
|
||||
}
|
||||
/// Local completions waiting for the server's reply to request `id`.
|
||||
struct Awaiting {
|
||||
@@ -482,6 +612,7 @@ impl ChatScreen {
|
||||
history_len: usize,
|
||||
width: i32,
|
||||
height: i32,
|
||||
abilities: ChatAbilities,
|
||||
) -> Self {
|
||||
let mut input = TextField::new(4, height - 12, width - 4, 12);
|
||||
input.max_length = 256;
|
||||
@@ -505,6 +636,7 @@ impl ChatScreen {
|
||||
allow_suggestions: false,
|
||||
keep_suggestions: false,
|
||||
auto_suggestions: true,
|
||||
abilities,
|
||||
};
|
||||
s.update_command_info(m, c);
|
||||
s
|
||||
@@ -522,7 +654,8 @@ impl ChatScreen {
|
||||
if (width, height) != (self.width, self.height) {
|
||||
let value = self.input.value.clone();
|
||||
let auto = self.auto_suggestions;
|
||||
*self = Self::new(m, c, &value, self.history_pos, width, height);
|
||||
let abilities = self.abilities;
|
||||
*self = Self::new(m, c, &value, self.history_pos, width, height, abilities);
|
||||
self.auto_suggestions = auto;
|
||||
}
|
||||
}
|
||||
@@ -604,6 +737,14 @@ impl ChatScreen {
|
||||
self.pending = Some((range.0, list));
|
||||
self.usage_width = 0;
|
||||
self.usage_position = 0;
|
||||
// CommandSuggestions.updateCommandInfo: the Chat option may take
|
||||
// the messages.
|
||||
if !self.abilities.send_messages {
|
||||
let line =
|
||||
Text::translate("chat_screen.messages_not_allowed", &[]).with_color(text::RED);
|
||||
self.usage_width = m.width(&line);
|
||||
self.usage.push(line);
|
||||
}
|
||||
} else {
|
||||
self.awaiting = None;
|
||||
self.pending = None;
|
||||
@@ -708,6 +849,15 @@ impl ChatScreen {
|
||||
}
|
||||
self.usage.extend(entries);
|
||||
}
|
||||
// CommandSuggestions.updateUsageInfo: the Chat option may take the
|
||||
// commands, under the usage. (Vanilla also counts a command with a
|
||||
// message argument, `/say hi`, as a message; Horizon's parse keeps no
|
||||
// argument kinds, so only a bare message does.)
|
||||
if !self.abilities.send_commands {
|
||||
self.usage.push(
|
||||
Text::translate("chat_screen.commands_not_allowed", &[]).with_color(text::RED),
|
||||
);
|
||||
}
|
||||
self.usage_width = self.usage.iter().map(|u| m.width(u)).max().unwrap_or(0);
|
||||
self.usage_position = if self.usage.is_empty() {
|
||||
0
|
||||
@@ -842,7 +992,14 @@ impl ChatScreen {
|
||||
}
|
||||
match key {
|
||||
K::Escape => return ChatAction::Close,
|
||||
K::Enter | K::NumpadEnter => return ChatAction::Submit(self.normalized()),
|
||||
K::Enter | K::NumpadEnter => {
|
||||
// ChatScreen.keyPressed: without `hasAllowedInput` the input
|
||||
// stays, unsent, and the screen stays open.
|
||||
if !self.abilities.allows(&self.input.value) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
return ChatAction::Submit(self.normalized());
|
||||
}
|
||||
K::ArrowUp => self.move_in_history(m, c, history, -1),
|
||||
K::ArrowDown => self.move_in_history(m, c, history, 1),
|
||||
_ => {
|
||||
|
||||
@@ -2,6 +2,11 @@ use super::*;
|
||||
use crate::{font::Font, hud::Rect};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
/// The Chat option's default: no restriction.
|
||||
fn full() -> ChatAbilities {
|
||||
ChatAbilities::new(ChatVisibility::Full)
|
||||
}
|
||||
|
||||
fn font() -> Font {
|
||||
// The missing-glyph metrics let these tests run without game assets.
|
||||
Font::load(
|
||||
@@ -139,7 +144,7 @@ fn locate_coordinates_fill_chat_with_teleport_without_submitting() {
|
||||
assert_eq!(link.component()["click_event"]["command"], expected);
|
||||
let mut chat = Chat::default();
|
||||
add(&mut chat, &font, Text::literal("Found at ").append(link));
|
||||
let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360);
|
||||
let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360, full());
|
||||
let x = 4.0 + f64::from(font.width("Found at "));
|
||||
assert!(!screen.click(&m, &commands, x, 315.0));
|
||||
let Some(ClickEvent::SuggestCommand(command)) = chat.click_at(&font, 360, x, 315.0) else {
|
||||
@@ -176,7 +181,15 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
|
||||
registries: ®istries,
|
||||
};
|
||||
let history = vec!["first message".to_owned(), "latest message".to_owned()];
|
||||
let mut screen = ChatScreen::new(&m, &commands, "unfinished draft", history.len(), 640, 360);
|
||||
let mut screen = ChatScreen::new(
|
||||
&m,
|
||||
&commands,
|
||||
"unfinished draft",
|
||||
history.len(),
|
||||
640,
|
||||
360,
|
||||
full(),
|
||||
);
|
||||
for (key, expected) in [
|
||||
(ArrowUp, "latest message"),
|
||||
(ArrowUp, "first message"),
|
||||
@@ -213,7 +226,7 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
|
||||
dispatcher,
|
||||
registries: ®istries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, line);
|
||||
screen
|
||||
};
|
||||
@@ -259,7 +272,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
|
||||
let tab = |screen: &mut ChatScreen| {
|
||||
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, "/bossbar get pt");
|
||||
let (request, sent) = screen.take_request().expect("the server is asked");
|
||||
assert_eq!(sent, "/bossbar get pt");
|
||||
@@ -281,7 +294,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
|
||||
dispatcher: local,
|
||||
registries: ®istries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, "/bossbar get pt");
|
||||
assert_eq!(screen.take_request(), None);
|
||||
}
|
||||
@@ -327,3 +340,130 @@ fn a_server_component_keeps_its_click_and_hover_events() {
|
||||
let past = 4.0 + f64::from(font.width("CLICK")) + 20.0;
|
||||
assert!(chat.hover_at(&font, 360, past, 315.0).is_none());
|
||||
}
|
||||
|
||||
/// `ChatVisiblity`: Commands Only keeps the chat out, Hidden takes the
|
||||
/// commands too (`ChatAbilities`), and Enter sends nothing the option hides.
|
||||
#[test]
|
||||
fn the_chat_option_decides_what_may_be_sent() {
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let registries = Registries::default();
|
||||
let c = Commands {
|
||||
dispatcher: crate::command::vanilla::game_dispatcher(),
|
||||
registries: ®istries,
|
||||
};
|
||||
let enter = |screen: &mut ChatScreen| {
|
||||
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Enter, false, false)
|
||||
};
|
||||
for (visibility, chat, command) in [
|
||||
(ChatVisibility::Full, true, true),
|
||||
(ChatVisibility::System, false, true),
|
||||
(ChatVisibility::Hidden, false, false),
|
||||
] {
|
||||
let abilities = ChatAbilities::new(visibility);
|
||||
assert_eq!(abilities.allows("hi"), chat, "{visibility:?}");
|
||||
assert_eq!(abilities.allows("/time set day"), command, "{visibility:?}");
|
||||
for (line, allowed) in [("hi", chat), ("/time set day", command)] {
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, abilities);
|
||||
screen.type_text(&m, &c, line);
|
||||
match enter(&mut screen) {
|
||||
ChatAction::Submit(sent) => {
|
||||
assert!(allowed, "{visibility:?} sent {sent}");
|
||||
assert_eq!(sent, line);
|
||||
}
|
||||
ChatAction::None => assert!(!allowed, "{visibility:?} kept {line}"),
|
||||
ChatAction::Close => panic!("Enter does not close the chat"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The restriction the option sets is a usage line: red, under the input, for
|
||||
/// a command the Chat option takes and for a message it takes.
|
||||
#[test]
|
||||
fn restricted_input_says_why_it_will_not_send() {
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let registries = Registries::default();
|
||||
let c = Commands {
|
||||
dispatcher: crate::command::vanilla::game_dispatcher(),
|
||||
registries: ®istries,
|
||||
};
|
||||
let usage = |visibility, line: &str| {
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, ChatAbilities::new(visibility));
|
||||
screen.type_text(&m, &c, line);
|
||||
screen.usage.iter().map(|t| t.plain()).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(usage(ChatVisibility::Full, "hi"), Vec::<String>::new());
|
||||
// The font has no language files here, so the line is its key.
|
||||
assert_eq!(
|
||||
usage(ChatVisibility::System, "hi"),
|
||||
["chat_screen.messages_not_allowed"]
|
||||
);
|
||||
// A command the option allows says nothing about the restriction.
|
||||
assert!(
|
||||
!usage(ChatVisibility::System, "/time set day")
|
||||
.iter()
|
||||
.any(|line| line == "chat_screen.commands_not_allowed")
|
||||
);
|
||||
assert!(
|
||||
usage(ChatVisibility::Hidden, "/time set day")
|
||||
.iter()
|
||||
.any(|line| line == "chat_screen.commands_not_allowed")
|
||||
);
|
||||
}
|
||||
|
||||
/// `ChatComponent.setVisibleMessageFilter`: a message the Chat option hides
|
||||
/// on arrival is dropped; one already in the chat leaves it while the option
|
||||
/// hides its kind and comes back when it shows it again.
|
||||
#[test]
|
||||
fn the_chat_option_filters_the_lines_both_ways() {
|
||||
let font = font();
|
||||
let mut chat = Chat::default();
|
||||
let says = |chat: &mut Chat, text: &str, source| {
|
||||
chat.add_message(&font, Text::literal(text), None, source, 0, false);
|
||||
};
|
||||
let shown =
|
||||
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
|
||||
says(&mut chat, "player", ChatSource::Player);
|
||||
says(&mut chat, "server", ChatSource::Server);
|
||||
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::System));
|
||||
assert_eq!(shown(&chat), ["server"]);
|
||||
says(&mut chat, "dropped", ChatSource::Player);
|
||||
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::Hidden));
|
||||
says(&mut chat, "client", ChatSource::Client);
|
||||
assert_eq!(shown(&chat), ["client"]);
|
||||
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::Full));
|
||||
assert_eq!(shown(&chat), ["client", "server", "player"]);
|
||||
}
|
||||
|
||||
/// The in-frame LAN host's chat: a guest's line is a player message, so the
|
||||
/// Chat option's player-message permission hides it at Commands Only and
|
||||
/// Hidden; command feedback (`/list`, `/tp`) is a system message and stays
|
||||
/// at Commands Only.
|
||||
#[test]
|
||||
fn a_guests_line_follows_the_player_message_permission() {
|
||||
let font = font();
|
||||
let shown = |visibility, source| {
|
||||
let mut chat = Chat::default();
|
||||
chat.set_abilities(&font, ChatAbilities::new(visibility));
|
||||
chat.add_message(&font, Text::literal("line"), None, source, 0, false);
|
||||
chat.messages.len()
|
||||
};
|
||||
for (visibility, player, system) in [
|
||||
(ChatVisibility::Full, 1, 1),
|
||||
(ChatVisibility::System, 0, 1),
|
||||
(ChatVisibility::Hidden, 0, 0),
|
||||
] {
|
||||
assert_eq!(
|
||||
shown(visibility, ChatSource::Player),
|
||||
player,
|
||||
"{visibility:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
shown(visibility, ChatSource::Server),
|
||||
system,
|
||||
"{visibility:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,20 @@ pub fn block_strategy() -> Strategy {
|
||||
Strategy::blocks(BLOCK_BITS.load(std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Frees `columns` (`count` of them) on a short-lived thread of its own
|
||||
/// when there are many: a teleport or a dimension change drops a few
|
||||
/// thousand columns at once, and freeing their sections took the frame
|
||||
/// 15-20 ms. A few are freed here.
|
||||
pub fn free_columns<T: Send + 'static>(columns: T, count: usize) {
|
||||
if count < 64 {
|
||||
return;
|
||||
}
|
||||
// Should the thread not start, the closure drops `columns` here.
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("Chunk freeing".into())
|
||||
.spawn(move || drop(columns));
|
||||
}
|
||||
|
||||
/// Sizes the block states' global palette for a registry of `states`.
|
||||
pub fn set_block_states(states: usize) {
|
||||
let bits = horizon_core::paletted::ceil_log2(states).max(16);
|
||||
|
||||
@@ -346,6 +346,11 @@ mod tests {
|
||||
assert_eq!(filter_text(s, true), "abc\nde\u{80}");
|
||||
assert_eq!(strip_formatting("§aRed§r §zx§"), "Red §zx§");
|
||||
assert_eq!(helper_clipboard_contents("§lone\r\ntwo"), "one\ntwo");
|
||||
// TextFieldHelper.getClipboardContents runs before insertText's filter.
|
||||
assert_eq!(
|
||||
filter_text(&helper_clipboard_contents("§aHello"), false),
|
||||
"Hello"
|
||||
);
|
||||
}
|
||||
|
||||
struct Field {
|
||||
|
||||
@@ -92,6 +92,13 @@ impl<W: BlockCollisionWorld> BlockCollisionWorld for EntityCollisions<'_, W> {
|
||||
) -> std::result::Result<bool, Self::Error> {
|
||||
self.blocks.is_suffocating(chunk, block, p)
|
||||
}
|
||||
fn may_reach_out(
|
||||
&mut self,
|
||||
chunk: Self::Chunk,
|
||||
section_y: i32,
|
||||
) -> std::result::Result<bool, Self::Error> {
|
||||
self.blocks.may_reach_out(chunk, section_y)
|
||||
}
|
||||
fn context_shape(
|
||||
&mut self,
|
||||
block: CollisionBlock,
|
||||
@@ -262,6 +269,17 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
|
||||
fn collision_block(&mut self, chunk: Self::Chunk, p: BlockPos) -> io::Result<CollisionBlock> {
|
||||
self.binding.collision_block(chunk.block(p.x, p.y, p.z))
|
||||
}
|
||||
/// From the section's palette, without reading its blocks; a palette
|
||||
/// of the whole registry reads every cell.
|
||||
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
|
||||
let blocks = usize::try_from(section_y - (crate::chunk::MIN_Y >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
Ok(blocks.is_none_or(|blocks| {
|
||||
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
|
||||
}))
|
||||
}
|
||||
fn is_suffocating(
|
||||
&mut self,
|
||||
_: Self::Chunk,
|
||||
|
||||
@@ -592,3 +592,21 @@ fn mod_commands_join_the_tree_for_every_player() {
|
||||
// The game's own tree is left as it was.
|
||||
assert!(vanilla::game_dispatcher().find("where").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizon_commands_are_off_unless_the_server_turns_them_on() {
|
||||
// By default the game runs vanilla's tree: /help lists no /pregen.
|
||||
assert!(vanilla::game_dispatcher().find("pregen").is_none());
|
||||
for dev in [false, true] {
|
||||
let vanilla_tree = vanilla::game_tree(dev, false);
|
||||
assert!(vanilla_tree.find("pregen").is_none());
|
||||
let d = vanilla::game_tree(dev, true);
|
||||
let parse = d.parse("pregen start 100 circle", ®());
|
||||
assert_eq!(parse.executable().unwrap().1, "pregen.start");
|
||||
// Vanilla's commands are the same with or without it.
|
||||
assert_eq!(
|
||||
d.nodes[0].children.len(),
|
||||
vanilla_tree.nodes[0].children.len() + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -538,12 +538,69 @@ fn apply_vanilla_order(d: &mut Dispatcher) {
|
||||
}
|
||||
/// The tree the game runs: a release integrated server's, or with
|
||||
/// `HORIZON_DEV_COMMANDS` set the development one
|
||||
/// (SharedConstants.DEBUG_DEV_COMMANDS).
|
||||
/// (SharedConstants.DEBUG_DEV_COMMANDS), plus Horizon's own commands when
|
||||
/// the server turned them on (`set_horizon_commands`).
|
||||
pub fn game_dispatcher() -> &'static Dispatcher {
|
||||
if std::env::var_os("HORIZON_DEV_COMMANDS").is_some() {
|
||||
dispatcher()
|
||||
} else {
|
||||
release_dispatcher()
|
||||
game_tree(
|
||||
std::env::var_os("HORIZON_DEV_COMMANDS").is_some(),
|
||||
HORIZON_COMMANDS.load(std::sync::atomic::Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
/// Whether this process's server has Horizon's own commands (`/pregen`). Off
|
||||
/// by default, so `/help` and the command tree are vanilla's; a dedicated
|
||||
/// server turns them on with `horizon-pregen=true` in server.properties, the
|
||||
/// game with `--pregen` (docs/pregeneration.md).
|
||||
static HORIZON_COMMANDS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Turns Horizon's own commands on or off for the trees `game_dispatcher`
|
||||
/// returns from now on.
|
||||
pub fn set_horizon_commands(on: bool) {
|
||||
HORIZON_COMMANDS.store(on, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
/// The server.properties key that turns `/pregen` on (Horizon's own; vanilla
|
||||
/// ignores keys it doesn't know).
|
||||
pub const PREGEN_PROPERTY: &str = "horizon-pregen";
|
||||
pub(crate) fn game_tree(dev: bool, horizon: bool) -> &'static Dispatcher {
|
||||
static HORIZON: [std::sync::OnceLock<Dispatcher>; 2] =
|
||||
[const { std::sync::OnceLock::new() }; 2];
|
||||
match (dev, horizon) {
|
||||
(true, false) => dispatcher(),
|
||||
(false, false) => release_dispatcher(),
|
||||
(dev, true) => HORIZON[usize::from(dev)].get_or_init(|| {
|
||||
let mut d = build_with(dev);
|
||||
horizon_commands(&mut d, 0);
|
||||
d
|
||||
}),
|
||||
}
|
||||
}
|
||||
/// Commands vanilla doesn't have, after its own: `/pregen`
|
||||
/// (docs/pregeneration.md).
|
||||
fn horizon_commands(d: &mut Dispatcher, root: usize) {
|
||||
let pregen = d.literal(root, "pregen");
|
||||
d.requires(pregen, GAMEMASTERS);
|
||||
let start = d.literal(pregen, "start");
|
||||
let radius = d.argument(
|
||||
start,
|
||||
"radius",
|
||||
ArgType::Integer {
|
||||
min: 0,
|
||||
max: 30_000_000,
|
||||
},
|
||||
);
|
||||
d.executes(radius, "pregen.start");
|
||||
for shape in ["square", "circle"] {
|
||||
let shape = d.literal(radius, shape);
|
||||
d.executes(shape, "pregen.start");
|
||||
let center = d.argument(shape, "center", ArgType::ColumnPos);
|
||||
d.executes(center, "pregen.start");
|
||||
}
|
||||
for (word, id) in [
|
||||
("pause", "pregen.pause"),
|
||||
("continue", "pregen.continue"),
|
||||
("cancel", "pregen.cancel"),
|
||||
("status", "pregen.status"),
|
||||
] {
|
||||
let w = d.literal(pregen, word);
|
||||
d.executes(w, id);
|
||||
}
|
||||
}
|
||||
pub fn dispatcher() -> &'static Dispatcher {
|
||||
|
||||
@@ -275,4 +275,30 @@ mod tests {
|
||||
Release::Click(Input::QuickMove)
|
||||
);
|
||||
}
|
||||
|
||||
/// A press whose release never arrives (the pointer left the window, or
|
||||
/// the screen closed first) leaves `isQuickCrafting` set; the next screen
|
||||
/// starts with its own state, as `AbstractContainerScreen` does, so the
|
||||
/// reset the game runs on open/close makes clicks work again.
|
||||
#[test]
|
||||
fn a_lost_release_does_not_wedge_the_next_screen() {
|
||||
let mut clicks = Clicks::default();
|
||||
let slot = Target::<usize>::Slot(3);
|
||||
// Pressed with a stack carried: a quick craft drag starts, and its
|
||||
// release is lost.
|
||||
assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None);
|
||||
assert!(clicks.quick_crafting().is_some());
|
||||
assert_eq!(
|
||||
clicks.press(&slot, Button::Left, true, false, false),
|
||||
None,
|
||||
"the stuck drag swallows the next press"
|
||||
);
|
||||
// A new screen: fresh state, so the press is a normal click again.
|
||||
clicks = Clicks::default();
|
||||
assert!(clicks.quick_crafting().is_none());
|
||||
assert_eq!(
|
||||
clicks.press(&slot, Button::Left, true, false, false),
|
||||
Some(Input::Pickup)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,17 @@ pub struct Furnace {
|
||||
/// 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
|
||||
/// ("Duplicate entry for key").
|
||||
pub fn recipe_used_key(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
/// A hopper's slots: the only plain container with five.
|
||||
pub const HOPPER_SLOTS: usize = 5;
|
||||
|
||||
@@ -83,6 +94,117 @@ pub enum BlockEntity {
|
||||
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),
|
||||
}
|
||||
/// `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`.
|
||||
pub fn place_food(&mut self, held: &mut ItemStack, cooking_time: i32) -> bool {
|
||||
let Some(slot) = self.items.iter().position(Option::is_none) else {
|
||||
return false;
|
||||
};
|
||||
self.total[slot] = cooking_time;
|
||||
self.progress[slot] = 0;
|
||||
self.items[slot] = Some(ItemStack {
|
||||
count: 1,
|
||||
..held.clone()
|
||||
});
|
||||
held.count = held.count.saturating_sub(1);
|
||||
true
|
||||
}
|
||||
/// `cookTick` (lit): each item's progress rises by one; the slots whose
|
||||
/// progress reached their cooking time give up their item (the caller
|
||||
/// drops its result).
|
||||
pub fn cook_tick(&mut self) -> Vec<(usize, ItemStack)> {
|
||||
let mut done = Vec::new();
|
||||
for i in 0..4 {
|
||||
if self.items[i].is_none() {
|
||||
continue;
|
||||
}
|
||||
self.progress[i] += 1;
|
||||
if self.progress[i] >= self.total[i]
|
||||
&& let Some(item) = self.items[i].take()
|
||||
{
|
||||
done.push((i, item));
|
||||
}
|
||||
}
|
||||
done
|
||||
}
|
||||
/// `cooldownTick` (unlit): the progress falls by two, no lower than 0.
|
||||
/// Whether anything changed (`setChanged`).
|
||||
pub fn cooldown_tick(&mut self) -> bool {
|
||||
let mut changed = false;
|
||||
for i in 0..4 {
|
||||
if self.progress[i] > 0 {
|
||||
changed = true;
|
||||
self.progress[i] = (self.progress[i] - 2).clamp(0, self.total[i]);
|
||||
}
|
||||
}
|
||||
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>,
|
||||
note_block_sound: Option<String>,
|
||||
custom_name: Option<horizon_world::nbt::Tag>,
|
||||
) -> Self {
|
||||
let skin = profile.as_ref().map(SkullSkin::of);
|
||||
Self {
|
||||
profile,
|
||||
note_block_sound,
|
||||
custom_name,
|
||||
skin,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `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))
|
||||
});
|
||||
// DefaultPlayerSkin.get(id), or getDefaultSkin (slim steve) without an id.
|
||||
let fallback = crate::skins::profile_uuid_nbt(profile)
|
||||
.map_or("entity/player/slim/steve", crate::tab_list::default_skin);
|
||||
Self { download, fallback }
|
||||
}
|
||||
}
|
||||
/// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every
|
||||
/// `*_skull` and `*_head` but the piston's head.
|
||||
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.
|
||||
@@ -238,6 +360,9 @@ impl BlockEntity {
|
||||
if name.ends_with("_banner") {
|
||||
return Some(Self::Banner(Banner::default()));
|
||||
}
|
||||
if is_skull(name) {
|
||||
return Some(Self::Skull(Skull::default()));
|
||||
}
|
||||
if name == "minecraft:beacon" {
|
||||
return Some(Self::Beacon(Box::new(Beacon {
|
||||
last_check_y: i32::MIN / 2,
|
||||
@@ -247,6 +372,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:brewing_stand" {
|
||||
return Some(Self::Brewing(Box::new(crate::brewing::BrewingStand::new())));
|
||||
}
|
||||
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
|
||||
return Some(Self::Campfire(Box::default()));
|
||||
}
|
||||
let size = match name {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
|
||||
n if n.ends_with("copper_chest") => 27,
|
||||
@@ -283,6 +411,8 @@ impl BlockEntity {
|
||||
Self::Vault(_) => "minecraft:vault",
|
||||
Self::Beehive(_) => "minecraft:beehive",
|
||||
Self::CommandBlock(_) => "minecraft:command_block",
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -298,11 +428,13 @@ impl BlockEntity {
|
||||
| Self::TrialSpawner(_)
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_) => Vec::new(),
|
||||
| Self::CommandBlock(_)
|
||||
| Self::Skull(_) => Vec::new(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
Self::Sculk(_) => Vec::new(),
|
||||
Self::Banner(_) => Vec::new(),
|
||||
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
|
||||
}
|
||||
}
|
||||
pub fn slots(&self) -> &[Option<ItemStack>] {
|
||||
@@ -315,14 +447,17 @@ impl BlockEntity {
|
||||
| Self::Beacon(_)
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_) => &[],
|
||||
| Self::Banner(_)
|
||||
| Self::Skull(_) => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_) => &[],
|
||||
| Self::CommandBlock(_)
|
||||
// Not a Container (only Clearable): hoppers don't reach in.
|
||||
| Self::Campfire(_) => &[],
|
||||
}
|
||||
}
|
||||
pub fn slots_mut(&mut self) -> &mut [Option<ItemStack>] {
|
||||
@@ -335,14 +470,16 @@ impl BlockEntity {
|
||||
| Self::Beacon(_)
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_) => &mut [],
|
||||
| Self::Banner(_)
|
||||
| Self::Skull(_) => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_) => &mut [],
|
||||
| Self::CommandBlock(_)
|
||||
| Self::Campfire(_) => &mut [],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -577,7 +714,10 @@ impl Furnace {
|
||||
Some(out) => out.count += recipe.result.count,
|
||||
None => self.items[2] = Some(recipe.result.clone()),
|
||||
}
|
||||
*self.recipes_used.entry(recipe.id.clone()).or_insert(0) += 1;
|
||||
*self
|
||||
.recipes_used
|
||||
.entry(recipe_used_key(&recipe.id))
|
||||
.or_insert(0) += 1;
|
||||
if let Some(i) = self.items[0].as_mut() {
|
||||
i.count -= 1;
|
||||
if i.count == 0 {
|
||||
@@ -631,6 +771,41 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
|
||||
#[test]
|
||||
fn campfire_slots_take_food_cook_it_and_cool_down() {
|
||||
let mut c = Campfire::default();
|
||||
let mut beef = ItemStack::new("minecraft:beef", 2);
|
||||
// placeFood: the first free slot, one item, its recipe's time.
|
||||
assert!(c.place_food(&mut beef, 600));
|
||||
assert_eq!(beef.count, 1);
|
||||
assert_eq!(c.items[0].as_ref().map(|s| s.count), Some(1));
|
||||
assert_eq!((c.progress[0], c.total[0]), (0, 600));
|
||||
let mut fish = ItemStack::new("minecraft:cod", 1);
|
||||
assert!(c.place_food(&mut fish, 3));
|
||||
assert_eq!(c.items[1].as_ref().map(|s| &*s.item), Some("minecraft:cod"));
|
||||
// cookTick: the cod is done on its third tick, the beef keeps cooking.
|
||||
assert!(c.cook_tick().is_empty());
|
||||
assert!(c.cook_tick().is_empty());
|
||||
let done = c.cook_tick();
|
||||
assert_eq!(done.len(), 1);
|
||||
assert_eq!((done[0].0, &*done[0].1.item), (1, "minecraft:cod"));
|
||||
assert!(c.items[1].is_none());
|
||||
assert_eq!(c.progress[0], 3);
|
||||
// cooldownTick: two a tick, never below zero.
|
||||
assert!(c.cooldown_tick());
|
||||
assert_eq!(c.progress[0], 1);
|
||||
assert!(c.cooldown_tick());
|
||||
assert_eq!(c.progress[0], 0);
|
||||
// Full: four slots, no fifth.
|
||||
for _ in 0..3 {
|
||||
let mut more = ItemStack::new("minecraft:potato", 1);
|
||||
assert!(c.place_food(&mut more, 600));
|
||||
}
|
||||
let mut fifth = ItemStack::new("minecraft:potato", 1);
|
||||
assert!(!c.place_food(&mut fifth, 600));
|
||||
assert_eq!(fifth.count, 1);
|
||||
}
|
||||
|
||||
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
|
||||
#[test]
|
||||
fn create_experience_rounds_the_fraction_by_chance() {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! `Player.getCooldowns()` (`ItemCooldowns`) as the game keeps it: the
|
||||
//! `use_cooldown` component's entries by cooldown group, counted down once a
|
||||
//! tick, and the percent `GuiGraphicsExtractor.itemCooldown` shades a slot
|
||||
//! by. The world crate's `item_cooldowns` models vanilla's hash map and its
|
||||
//! observers for the parity harness; this is the same data with the lookup a
|
||||
//! slot needs, and no observer (vanilla's own player uses the plain class).
|
||||
use crate::items::ItemStack;
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A stack's `minecraft:use_cooldown` component.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct UseCooldown {
|
||||
pub seconds: f32,
|
||||
/// `cooldown_group`: the group the entry is filed under, else the item.
|
||||
pub group: Option<Arc<str>>,
|
||||
}
|
||||
impl UseCooldown {
|
||||
/// `UseCooldown.ticks`: the seconds in ticks, truncated.
|
||||
pub fn ticks(&self) -> i32 {
|
||||
(self.seconds * 20.0) as i32
|
||||
}
|
||||
/// The component as `items.json` holds it.
|
||||
pub fn from_json(v: &serde_json::Value) -> Option<Self> {
|
||||
let seconds = v.get("seconds")?.as_f64()? as f32;
|
||||
Some(Self {
|
||||
seconds,
|
||||
group: v
|
||||
.get("cooldown_group")
|
||||
.and_then(|g| g.as_str())
|
||||
.map(Arc::from),
|
||||
})
|
||||
}
|
||||
/// The component as a stack's saved `components` holds it.
|
||||
pub fn from_tag(t: &Tag) -> Option<Self> {
|
||||
let Tag::Compound(c) = t else {
|
||||
return None;
|
||||
};
|
||||
let text = |k: &str| match c.get(&horizon_world::nbt::JavaString::from(k)) {
|
||||
Some(Tag::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
let seconds = match c.get(&horizon_world::nbt::JavaString::from("seconds")) {
|
||||
Some(Tag::Float(bits)) => f32::from_bits(*bits),
|
||||
Some(Tag::Double(bits)) => f64::from_bits(*bits) as f32,
|
||||
_ => return None,
|
||||
};
|
||||
Some(Self {
|
||||
seconds,
|
||||
group: text("cooldown_group").map(Arc::from),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The component a stack carries: its own patch first (`ItemStack.get`), then
|
||||
/// the item's default from `items.json` (`ItemInfo::use_cooldown`).
|
||||
pub fn component(stack: &ItemStack, default: Option<&UseCooldown>) -> Option<UseCooldown> {
|
||||
stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:use_cooldown"))
|
||||
.and_then(UseCooldown::from_tag)
|
||||
.or_else(|| default.cloned())
|
||||
}
|
||||
|
||||
/// `ItemCooldowns.getCooldownGroup`: the component's group, else the item id.
|
||||
pub fn group(stack: &ItemStack, default: Option<&UseCooldown>) -> Arc<str> {
|
||||
component(stack, default)
|
||||
.and_then(|c| c.group)
|
||||
.unwrap_or_else(|| Arc::clone(&stack.item))
|
||||
}
|
||||
|
||||
/// `ItemCooldowns`: start and end ticks by group, and the tick counter.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Cooldowns {
|
||||
tick_count: i32,
|
||||
entries: HashMap<Arc<str>, (i32, i32)>,
|
||||
}
|
||||
impl Cooldowns {
|
||||
/// `getCooldownPercent`: what is left of the entry, from 1 to 0.
|
||||
pub fn percent(&self, group: &str, partial_tick: f32) -> f32 {
|
||||
let Some((start, end)) = self.entries.get(group) else {
|
||||
return 0.0;
|
||||
};
|
||||
let duration = end.wrapping_sub(*start) as f32;
|
||||
let remaining = *end as f32 - (self.tick_count as f32 + partial_tick);
|
||||
(remaining / duration).clamp(0.0, 1.0)
|
||||
}
|
||||
/// `isOnCooldown`: whether the group still has time to run.
|
||||
pub fn is_on_cooldown(&self, group: &str) -> bool {
|
||||
self.percent(group, 0.0) > 0.0
|
||||
}
|
||||
/// `addCooldown`.
|
||||
pub fn add(&mut self, group: Arc<str>, ticks: i32) {
|
||||
self.entries.insert(
|
||||
group,
|
||||
(self.tick_count, self.tick_count.wrapping_add(ticks)),
|
||||
);
|
||||
}
|
||||
/// `ItemCooldowns.tick`: the counter, then every entry that has run out,
|
||||
/// which it hands back (`onCooldownEnded`).
|
||||
pub fn tick(&mut self) -> Vec<Arc<str>> {
|
||||
self.tick_count = self.tick_count.wrapping_add(1);
|
||||
let done: Vec<Arc<str>> = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|(_, (_, end))| *end <= self.tick_count)
|
||||
.map(|(group, _)| Arc::clone(group))
|
||||
.collect();
|
||||
self.entries.retain(|_, (_, end)| *end > self.tick_count);
|
||||
done
|
||||
}
|
||||
/// `removeCooldown`.
|
||||
pub fn remove(&mut self, group: &str) {
|
||||
self.entries.remove(group);
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn stack(item: &str) -> ItemStack {
|
||||
ItemStack::new(item, 1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_percent_falls_from_one_to_zero_over_the_entry() {
|
||||
let mut c = Cooldowns::default();
|
||||
c.add(Arc::from("minecraft:ender_pearl"), 20);
|
||||
assert!(c.is_on_cooldown("minecraft:ender_pearl"));
|
||||
assert_eq!(c.percent("minecraft:ender_pearl", 0.0), 1.0);
|
||||
for _ in 0..10 {
|
||||
assert!(c.tick().is_empty());
|
||||
}
|
||||
assert_eq!(c.percent("minecraft:ender_pearl", 0.0), 0.5);
|
||||
// The partial tick moves it within the tick.
|
||||
assert_eq!(c.percent("minecraft:ender_pearl", 0.5), 0.475);
|
||||
for _ in 0..9 {
|
||||
c.tick();
|
||||
}
|
||||
assert!(c.is_on_cooldown("minecraft:ender_pearl"));
|
||||
// ItemCooldowns.tick drops the entry at its end tick, and says so.
|
||||
assert_eq!(c.tick(), [Arc::<str>::from("minecraft:ender_pearl")]);
|
||||
assert!(!c.is_on_cooldown("minecraft:ender_pearl"));
|
||||
assert!(c.is_empty());
|
||||
assert_eq!(c.percent("minecraft:ender_pearl", 0.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_group_comes_from_the_stack_then_the_item() {
|
||||
let pearl = stack("minecraft:ender_pearl");
|
||||
let default = UseCooldown::from_json(&json!({"seconds": 1.0})).expect("the component");
|
||||
assert_eq!(&*group(&pearl, Some(&default)), "minecraft:ender_pearl");
|
||||
// A stack's own component overrides the item's, group included.
|
||||
let mut grouped = stack("minecraft:ender_pearl");
|
||||
grouped.data_mut().set_other(
|
||||
"minecraft:use_cooldown",
|
||||
Some(Tag::compound([
|
||||
("seconds", Tag::Float(2.0f32.to_bits())),
|
||||
("cooldown_group", Tag::String("minecraft:pearls".into())),
|
||||
])),
|
||||
);
|
||||
let c = component(&grouped, Some(&default)).expect("the stack's component");
|
||||
assert_eq!(c.ticks(), 40);
|
||||
assert_eq!(&*group(&grouped, Some(&default)), "minecraft:pearls");
|
||||
// An item without the component has no cooldown.
|
||||
assert!(component(&stack("minecraft:dirt"), None).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1612,7 +1612,9 @@ impl Recipes {
|
||||
}
|
||||
/// The `experience` of the cooking recipe with this file name, and 0 for one this data
|
||||
/// pack does not have (a save from another pack can name it).
|
||||
/// `id` with or without the `minecraft:` namespace (a furnace keeps the full id).
|
||||
pub fn cooking_experience(&self, id: &str) -> f32 {
|
||||
let id = id.strip_prefix("minecraft:").unwrap_or(id);
|
||||
self.cooking
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Creative inventory (CreativeModeInventoryScreen): vanilla's tabs and their
|
||||
//! contents as built by the game's own display generators
|
||||
//! (`creative-inventory.json`), a 9x5 scrolling grid and the search tab.
|
||||
use crate::{Result, items::ItemStack};
|
||||
use crate::{Result, items::ItemStack, ui::EditBox};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
|
||||
@@ -35,10 +35,8 @@ pub struct Creative {
|
||||
pub scroll: f32,
|
||||
/// The scrollbar is held (scrolling): dragging moves it.
|
||||
pub scrolling: bool,
|
||||
pub search: String,
|
||||
/// When the search box took the focus (`EditBox.setFocused`): its
|
||||
/// cursor blinks from then.
|
||||
pub search_focused: std::time::Instant,
|
||||
/// CreativeModeInventoryScreen's search box: an unbordered `EditBox` of 50.
|
||||
pub search: EditBox,
|
||||
/// The search tab's stacks, in its display order.
|
||||
search_items: Vec<ItemStack>,
|
||||
search_index: Vec<SearchEntry>,
|
||||
@@ -284,8 +282,13 @@ impl Creative {
|
||||
selected: 0,
|
||||
scroll: 0.0,
|
||||
scrolling: false,
|
||||
search: String::new(),
|
||||
search_focused: std::time::Instant::now(),
|
||||
search: {
|
||||
let mut search = EditBox::new(0.0, 0.0, 80.0, 9.0);
|
||||
search.bordered = false;
|
||||
search.max_length = 50;
|
||||
search.text_color = u32::MAX;
|
||||
search
|
||||
},
|
||||
search_items,
|
||||
search_index,
|
||||
}
|
||||
@@ -306,12 +309,13 @@ impl Creative {
|
||||
/// selectTab: a different tab empties the search box; the scrollbar goes to the top.
|
||||
pub fn select(&mut self, tab: usize) {
|
||||
if tab != self.selected {
|
||||
self.search.clear();
|
||||
self.search.set_value("");
|
||||
}
|
||||
self.selected = tab;
|
||||
self.scroll = 0.0;
|
||||
if self.tab().search {
|
||||
self.search_focused = std::time::Instant::now();
|
||||
self.search.focused = true;
|
||||
self.search.reset_blink();
|
||||
}
|
||||
}
|
||||
/// The search tab's stacks.
|
||||
@@ -385,7 +389,7 @@ impl Creative {
|
||||
let tab = self.tab();
|
||||
if tab.search {
|
||||
return self
|
||||
.search_results(&self.search, names)
|
||||
.search_results(&self.search.value, names)
|
||||
.into_iter()
|
||||
.map(|i| self.search_items[i].clone())
|
||||
.collect();
|
||||
@@ -754,13 +758,13 @@ mod tests {
|
||||
(1, false, true),
|
||||
(2, true, false),
|
||||
] {
|
||||
c.search = "anvil".into();
|
||||
c.search.set_value("anvil");
|
||||
c.scroll = 0.5;
|
||||
c.select(selected);
|
||||
assert_eq!(c.selected, selected);
|
||||
assert_eq!(c.tab().inventory, inventory);
|
||||
assert_eq!(c.tab().search, search);
|
||||
assert!(c.search.is_empty());
|
||||
assert!(c.search.value.is_empty());
|
||||
assert_eq!(c.scroll, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -780,12 +784,12 @@ mod tests {
|
||||
assert_eq!(c.tabs[0].items[0].item.as_ref(), "minecraft:anvil");
|
||||
let mut c = c;
|
||||
c.select(1);
|
||||
c.search = "anvil".into();
|
||||
c.search.set_value("anvil");
|
||||
c.scroll = 0.5;
|
||||
c.select(1);
|
||||
assert_eq!((c.search.as_str(), c.scroll), ("anvil", 0.0));
|
||||
assert_eq!((c.search.value.as_str(), c.scroll), ("anvil", 0.0));
|
||||
c.select(0);
|
||||
assert_eq!(c.search, "");
|
||||
assert_eq!(c.search.value, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -899,4 +903,25 @@ mod tests {
|
||||
c.scroll = 1.0;
|
||||
assert_eq!(c.scroller_offset(), 95.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_edits_at_its_cursor() {
|
||||
use winit::keyboard::KeyCode;
|
||||
let mut c = creative(0);
|
||||
c.select(1);
|
||||
c.search.insert_text("hello world");
|
||||
for _ in 0..5 {
|
||||
assert!(c.search.key(KeyCode::ArrowLeft, false, false));
|
||||
}
|
||||
c.search.insert_text("X");
|
||||
assert_eq!(c.search.value, "hello Xworld");
|
||||
c.search.key(KeyCode::Home, false, true);
|
||||
c.search.insert_text("Y");
|
||||
assert_eq!(c.search.value, "Yworld");
|
||||
// Ctrl+Left jumps a word; one step right and Backspace deletes before the cursor.
|
||||
c.search.key(KeyCode::ArrowLeft, true, false);
|
||||
c.search.key(KeyCode::ArrowRight, false, false);
|
||||
c.search.key(KeyCode::Backspace, false, false);
|
||||
assert_eq!(c.search.value, "world");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Cushion placement and support checks (`Cushion.canBePlacedAt`/`wouldSurviveAt`).
|
||||
use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape};
|
||||
|
||||
pub trait World {
|
||||
fn outline_bounds(&self, pos: [i32; 3]) -> Option<Aabb>;
|
||||
fn collision_shapes(&self, bounds: Aabb) -> Vec<VoxelShape>;
|
||||
fn suffocating(&self, pos: [i32; 3]) -> bool;
|
||||
}
|
||||
|
||||
pub fn blocks_in(a: Aabb) -> impl Iterator<Item = [i32; 3]> {
|
||||
let (lo, hi) = (a.min_position(), a.max_position());
|
||||
let (x0, y0, z0) = (
|
||||
lo.x.floor() as i32,
|
||||
lo.y.floor() as i32,
|
||||
lo.z.floor() as i32,
|
||||
);
|
||||
let (x1, y1, z1) = (
|
||||
hi.x.floor() as i32,
|
||||
hi.y.floor() as i32,
|
||||
hi.z.floor() as i32,
|
||||
);
|
||||
(y0..=y1).flat_map(move |y| (z0..=z1).flat_map(move |z| (x0..=x1).map(move |x| [x, y, z])))
|
||||
}
|
||||
|
||||
pub fn survives(world: &impl World, bounds: Aabb) -> bool {
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
let anchor = Aabb::new(
|
||||
Vec3::new(lo.x, lo.y - 0.015625, lo.z),
|
||||
Vec3::new(hi.x.next_down(), lo.y, hi.z.next_down()),
|
||||
);
|
||||
// The extra eighth block expands the scan, not the intersection test.
|
||||
let supported = blocks_in(anchor.expand_towards(Vec3::new(0.0, -0.125, 0.0))).any(|p| {
|
||||
world
|
||||
.outline_bounds(p)
|
||||
.is_some_and(|shape| shape.intersects(anchor))
|
||||
});
|
||||
supported && !blocks_in(bounds.next_deflated()).all(|p| world.suffocating(p))
|
||||
}
|
||||
|
||||
pub fn can_place(world: &impl World, bounds: Aabb) -> bool {
|
||||
if !survives(world, bounds) {
|
||||
return false;
|
||||
}
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
let slab = Aabb::new(lo, Vec3::new(hi.x, lo.y + 0.015625, hi.z)).next_deflated();
|
||||
let Ok(mut remaining) = VoxelShape::from_box(slab) else {
|
||||
return false;
|
||||
};
|
||||
for shape in world.collision_shapes(slab) {
|
||||
let Ok(next) = remaining.join(&shape, ShapeOp::ONLY_FIRST) else {
|
||||
return false;
|
||||
};
|
||||
remaining = next;
|
||||
if remaining.is_empty() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -84,6 +84,33 @@ impl Displayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's own lines: the shader pack's GPU time, then its costliest passes (`passes`: each
|
||||
/// pass's median milliseconds, in the order they run).
|
||||
pub fn shader_pass_lines(passes: &[(String, f32)]) -> Vec<String> {
|
||||
const SHOWN: usize = 8;
|
||||
if passes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let total: f32 = passes.iter().map(|p| p.1).sum();
|
||||
let mut lines = vec![format!(
|
||||
"Shader pack GPU: {total:.2} ms in {} passes",
|
||||
passes.len()
|
||||
)];
|
||||
let mut costliest: Vec<&(String, f32)> = passes.iter().collect();
|
||||
costliest.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
lines.extend(
|
||||
costliest
|
||||
.iter()
|
||||
.take(SHOWN)
|
||||
.map(|(name, ms)| format!("{name}: {ms:.2} ms")),
|
||||
);
|
||||
if costliest.len() > SHOWN {
|
||||
let rest: f32 = costliest[SHOWN..].iter().map(|p| p.1).sum();
|
||||
lines.push(format!("{} more: {rest:.2} ms", costliest.len() - SHOWN));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// `Mth.wrapDegrees`.
|
||||
fn wrap_degrees(angle: f32) -> f32 {
|
||||
let wrapped = angle % 360.0;
|
||||
@@ -194,18 +221,17 @@ pub fn scale(
|
||||
pub fn draw(font: &Font, columns: [&[String]; 2], scale: f32, width: i32) -> Vec<GuiDraw> {
|
||||
let mut out = vec![GuiDraw::Push(vec![PoseOp::Scale(scale, scale)])];
|
||||
for (lines, align_left) in [(columns[0], true), (columns[1], false)] {
|
||||
let placed: Vec<(&String, i32, i32)> = lines
|
||||
let placed: Vec<(&String, i32, i32, i32)> = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| !l.is_empty())
|
||||
.map(|(i, l)| {
|
||||
let w = font.width(l) as i32;
|
||||
let left = if align_left { 2 } else { width - 2 - w };
|
||||
(l, left, 2 + LINE_HEIGHT * i as i32)
|
||||
(l, left, 2 + LINE_HEIGHT * i as i32, w)
|
||||
})
|
||||
.collect();
|
||||
for (line, left, top) in &placed {
|
||||
let w = font.width(line) as i32;
|
||||
for (_, left, top, w) in &placed {
|
||||
out.push(fill(
|
||||
left - 1,
|
||||
top - 1,
|
||||
@@ -214,7 +240,7 @@ pub fn draw(font: &Font, columns: [&[String]; 2], scale: f32, width: i32) -> Vec
|
||||
BACKGROUND,
|
||||
));
|
||||
}
|
||||
for (line, left, top) in placed {
|
||||
for (line, left, top, _) in placed {
|
||||
out.push(GuiDraw::Text {
|
||||
text: Text::literal(line.clone()),
|
||||
x: left,
|
||||
@@ -337,4 +363,15 @@ mod tests {
|
||||
(1.0, [640, 360])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_pass_lines_list_the_costliest_passes_first() {
|
||||
assert!(shader_pass_lines(&[]).is_empty());
|
||||
let passes: Vec<(String, f32)> = (0..10).map(|i| (format!("p{i}"), i as f32)).collect();
|
||||
let lines = shader_pass_lines(&passes);
|
||||
assert_eq!(lines[0], "Shader pack GPU: 45.00 ms in 10 passes");
|
||||
assert_eq!(lines[1], "p9: 9.00 ms");
|
||||
assert_eq!(lines[8], "p2: 2.00 ms");
|
||||
assert_eq!(lines[9], "2 more: 1.00 ms");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
//! Server dialogs (`net.minecraft.server.dialog`): what `Dialog.DIRECT_CODEC`
|
||||
//! decodes from a `show_dialog` packet, the values its inputs hold, and the
|
||||
//! click event an action makes of them (`Action.createAction`).
|
||||
use crate::text::Text;
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// `CommonDialogData` and the type's own fields.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Dialog {
|
||||
pub kind: Kind,
|
||||
pub title: Text,
|
||||
pub external_title: Option<Text>,
|
||||
pub can_close_with_escape: bool,
|
||||
pub pause: bool,
|
||||
pub after_action: AfterAction,
|
||||
pub body: Vec<Body>,
|
||||
pub inputs: Vec<Input>,
|
||||
}
|
||||
|
||||
/// The dialog types (`DialogTypes`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Kind {
|
||||
/// `NoticeDialog`: one button, "OK" when the server names none.
|
||||
Notice(Button),
|
||||
Confirmation {
|
||||
yes: Button,
|
||||
no: Button,
|
||||
},
|
||||
MultiAction {
|
||||
actions: Vec<Button>,
|
||||
exit: Option<Button>,
|
||||
columns: i32,
|
||||
},
|
||||
ServerLinks {
|
||||
exit: Option<Button>,
|
||||
columns: i32,
|
||||
button_width: i32,
|
||||
},
|
||||
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs).
|
||||
DialogList {
|
||||
dialogs: Tag,
|
||||
exit: Option<Button>,
|
||||
columns: i32,
|
||||
button_width: i32,
|
||||
},
|
||||
}
|
||||
|
||||
/// `DialogAction`: the screen after an action runs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AfterAction {
|
||||
Close,
|
||||
None,
|
||||
WaitForResponse,
|
||||
}
|
||||
|
||||
/// `ActionButton`: `CommonButtonData` and what it does.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Button {
|
||||
pub label: Text,
|
||||
pub tooltip: Option<Text>,
|
||||
pub width: i32,
|
||||
pub action: Option<Action>,
|
||||
}
|
||||
|
||||
/// `Action`: a fixed click event, or one made from the inputs' values.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Action {
|
||||
/// `StaticAction`: a click event as it is.
|
||||
Static(Click),
|
||||
/// `CommandTemplate`: a command with `$(key)` replaced by input values.
|
||||
RunCommand(String),
|
||||
/// `CustomAll`: a custom click event whose payload is `additions` plus
|
||||
/// every input's value under its key.
|
||||
Custom { id: String, additions: Option<Tag> },
|
||||
}
|
||||
|
||||
/// `ClickEvent`, the kinds a server may send (`isAllowedFromServer`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Click {
|
||||
OpenUrl(String),
|
||||
RunCommand(String),
|
||||
SuggestCommand(String),
|
||||
/// The dialog: an id of the `minecraft:dialog` registry or the dialog itself.
|
||||
ShowDialog(Tag),
|
||||
ChangePage(i32),
|
||||
CopyToClipboard(String),
|
||||
Custom {
|
||||
id: String,
|
||||
payload: Option<Tag>,
|
||||
},
|
||||
}
|
||||
|
||||
/// `DialogBody`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Body {
|
||||
/// `PlainMessage`: centred text wrapped to `width`.
|
||||
Message { contents: Text, width: i32 },
|
||||
/// `ItemBody`: the item as `ItemStackTemplate` sent it.
|
||||
Item {
|
||||
item: Tag,
|
||||
description: Option<(Text, i32)>,
|
||||
show_decorations: bool,
|
||||
show_tooltip: bool,
|
||||
width: i32,
|
||||
height: i32,
|
||||
},
|
||||
}
|
||||
|
||||
/// `Input`: the key its value goes by, and its control.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Input {
|
||||
pub key: String,
|
||||
pub control: Control,
|
||||
}
|
||||
|
||||
/// `InputControl`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Control {
|
||||
Text {
|
||||
width: i32,
|
||||
label: Text,
|
||||
label_visible: bool,
|
||||
initial: String,
|
||||
max_length: i32,
|
||||
/// `MultilineOptions`: the most lines and the height, when set.
|
||||
multiline: Option<(Option<i32>, Option<i32>)>,
|
||||
},
|
||||
Boolean {
|
||||
label: Text,
|
||||
initial: bool,
|
||||
on_true: String,
|
||||
on_false: String,
|
||||
},
|
||||
SingleOption {
|
||||
width: i32,
|
||||
/// Each option's id, its label and whether it is the initial one.
|
||||
options: Vec<(String, Option<Text>, bool)>,
|
||||
label: Text,
|
||||
label_visible: bool,
|
||||
},
|
||||
NumberRange {
|
||||
width: i32,
|
||||
label: Text,
|
||||
label_format: String,
|
||||
start: f32,
|
||||
end: f32,
|
||||
initial: Option<f32>,
|
||||
step: Option<f32>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What an input holds while the dialog is open.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum InputValue {
|
||||
Text(String),
|
||||
Bool(bool),
|
||||
/// The chosen option's index.
|
||||
Option(usize),
|
||||
/// The slider's position, 0 to 1.
|
||||
Slider(f32),
|
||||
}
|
||||
|
||||
fn text(t: &Tag) -> Text {
|
||||
crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t))
|
||||
}
|
||||
fn string(t: Option<&Tag>) -> Option<String> {
|
||||
match t? {
|
||||
Tag::String(s) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn number(t: Option<&Tag>) -> Option<f64> {
|
||||
Some(match t? {
|
||||
Tag::Byte(v) => f64::from(*v),
|
||||
Tag::Short(v) => f64::from(*v),
|
||||
Tag::Int(v) => f64::from(*v),
|
||||
Tag::Long(v) => *v as f64,
|
||||
Tag::Float(v) => f64::from(f32::from_bits(*v)),
|
||||
Tag::Double(v) => f64::from_bits(*v),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
fn int(t: &Tag, key: &str, default: i32) -> i32 {
|
||||
number(t.get(key)).map_or(default, |v| v as i32)
|
||||
}
|
||||
fn boolean(t: &Tag, key: &str, default: bool) -> bool {
|
||||
number(t.get(key)).map_or(default, |v| v != 0.0)
|
||||
}
|
||||
fn list(t: Option<&Tag>) -> &[Tag] {
|
||||
match t {
|
||||
Some(Tag::List(items)) => items,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
/// A registry name without its default namespace.
|
||||
fn path(id: &str) -> &str {
|
||||
id.strip_prefix("minecraft:").unwrap_or(id)
|
||||
}
|
||||
|
||||
impl Button {
|
||||
/// `ActionButton.CODEC`.
|
||||
fn parse(t: &Tag) -> Option<Self> {
|
||||
Some(Self {
|
||||
label: text(t.get("label")?),
|
||||
tooltip: t.get("tooltip").map(text),
|
||||
width: int(t, "width", 150),
|
||||
action: t.get("action").and_then(Action::parse),
|
||||
})
|
||||
}
|
||||
/// `NoticeDialog.DEFAULT_ACTION`.
|
||||
fn ok() -> Self {
|
||||
Self {
|
||||
label: Text::translate("gui.ok", &[]),
|
||||
tooltip: None,
|
||||
width: 150,
|
||||
action: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Click {
|
||||
/// `ClickEvent.CODEC`, from its `action` (a click event in a component)
|
||||
/// or `type` (an action) field.
|
||||
pub fn parse(t: &Tag, kind: &str) -> Option<Self> {
|
||||
let s = |key| string(t.get(key));
|
||||
Some(match path(kind) {
|
||||
"open_url" => Self::OpenUrl(s("url")?),
|
||||
"run_command" => Self::RunCommand(s("command")?),
|
||||
"suggest_command" => Self::SuggestCommand(s("command")?),
|
||||
"show_dialog" => Self::ShowDialog(t.get("dialog")?.clone()),
|
||||
"change_page" => Self::ChangePage(int(t, "page", 1)),
|
||||
"copy_to_clipboard" => Self::CopyToClipboard(s("value")?),
|
||||
"custom" => Self::Custom {
|
||||
id: s("id")?,
|
||||
payload: t.get("payload").cloned(),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Action {
|
||||
/// `Action.CODEC`, dispatched on `type`.
|
||||
fn parse(t: &Tag) -> Option<Self> {
|
||||
let kind = string(t.get("type"))?;
|
||||
Some(match path(&kind) {
|
||||
"dynamic/run_command" => Self::RunCommand(string(t.get("template"))?),
|
||||
"dynamic/custom" => Self::Custom {
|
||||
id: string(t.get("id"))?,
|
||||
additions: t.get("additions").cloned(),
|
||||
},
|
||||
other => Self::Static(Click::parse(t, other)?),
|
||||
})
|
||||
}
|
||||
/// `createAction`: the click event, given each input's key, control and value.
|
||||
pub fn click(&self, inputs: &[Input], values: &[InputValue]) -> Option<Click> {
|
||||
let values = || inputs.iter().zip(values);
|
||||
Some(match self {
|
||||
Self::Static(click) => click.clone(),
|
||||
Self::RunCommand(template) => Click::RunCommand(substitute(template, &|key| {
|
||||
values()
|
||||
.find(|(i, _)| i.key == key)
|
||||
.map(|(i, v)| i.control.substitution(v))
|
||||
})),
|
||||
Self::Custom { id, additions } => {
|
||||
let mut tag = match additions {
|
||||
Some(t @ Tag::Compound(_)) => t.clone(),
|
||||
_ => Tag::Compound(Default::default()),
|
||||
};
|
||||
for (input, value) in values() {
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.insert(input.key.as_str().into(), input.control.tag(value));
|
||||
}
|
||||
}
|
||||
Click::Custom {
|
||||
id: id.clone(),
|
||||
payload: Some(tag),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `ParsedTemplate.instantiate`: each `$(key)` replaced by its value, or by
|
||||
/// nothing for a key no input has.
|
||||
fn substitute(template: &str, value: &dyn Fn(&str) -> Option<String>) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = template;
|
||||
while let Some(at) = rest.find("$(") {
|
||||
let Some(end) = rest[at..].find(')') else {
|
||||
break;
|
||||
};
|
||||
out.push_str(&rest[..at]);
|
||||
out.push_str(&value(&rest[at + 2..at + end]).unwrap_or_default());
|
||||
rest = &rest[at + end + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// `StringTag.escapeWithoutQuotes`.
|
||||
fn escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' | '\'' | '\\' => {
|
||||
out.push('\\');
|
||||
out.push(c);
|
||||
}
|
||||
'\u{8}' => out.push_str("\\b"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\u{c}' => out.push_str("\\f"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
c if c < ' ' => out.push_str(&format!("\\x{:02x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Control {
|
||||
/// `InputControl.MAP_CODEC`, dispatched on `type`.
|
||||
fn parse(t: &Tag) -> Option<Self> {
|
||||
let label = || t.get("label").map(text);
|
||||
Some(match path(&string(t.get("type"))?) {
|
||||
"text" => Self::Text {
|
||||
width: int(t, "width", 200),
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
initial: string(t.get("initial")).unwrap_or_default(),
|
||||
max_length: int(t, "max_length", 32),
|
||||
multiline: t.get("multiline").map(|m| {
|
||||
let get = |key| number(m.get(key)).map(|v| v as i32);
|
||||
(get("max_lines"), get("height"))
|
||||
}),
|
||||
},
|
||||
"boolean" => Self::Boolean {
|
||||
label: label()?,
|
||||
initial: boolean(t, "initial", false),
|
||||
on_true: string(t.get("on_true")).unwrap_or_else(|| "true".into()),
|
||||
on_false: string(t.get("on_false")).unwrap_or_else(|| "false".into()),
|
||||
},
|
||||
"single_option" => Self::SingleOption {
|
||||
width: int(t, "width", 200),
|
||||
// SingleOptionInput.Entry.CODEC: a full entry or just its id.
|
||||
options: list(t.get("options"))
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
Tag::String(id) => Some((id.to_string_lossy(), None, false)),
|
||||
o => Some((
|
||||
string(o.get("id"))?,
|
||||
o.get("display").map(text),
|
||||
boolean(o, "initial", false),
|
||||
)),
|
||||
})
|
||||
.collect(),
|
||||
label: label()?,
|
||||
label_visible: boolean(t, "label_visible", true),
|
||||
},
|
||||
"number_range" => {
|
||||
let float = |key| number(t.get(key)).map(|v| v as f32);
|
||||
Self::NumberRange {
|
||||
width: int(t, "width", 200),
|
||||
label: label()?,
|
||||
label_format: string(t.get("label_format"))
|
||||
.unwrap_or_else(|| "options.generic_value".into()),
|
||||
start: float("start")?,
|
||||
end: float("end")?,
|
||||
initial: float("initial"),
|
||||
step: float("step"),
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// The value it starts with.
|
||||
pub fn initial(&self) -> InputValue {
|
||||
match self {
|
||||
Self::Text { initial, .. } => InputValue::Text(initial.clone()),
|
||||
Self::Boolean { initial, .. } => InputValue::Bool(*initial),
|
||||
Self::SingleOption { options, .. } => {
|
||||
InputValue::Option(options.iter().position(|o| o.2).unwrap_or(0))
|
||||
}
|
||||
Self::NumberRange {
|
||||
start,
|
||||
end,
|
||||
initial,
|
||||
..
|
||||
} => InputValue::Slider(to_slider(
|
||||
initial.unwrap_or((start + end) / 2.0),
|
||||
*start,
|
||||
*end,
|
||||
)),
|
||||
}
|
||||
}
|
||||
/// `ValueGetter.asTemplateSubstitution`.
|
||||
pub fn substitution(&self, value: &InputValue) -> String {
|
||||
match (self, value) {
|
||||
(Self::Text { .. }, InputValue::Text(s)) => escape(s),
|
||||
(
|
||||
Self::Boolean {
|
||||
on_true, on_false, ..
|
||||
},
|
||||
InputValue::Bool(b),
|
||||
) => if *b { on_true } else { on_false }.clone(),
|
||||
(Self::SingleOption { options, .. }, InputValue::Option(i)) => {
|
||||
options.get(*i).map(|o| o.0.clone()).unwrap_or_default()
|
||||
}
|
||||
(Self::NumberRange { .. }, InputValue::Slider(_)) => {
|
||||
number_string(self.scaled(value).unwrap_or(0.0))
|
||||
}
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
/// `ValueGetter.asTag`.
|
||||
pub fn tag(&self, value: &InputValue) -> Tag {
|
||||
match (self, value) {
|
||||
(Self::Boolean { .. }, InputValue::Bool(b)) => Tag::Byte(i8::from(*b)),
|
||||
(Self::NumberRange { .. }, _) => {
|
||||
Tag::Float(self.scaled(value).unwrap_or(0.0).to_bits())
|
||||
}
|
||||
(Self::Text { .. }, InputValue::Text(s)) => Tag::String(s.as_str().into()),
|
||||
_ => Tag::String(self.substitution(value).as_str().into()),
|
||||
}
|
||||
}
|
||||
/// `RangeInfo.computeScaledValue`: a number range's value at its slider position.
|
||||
pub fn scaled(&self, value: &InputValue) -> Option<f32> {
|
||||
let (
|
||||
Self::NumberRange {
|
||||
start,
|
||||
end,
|
||||
initial,
|
||||
step,
|
||||
..
|
||||
},
|
||||
InputValue::Slider(slider),
|
||||
) = (self, value)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let (start, end) = (*start, *end);
|
||||
let in_range = start + slider * (end - start);
|
||||
let Some(step) = step else {
|
||||
return Some(in_range);
|
||||
};
|
||||
let first = initial.unwrap_or((start + end) / 2.0);
|
||||
// Math.round: half up.
|
||||
let steps = ((in_range - first) / step + 0.5).floor() as i32;
|
||||
let result = first + steps as f32 * step;
|
||||
let position = to_slider(result, start, end);
|
||||
if (0.0..=1.0).contains(&position) {
|
||||
return Some(result);
|
||||
}
|
||||
Some(first + (steps - steps.signum()) as f32 * step)
|
||||
}
|
||||
}
|
||||
|
||||
/// `RangeInfo.scaledValueToSlider`.
|
||||
fn to_slider(value: f32, start: f32, end: f32) -> f32 {
|
||||
if start == end {
|
||||
0.5
|
||||
} else {
|
||||
(value - start) / (end - start)
|
||||
}
|
||||
}
|
||||
|
||||
/// `SliderImpl.valueToString`: whole numbers without a fraction, the rest as
|
||||
/// Java's `Float.toString`.
|
||||
pub fn number_string(v: f32) -> String {
|
||||
if v == (v as i32) as f32 {
|
||||
(v as i32).to_string()
|
||||
} else {
|
||||
crate::snbt::java_float(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl Body {
|
||||
/// `DialogBody.DIALOG_BODY_CODEC`.
|
||||
fn parse(t: &Tag) -> Option<Self> {
|
||||
let message = |t: &Tag| -> Option<(Text, i32)> {
|
||||
// PlainMessage.CODEC: the full form or just its contents.
|
||||
match t {
|
||||
Tag::Compound(_) if t.get("contents").is_some() => {
|
||||
Some((text(t.get("contents")?), int(t, "width", 200)))
|
||||
}
|
||||
t => Some((text(t), 200)),
|
||||
}
|
||||
};
|
||||
Some(match path(&string(t.get("type"))?) {
|
||||
"plain_message" => {
|
||||
let (contents, width) = message(t)?;
|
||||
Self::Message { contents, width }
|
||||
}
|
||||
"item" => Self::Item {
|
||||
item: t.get("item")?.clone(),
|
||||
description: t.get("description").and_then(message),
|
||||
show_decorations: boolean(t, "show_decorations", true),
|
||||
show_tooltip: boolean(t, "show_tooltip", true),
|
||||
width: int(t, "width", 16),
|
||||
height: int(t, "height", 16),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
/// `Dialog.DIRECT_CODEC`; `None` for a type or field it can't read
|
||||
/// (vanilla fails the whole packet then).
|
||||
pub fn parse(t: &Tag) -> Option<Self> {
|
||||
let button = |key: &str| t.get(key).and_then(Button::parse);
|
||||
let kind = match path(&string(t.get("type"))?) {
|
||||
"notice" => Kind::Notice(match t.get("action") {
|
||||
Some(b) => Button::parse(b)?,
|
||||
None => Button::ok(),
|
||||
}),
|
||||
"confirmation" => Kind::Confirmation {
|
||||
yes: button("yes")?,
|
||||
no: button("no")?,
|
||||
},
|
||||
"multi_action" => Kind::MultiAction {
|
||||
actions: list(t.get("actions"))
|
||||
.iter()
|
||||
.map(Button::parse)
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.filter(|a| !a.is_empty())?,
|
||||
exit: button("exit_action"),
|
||||
columns: int(t, "columns", 2),
|
||||
},
|
||||
"server_links" => Kind::ServerLinks {
|
||||
exit: button("exit_action"),
|
||||
columns: int(t, "columns", 2),
|
||||
button_width: int(t, "button_width", 150),
|
||||
},
|
||||
"dialog_list" => Kind::DialogList {
|
||||
dialogs: t.get("dialogs")?.clone(),
|
||||
exit: button("exit_action"),
|
||||
columns: int(t, "columns", 2),
|
||||
button_width: int(t, "button_width", 150),
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
// DialogBody.COMPACT_LIST_CODEC: one body, or a list of them.
|
||||
let body = match t.get("body") {
|
||||
Some(Tag::List(items)) => items.iter().filter_map(Body::parse).collect(),
|
||||
Some(one) => Body::parse(one).into_iter().collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let inputs = list(t.get("inputs"))
|
||||
.iter()
|
||||
.filter_map(|i| {
|
||||
Some(Input {
|
||||
key: string(i.get("key"))?,
|
||||
control: Control::parse(i)?,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Some(Self {
|
||||
kind,
|
||||
title: text(t.get("title")?),
|
||||
external_title: t.get("external_title").map(text),
|
||||
can_close_with_escape: boolean(t, "can_close_with_escape", true),
|
||||
pause: boolean(t, "pause", true),
|
||||
after_action: match string(t.get("after_action")).as_deref() {
|
||||
Some("none") => AfterAction::None,
|
||||
Some("wait_for_response") => AfterAction::WaitForResponse,
|
||||
_ => AfterAction::Close,
|
||||
},
|
||||
body,
|
||||
inputs,
|
||||
})
|
||||
}
|
||||
/// `onCancel`: what Escape (or closing it) does.
|
||||
pub fn on_cancel(&self) -> Option<&Action> {
|
||||
match &self.kind {
|
||||
Kind::Notice(b) => b.action.as_ref(),
|
||||
Kind::Confirmation { no, .. } => no.action.as_ref(),
|
||||
Kind::MultiAction { exit, .. }
|
||||
| Kind::ServerLinks { exit, .. }
|
||||
| Kind::DialogList { exit, .. } => exit.as_ref().and_then(|b| b.action.as_ref()),
|
||||
}
|
||||
}
|
||||
/// The buttons it draws, in order: the list, then the footer's.
|
||||
pub fn buttons(&self) -> Vec<&Button> {
|
||||
match &self.kind {
|
||||
Kind::Notice(b) => vec![b],
|
||||
Kind::Confirmation { yes, no } => vec![yes, no],
|
||||
Kind::MultiAction { actions, exit, .. } => actions.iter().chain(exit).collect(),
|
||||
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => exit.iter().collect(),
|
||||
}
|
||||
}
|
||||
/// What `status` reports of it.
|
||||
pub fn json(&self, values: &[InputValue]) -> Value {
|
||||
let kind = match &self.kind {
|
||||
Kind::Notice(_) => "notice",
|
||||
Kind::Confirmation { .. } => "confirmation",
|
||||
Kind::MultiAction { .. } => "multi_action",
|
||||
Kind::ServerLinks { .. } => "server_links",
|
||||
Kind::DialogList { .. } => "dialog_list",
|
||||
};
|
||||
let inputs: Vec<Value> = self
|
||||
.inputs
|
||||
.iter()
|
||||
.zip(values)
|
||||
.map(|(i, v)| {
|
||||
let kind = match i.control {
|
||||
Control::Text { .. } => "text",
|
||||
Control::Boolean { .. } => "boolean",
|
||||
Control::SingleOption { .. } => "single_option",
|
||||
Control::NumberRange { .. } => "number_range",
|
||||
};
|
||||
json!({"key": i.key, "type": kind, "value": i.control.substitution(v)})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"type": kind,
|
||||
"title": self.title.plain(),
|
||||
"body": self.body.iter().map(|b| match b {
|
||||
Body::Message { contents, .. } => contents.plain(),
|
||||
Body::Item { item, .. } => string(item.get("id")).unwrap_or_default(),
|
||||
}).collect::<Vec<_>>(),
|
||||
"inputs": inputs,
|
||||
"buttons": self.buttons().iter().map(|b| b.label.plain()).collect::<Vec<_>>(),
|
||||
"can_close_with_escape": self.can_close_with_escape,
|
||||
"after_action": match self.after_action {
|
||||
AfterAction::Close => "close",
|
||||
AfterAction::None => "none",
|
||||
AfterAction::WaitForResponse => "wait_for_response",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn s(v: &str) -> Tag {
|
||||
Tag::String(v.into())
|
||||
}
|
||||
|
||||
/// The register dialog AuthMe 6 sends in configuration (the shape its
|
||||
/// `show_dialog` packet has on the test bed).
|
||||
fn register() -> Tag {
|
||||
Tag::compound([
|
||||
("type", s("minecraft:multi_action")),
|
||||
("title", s("Register")),
|
||||
("can_close_with_escape", Tag::Byte(0)),
|
||||
("after_action", s("wait_for_response")),
|
||||
(
|
||||
"body",
|
||||
Tag::compound([
|
||||
("type", s("minecraft:plain_message")),
|
||||
("contents", s("Choose a password")),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"inputs",
|
||||
Tag::List(vec![Tag::compound([
|
||||
("type", s("minecraft:text")),
|
||||
("key", s("password")),
|
||||
("label", s("Password")),
|
||||
])]),
|
||||
),
|
||||
(
|
||||
"actions",
|
||||
Tag::List(vec![
|
||||
Tag::compound([
|
||||
("label", s("Register")),
|
||||
(
|
||||
"action",
|
||||
Tag::compound([
|
||||
("type", s("minecraft:dynamic/custom")),
|
||||
("id", s("authme:prejoin-register/submit")),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
Tag::compound([
|
||||
("label", s("Cancel")),
|
||||
(
|
||||
"action",
|
||||
Tag::compound([
|
||||
("type", s("minecraft:dynamic/run_command")),
|
||||
("template", s("say $(password) $(missing)!")),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_a_multi_action_dialog_with_defaults() {
|
||||
let d = Dialog::parse(®ister()).unwrap();
|
||||
assert_eq!(d.title.plain(), "Register");
|
||||
assert!(!d.can_close_with_escape);
|
||||
assert!(d.pause);
|
||||
assert_eq!(d.after_action, AfterAction::WaitForResponse);
|
||||
assert!(matches!(&d.body[..], [Body::Message { width: 200, .. }]));
|
||||
let Kind::MultiAction {
|
||||
actions,
|
||||
exit,
|
||||
columns,
|
||||
} = &d.kind
|
||||
else {
|
||||
panic!("{:?}", d.kind)
|
||||
};
|
||||
assert_eq!((actions.len(), exit.is_none(), *columns), (2, true, 2));
|
||||
assert_eq!(actions[0].width, 150);
|
||||
assert!(d.on_cancel().is_none());
|
||||
let [
|
||||
Input {
|
||||
key,
|
||||
control:
|
||||
Control::Text {
|
||||
max_length: 32,
|
||||
width: 200,
|
||||
..
|
||||
},
|
||||
},
|
||||
] = &d.inputs[..]
|
||||
else {
|
||||
panic!("{:?}", d.inputs)
|
||||
};
|
||||
assert_eq!(key, "password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actions_carry_the_input_values() {
|
||||
let d = Dialog::parse(®ister()).unwrap();
|
||||
let values = [InputValue::Text("p\"w".into())];
|
||||
let Kind::MultiAction { actions, .. } = &d.kind else {
|
||||
unreachable!()
|
||||
};
|
||||
let custom = actions[0]
|
||||
.action
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.click(&d.inputs, &values);
|
||||
assert_eq!(
|
||||
custom,
|
||||
Some(Click::Custom {
|
||||
id: "authme:prejoin-register/submit".into(),
|
||||
payload: Some(Tag::compound([("password", s("p\"w"))])),
|
||||
})
|
||||
);
|
||||
// The template escapes the text; a key no input has becomes nothing.
|
||||
let command = actions[1]
|
||||
.action
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.click(&d.inputs, &values);
|
||||
assert_eq!(command, Some(Click::RunCommand("say p\\\"w !".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notice_without_an_action_has_ok() {
|
||||
let d = Dialog::parse(&Tag::compound([("type", s("notice")), ("title", s("Hi"))])).unwrap();
|
||||
let Kind::Notice(b) = &d.kind else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(b.label, Text::translate("gui.ok", &[]));
|
||||
// Without a title, or with an unknown type, there is no dialog.
|
||||
assert!(Dialog::parse(&Tag::compound([("type", s("notice"))])).is_none());
|
||||
assert!(Dialog::parse(&Tag::compound([("type", s("x")), ("title", s("Hi"))])).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_ranges_snap_to_their_step() {
|
||||
let mut range = Control::NumberRange {
|
||||
width: 200,
|
||||
label: Text::literal("n"),
|
||||
label_format: "options.generic_value".into(),
|
||||
start: 0.0,
|
||||
end: 10.0,
|
||||
initial: Some(4.0),
|
||||
step: Some(3.0),
|
||||
};
|
||||
assert_eq!(range.initial(), InputValue::Slider(0.4));
|
||||
assert_eq!(range.scaled(&InputValue::Slider(0.62)), Some(7.0));
|
||||
assert_eq!(range.scaled(&InputValue::Slider(1.0)), Some(10.0));
|
||||
let Control::NumberRange { step, .. } = &mut range else {
|
||||
unreachable!()
|
||||
};
|
||||
*step = Some(4.0);
|
||||
// 12 is out of range: one step less.
|
||||
assert_eq!(range.scaled(&InputValue::Slider(1.0)), Some(8.0));
|
||||
assert_eq!(range.substitution(&InputValue::Slider(0.0)), "0");
|
||||
assert_eq!(number_string(2.5), "2.5");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
//! `DialogScreen` and its kinds (`SimpleDialogScreen`, `MultiButtonDialogScreen`):
|
||||
//! where a server dialog's title, body, inputs and buttons go in a
|
||||
//! `HeaderAndFooterLayout` (a 33 high header, the body in a `ScrollableLayout`,
|
||||
//! the footer), and the `WaitingForResponseScreen` an answer can leave open.
|
||||
use crate::{
|
||||
dialog::{Body, Control, Dialog, Kind},
|
||||
font::Font,
|
||||
text::Text,
|
||||
};
|
||||
|
||||
/// `HeaderAndFooterLayout.DEFAULT_HEADER_AND_FOOTER_HEIGHT`.
|
||||
const BAR: f32 = 33.0;
|
||||
/// `Checkbox.getBoxSize`.
|
||||
pub const BOX: f32 = 17.0;
|
||||
/// `DialogScreen.WARNING_BUTTON_SPRITES`: normal, disabled, highlighted.
|
||||
pub const WARNING_SPRITES: [&str; 3] = [
|
||||
"gui/sprites/dialog/warning_button",
|
||||
"gui/sprites/dialog/warning_button_disabled",
|
||||
"gui/sprites/dialog/warning_button_highlighted",
|
||||
];
|
||||
|
||||
/// One drawn piece; `y` is from the top of the body (before it scrolls) for
|
||||
/// the body's parts, from the top of the screen for the footer's.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Part {
|
||||
/// A line of text and where it starts.
|
||||
Line(Text, f32, f32),
|
||||
/// A text input's box.
|
||||
Field { input: usize, rect: [f32; 4] },
|
||||
/// A boolean input: the box, its label right of it.
|
||||
Check { input: usize, rect: [f32; 4] },
|
||||
/// A button of `Dialog::buttons`.
|
||||
Button { index: usize, rect: [f32; 4] },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Layout {
|
||||
/// The title and where it is drawn.
|
||||
pub title: (f32, f32),
|
||||
pub warning: [f32; 4],
|
||||
/// The body's visible area (`ScrollableLayout`'s container): x, y, w, h.
|
||||
pub area: [f32; 4],
|
||||
/// The body's full height: more than the area's scrolls.
|
||||
pub content: f32,
|
||||
pub body: Vec<Part>,
|
||||
pub footer: Vec<Part>,
|
||||
}
|
||||
|
||||
/// A laid-out element of a `LinearLayout` before it is placed.
|
||||
struct Element {
|
||||
w: f32,
|
||||
h: f32,
|
||||
parts: Vec<Part>,
|
||||
}
|
||||
impl Element {
|
||||
/// Its parts moved to `(x, y)`.
|
||||
fn at(self, x: f32, y: f32) -> impl Iterator<Item = Part> {
|
||||
self.parts.into_iter().map(move |p| match p {
|
||||
Part::Line(t, px, py) => Part::Line(t, px + x, py + y),
|
||||
Part::Field { input, rect } => Part::Field {
|
||||
input,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
Part::Check { input, rect } => Part::Check {
|
||||
input,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
Part::Button { index, rect } => Part::Button {
|
||||
index,
|
||||
rect: shift(rect, x, y),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
fn shift([x, y, w, h]: [f32; 4], dx: f32, dy: f32) -> [f32; 4] {
|
||||
[x + dx, y + dy, w, h]
|
||||
}
|
||||
/// `FrameLayout.alignInDimension` at 0.5: the offset that centres `inner`.
|
||||
fn centre(outer: f32, inner: f32) -> f32 {
|
||||
((outer - inner) * 0.5).trunc()
|
||||
}
|
||||
|
||||
/// `FocusableTextWidget` with the body's settings: `width` wide, the text
|
||||
/// wrapped to `width - 8` and centred, 4 of padding all round.
|
||||
fn message(font: &Font, contents: &Text, width: f32) -> Element {
|
||||
let lines = font.split_lines(contents, width - 8.0);
|
||||
let mid = (width / 2.0).trunc();
|
||||
let parts = lines
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (line, _))| {
|
||||
let x = mid - (font.width_text(&line) / 2.0).trunc();
|
||||
Part::Line(line, x, 4.0 + 9.0 * i as f32)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Element {
|
||||
w: width,
|
||||
h: 9.0 * parts.len() as f32 + 8.0,
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
/// `InputControlHandlers`: the control for input `i`, if Horizon has it.
|
||||
fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
|
||||
Some(match control {
|
||||
Control::Text {
|
||||
width,
|
||||
label,
|
||||
label_visible,
|
||||
..
|
||||
} => {
|
||||
let w = *width as f32;
|
||||
if !label_visible {
|
||||
return Some(Element {
|
||||
w,
|
||||
h: 20.0,
|
||||
parts: vec![Part::Field {
|
||||
input: i,
|
||||
rect: [0.0, 0.0, w, 20.0],
|
||||
}],
|
||||
});
|
||||
}
|
||||
// CommonLayouts.labeledElement: the label, 4 below it the box,
|
||||
// both on the left.
|
||||
Element {
|
||||
w: w.max(font.width_text(label)),
|
||||
h: 9.0 + 4.0 + 20.0,
|
||||
parts: vec![
|
||||
Part::Line(label.clone(), 0.0, 0.0),
|
||||
Part::Field {
|
||||
input: i,
|
||||
rect: [0.0, 13.0, w, 20.0],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
Control::Boolean { label, .. } => {
|
||||
let w = BOX + 4.0 + font.width_text(label);
|
||||
Element {
|
||||
w,
|
||||
h: BOX,
|
||||
parts: vec![Part::Check {
|
||||
input: i,
|
||||
rect: [0.0, 0.0, w, BOX],
|
||||
}],
|
||||
}
|
||||
}
|
||||
Control::SingleOption { .. } | Control::NumberRange { .. } => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `DialogScreen.packControlsIntoColumns`: full rows in a grid 2 apart, what
|
||||
/// is left in a centred row under them.
|
||||
fn columns(widths: &[f32], columns: usize, first: usize) -> Element {
|
||||
let columns = columns.max(1);
|
||||
let full = widths.len() / columns * columns;
|
||||
let row_width = |ws: &[f32]| ws.iter().sum::<f32>() + 2.0 * (ws.len().max(1) - 1) as f32;
|
||||
let mut col = vec![0.0f32; columns];
|
||||
for (i, w) in widths[..full].iter().enumerate() {
|
||||
col[i % columns] = col[i % columns].max(*w);
|
||||
}
|
||||
let last = &widths[full..];
|
||||
if !last.is_empty() {
|
||||
// GridLayout spreads a cell over every column it spans (Divisor).
|
||||
let spread = row_width(last) - 2.0 * (columns - 1) as f32;
|
||||
let (each, extra) = ((spread / columns as f32).floor(), spread % columns as f32);
|
||||
for (c, w) in col.iter_mut().enumerate() {
|
||||
*w = w.max(each + f32::from(u8::from((c as f32) < extra)));
|
||||
}
|
||||
}
|
||||
let x_of = |c: usize| col[..c].iter().sum::<f32>() + 2.0 * c as f32;
|
||||
let mut parts = Vec::new();
|
||||
for (i, w) in widths[..full].iter().enumerate() {
|
||||
let (r, c) = (i / columns, i % columns);
|
||||
parts.push(Part::Button {
|
||||
index: first + i,
|
||||
rect: [x_of(c) + centre(col[c], *w), 22.0 * r as f32, *w, 20.0],
|
||||
});
|
||||
}
|
||||
let width = x_of(columns - 1) + col[columns - 1];
|
||||
if !last.is_empty() {
|
||||
let y = 22.0 * (full / columns) as f32;
|
||||
let mut x = centre(width, row_width(last));
|
||||
for (i, w) in last.iter().enumerate() {
|
||||
parts.push(Part::Button {
|
||||
index: first + full + i,
|
||||
rect: [x, y, *w, 20.0],
|
||||
});
|
||||
x += w + 2.0;
|
||||
}
|
||||
}
|
||||
let rows = full / columns + usize::from(!last.is_empty());
|
||||
Element {
|
||||
w: width,
|
||||
h: (22.0 * rows as f32 - 2.0).max(0.0),
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// `DialogScreen.init` and `repositionElements` on a `w` by `h` screen.
|
||||
pub fn new(dialog: &Dialog, font: &Font, (w, h): (f32, f32)) -> Self {
|
||||
// The header: the title, 10 right of it the warning button, both
|
||||
// centred together.
|
||||
let tw = font.width_text(&dialog.title);
|
||||
let (hx, hy) = (centre(w, tw + 30.0), centre(BAR, 20.0));
|
||||
let title = (hx, hy + centre(20.0, 9.0));
|
||||
let warning = [hx + tw + 10.0, hy, 20.0, 20.0];
|
||||
|
||||
let mut elements: Vec<Element> = dialog
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
Body::Message { contents, width } => Some(message(font, contents, *width as f32)),
|
||||
Body::Item { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
elements.extend(
|
||||
dialog
|
||||
.inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, input_)| input(font, i, &input_.control)),
|
||||
);
|
||||
let buttons = dialog.buttons();
|
||||
let (listed, footer_buttons, footer_h) = match &dialog.kind {
|
||||
Kind::MultiAction {
|
||||
actions,
|
||||
exit,
|
||||
columns: n,
|
||||
} => {
|
||||
let widths: Vec<f32> = actions.iter().map(|b| b.width as f32).collect();
|
||||
elements.push(columns(&widths, *n as usize, 0));
|
||||
// ButtonListDialogScreen: the exit button in the footer, else a 5 high one.
|
||||
let footer = exit.as_ref().map(|_| actions.len()).into_iter().collect();
|
||||
(
|
||||
actions.len(),
|
||||
footer,
|
||||
if exit.is_some() { BAR } else { 5.0 },
|
||||
)
|
||||
}
|
||||
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => {
|
||||
let footer = exit.as_ref().map(|_| 0).into_iter().collect();
|
||||
(0, footer, if exit.is_some() { BAR } else { 5.0 })
|
||||
}
|
||||
// SimpleDialogScreen: the buttons side by side, 8 apart.
|
||||
Kind::Notice(_) | Kind::Confirmation { .. } => {
|
||||
(0, (0..buttons.len()).collect::<Vec<_>>(), BAR)
|
||||
}
|
||||
};
|
||||
debug_assert!(listed + footer_buttons.len() <= buttons.len());
|
||||
|
||||
// The body: a column 10 apart, each element centred in it.
|
||||
let body_w = elements.iter().map(|e| e.w).fold(0.0, f32::max);
|
||||
let content = elements.iter().map(|e| e.h).sum::<f32>()
|
||||
+ 10.0 * elements.len().saturating_sub(1) as f32;
|
||||
// ScrollableLayout: 10 each side for the scrollbar (ReserveStrategy.BOTH),
|
||||
// no higher than what the header and footer leave.
|
||||
let area_w = body_w + 20.0;
|
||||
let area_h = content.min(h - BAR - footer_h).max(0.0);
|
||||
let area_y = (BAR + 30.0).min(h - footer_h - area_h);
|
||||
let area_x = centre(w, area_w);
|
||||
let mut body = Vec::new();
|
||||
let mut y = 0.0;
|
||||
for e in elements {
|
||||
let (x, eh) = (area_x + 10.0 + centre(body_w, e.w), e.h);
|
||||
body.extend(e.at(x, y));
|
||||
y += eh + 10.0;
|
||||
}
|
||||
|
||||
let widths: Vec<f32> = footer_buttons
|
||||
.iter()
|
||||
.map(|&i| buttons[i].width as f32)
|
||||
.collect();
|
||||
let row = widths.iter().sum::<f32>() + 8.0 * widths.len().saturating_sub(1) as f32;
|
||||
let (mut x, fy) = (centre(w, row), h - footer_h + centre(footer_h, 20.0));
|
||||
let mut footer = Vec::new();
|
||||
for (&i, bw) in footer_buttons.iter().zip(&widths) {
|
||||
footer.push(Part::Button {
|
||||
index: i,
|
||||
rect: [x, fy, *bw, 20.0],
|
||||
});
|
||||
x += bw + 8.0;
|
||||
}
|
||||
Self {
|
||||
title,
|
||||
warning: Self::in_bounds(warning, w, h),
|
||||
area: [area_x, area_y, area_w, area_h],
|
||||
content,
|
||||
body,
|
||||
footer,
|
||||
}
|
||||
}
|
||||
/// `makeSureWarningButtonIsInBounds`.
|
||||
fn in_bounds(r: [f32; 4], w: f32, h: f32) -> [f32; 4] {
|
||||
if r[0] < 0.0 || r[1] < 0.0 || r[0] > w - 20.0 || r[1] > h - 20.0 {
|
||||
[(w - 40.0).max(0.0), 5.0f32.min(h), 20.0, 20.0]
|
||||
} else {
|
||||
r
|
||||
}
|
||||
}
|
||||
/// Every part where it is drawn with the body scrolled by `scroll`, and
|
||||
/// whether it is in the body (clipped to its area).
|
||||
pub fn placed(&self, scroll: f32) -> impl Iterator<Item = (Part, bool)> + '_ {
|
||||
let dy = self.area[1] - scroll;
|
||||
let body = self.body.iter().map(move |p| {
|
||||
let p = match p.clone() {
|
||||
Part::Line(t, x, y) => Part::Line(t, x, y + dy),
|
||||
Part::Field { input, rect } => Part::Field {
|
||||
input,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
Part::Check { input, rect } => Part::Check {
|
||||
input,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
Part::Button { index, rect } => Part::Button {
|
||||
index,
|
||||
rect: shift(rect, 0.0, dy),
|
||||
},
|
||||
};
|
||||
(p, true)
|
||||
});
|
||||
body.chain(self.footer.iter().map(|p| (p.clone(), false)))
|
||||
}
|
||||
}
|
||||
|
||||
/// `WaitingForResponseScreen`: its Back button's label after `ticks`, and
|
||||
/// whether it shows and works (it shows after a second, works after five).
|
||||
pub fn waiting_button(ticks: u32) -> (Text, bool, bool) {
|
||||
let seconds = (ticks / 20).min(5);
|
||||
let label = match seconds {
|
||||
0 => Text::literal(""),
|
||||
5 => Text::translate("gui.back", &[]),
|
||||
s => Text::translate(
|
||||
"gui.waitingForResponse.button.inactive",
|
||||
&[Text::literal((5 - s).to_string())],
|
||||
),
|
||||
};
|
||||
(label, seconds >= 1, seconds == 5)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grids_pack_full_rows_and_centre_the_rest() {
|
||||
// AuthMe's two buttons: one row, 2 apart.
|
||||
let two = columns(&[150.0, 150.0], 2, 0);
|
||||
assert_eq!((two.w, two.h), (302.0, 20.0));
|
||||
// Three in two columns: the third alone, centred under them.
|
||||
let three = columns(&[150.0, 150.0, 150.0], 2, 0);
|
||||
assert_eq!((three.w, three.h), (302.0, 42.0));
|
||||
assert_eq!(
|
||||
three.parts[2],
|
||||
Part::Button {
|
||||
index: 2,
|
||||
rect: [76.0, 22.0, 150.0, 20.0]
|
||||
}
|
||||
);
|
||||
// One in two columns spreads over both.
|
||||
let one = columns(&[100.0], 2, 0);
|
||||
assert_eq!(one.w, 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_waiting_screens_button_counts_down() {
|
||||
assert!(!waiting_button(0).1);
|
||||
assert_eq!(
|
||||
waiting_button(20).0,
|
||||
Text::translate(
|
||||
"gui.waitingForResponse.button.inactive",
|
||||
&[Text::literal("4")]
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
waiting_button(100),
|
||||
(Text::translate("gui.back", &[]), true, true)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,11 @@ impl Cost {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Enchantment {
|
||||
pub id: Arc<str>,
|
||||
/// The name the pinned English map gave at load: the fallback for a
|
||||
/// language that has no entry for the key below.
|
||||
pub description: String,
|
||||
/// `description`'s translation key (`enchantment.minecraft.<name>`).
|
||||
pub description_key: String,
|
||||
pub max_level: u32,
|
||||
pub weight: u32,
|
||||
pub anvil_cost: u32,
|
||||
@@ -224,6 +228,7 @@ impl Enchantments {
|
||||
.get(key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| id.trim_start_matches("minecraft:").replace('_', " ")),
|
||||
description_key: key.to_owned(),
|
||||
max_level: v["max_level"].as_u64().unwrap_or(1) as u32,
|
||||
weight: v["weight"].as_u64().unwrap_or(1) as u32,
|
||||
anvil_cost: v["anvil_cost"].as_u64().unwrap_or(0) as u32,
|
||||
@@ -329,16 +334,26 @@ impl Enchantments {
|
||||
}
|
||||
/// `Enchantment.getFullname` as plain text and whether it is a curse (red).
|
||||
pub fn full_name(&self, id: &str, level: u32) -> (String, bool) {
|
||||
let language = crate::lang::current();
|
||||
self.full_name_in(language.as_deref(), id, level)
|
||||
}
|
||||
/// `full_name` against `language`; `None` (no language loaded: a
|
||||
/// dedicated server, the probes) keeps the English names loaded above.
|
||||
pub fn full_name_in(
|
||||
&self,
|
||||
language: Option<&crate::lang::Language>,
|
||||
id: &str,
|
||||
level: u32,
|
||||
) -> (String, bool) {
|
||||
let tr = |key: &str| language.and_then(|l| l.get(key).map(str::to_owned));
|
||||
let Some(e) = self.get(id) else {
|
||||
return (format!("{id} {}", level_name(level)), false);
|
||||
};
|
||||
let mut name = e.description.clone();
|
||||
let mut name = tr(&e.description_key).unwrap_or_else(|| e.description.clone());
|
||||
if level != 1 || e.max_level != 1 {
|
||||
name.push(' ');
|
||||
name.push_str(
|
||||
self.lang
|
||||
.get(&format!("enchantment.level.{level}"))
|
||||
.map_or(&level_name(level), |s| s),
|
||||
&tr(&format!("enchantment.level.{level}")).unwrap_or_else(|| level_name(level)),
|
||||
);
|
||||
}
|
||||
(name, self.is_curse(id))
|
||||
@@ -771,7 +786,7 @@ pub(crate) mod tests {
|
||||
let defs = [
|
||||
(
|
||||
"minecraft:sharpness",
|
||||
json!({"anvil_cost":1,"exclusive_set":"#minecraft:exclusive_set/damage","max_cost":{"base":21,"per_level_above_first":11},"max_level":5,"min_cost":{"base":1,"per_level_above_first":11},"primary_items":"#minecraft:enchantable/melee_weapon","supported_items":"#minecraft:enchantable/sharp_weapon","weight":10,"slots":["mainhand"],"effects":{"minecraft:damage":[{"effect":{"type":"minecraft:add","value":{"type":"minecraft:linear","base":1.0,"per_level_above_first":0.5}}}]}}),
|
||||
json!({"anvil_cost":1,"description":{"translate":"enchantment.minecraft.sharpness"},"exclusive_set":"#minecraft:exclusive_set/damage","max_cost":{"base":21,"per_level_above_first":11},"max_level":5,"min_cost":{"base":1,"per_level_above_first":11},"primary_items":"#minecraft:enchantable/melee_weapon","supported_items":"#minecraft:enchantable/sharp_weapon","weight":10,"slots":["mainhand"],"effects":{"minecraft:damage":[{"effect":{"type":"minecraft:add","value":{"type":"minecraft:linear","base":1.0,"per_level_above_first":0.5}}}]}}),
|
||||
),
|
||||
(
|
||||
"minecraft:smite",
|
||||
@@ -795,6 +810,7 @@ pub(crate) mod tests {
|
||||
.map(|(id, v)| Enchantment {
|
||||
id: Arc::from(*id),
|
||||
description: id.trim_start_matches("minecraft:").into(),
|
||||
description_key: v["description"]["translate"].as_str().unwrap_or("").into(),
|
||||
max_level: v["max_level"].as_u64().unwrap() as u32,
|
||||
weight: v["weight"].as_u64().unwrap() as u32,
|
||||
anvil_cost: v["anvil_cost"].as_u64().unwrap() as u32,
|
||||
@@ -1088,4 +1104,33 @@ pub(crate) mod tests {
|
||||
assert!(!e.compatible("minecraft:sharpness", "minecraft:smite"));
|
||||
assert!(e.compatible("minecraft:sharpness", "minecraft:unbreaking"));
|
||||
}
|
||||
|
||||
/// The enchantment's name and its level come from the selected language,
|
||||
/// with the English name loaded above as the fallback.
|
||||
#[test]
|
||||
fn names_follow_the_language() {
|
||||
struct Mem(HashMap<String, Vec<u8>>);
|
||||
impl crate::lang::ResourceSource for Mem {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
self.0.get(path).cloned()
|
||||
}
|
||||
}
|
||||
let ru = Mem(HashMap::from([(
|
||||
"assets/minecraft/lang/ru_ru.json".to_owned(),
|
||||
r#"{"enchantment.minecraft.sharpness":"Острота","enchantment.level.5":"V"}"#
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
)]));
|
||||
let ru = crate::lang::Language::load(&[&ru], "ru_ru");
|
||||
let e = sample();
|
||||
assert_eq!(
|
||||
e.full_name_in(Some(&ru), "minecraft:sharpness", 5).0,
|
||||
"Острота V"
|
||||
);
|
||||
// mending carries no key in the fixture: the English name stays.
|
||||
assert_eq!(
|
||||
e.full_name_in(Some(&ru), "minecraft:mending", 1).0,
|
||||
"mending"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ use crate::{
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_world::block_collision::BlockCollisionRegistry;
|
||||
|
||||
mod fluids;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ItemEntity {
|
||||
pub health: i32,
|
||||
@@ -165,18 +167,16 @@ impl ItemEntity {
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
no_physics: bool,
|
||||
fluids: &crate::motion::FluidResources,
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
if self.pickup_delay > 0 && self.pickup_delay != u32::MAX {
|
||||
self.pickup_delay -= 1;
|
||||
}
|
||||
let eye = world.block(
|
||||
self.position.x.floor() as i32,
|
||||
(self.position.y + 0.1).floor() as i32,
|
||||
self.position.z.floor() as i32,
|
||||
);
|
||||
let in_water = facts.get(eye).fluid == Fluid::Water;
|
||||
// Entity.baseTick pushes before ItemEntity applies buoyancy and moves.
|
||||
let (water_height, lava_height) = self.update_fluids(world, fluids);
|
||||
let in_water = water_height > f64::from(0.1f32);
|
||||
if in_water {
|
||||
// ItemEntity.setUnderwaterMovement
|
||||
self.velocity = Vec3::new(
|
||||
@@ -184,7 +184,7 @@ impl ItemEntity {
|
||||
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
|
||||
self.velocity.z * 0.99,
|
||||
);
|
||||
} else if facts.get(eye).fluid == Fluid::Lava {
|
||||
} else if lava_height > f64::from(0.1f32) {
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x * 0.95,
|
||||
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
|
||||
@@ -245,6 +245,8 @@ impl ItemEntity {
|
||||
}
|
||||
}
|
||||
self.age += 1;
|
||||
// ItemEntity.tick refreshes immersion and pushes again after moving.
|
||||
self.update_fluids(world, fluids);
|
||||
self.age < 6000 && self.position.y > -128.0
|
||||
}
|
||||
/// `ItemEntity.isMergable` at `age`: a stack below its size whose pickup
|
||||
@@ -589,6 +591,57 @@ impl Painting {
|
||||
)
|
||||
}
|
||||
}
|
||||
/// The `DyeColor` ordinal of a colour name (`DyeColor.byName`), white when
|
||||
/// the name is unknown.
|
||||
pub fn dye_index(name: &str) -> u8 {
|
||||
crate::mobs::render::DYE_NAMES
|
||||
.iter()
|
||||
.position(|n| *n == name)
|
||||
.unwrap_or(0) as u8
|
||||
}
|
||||
/// A cushion (`Cushion`, a `BlockAttachedEntity`): a 1 x 0.25 block pillow
|
||||
/// that seats a player. It sits on the top face of a block, at the clicked
|
||||
/// height, and is coloured by its `DyeColor`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Cushion {
|
||||
pub id: u32,
|
||||
/// `Entity.position`: the bottom centre of the cushion.
|
||||
pub position: Vec3,
|
||||
/// `BlockAttachedEntity.pos` (the saved `block_pos`).
|
||||
pub pos: [i32; 3],
|
||||
pub yaw: f32,
|
||||
/// `DyeColor` ordinal: white 0, orange 1, ..., black 15.
|
||||
pub color: u8,
|
||||
/// BlockAttachedEntity.ticksSinceLastCheck.
|
||||
pub check_interval: u32,
|
||||
}
|
||||
impl Cushion {
|
||||
/// `Entity.makeBoundingBox`: x and z half a block out, y to y + 0.25.
|
||||
pub fn bounds(&self) -> ([f64; 3], [f64; 3]) {
|
||||
let p = self.position;
|
||||
(
|
||||
[p.x - 0.5, p.y, p.z - 0.5],
|
||||
[p.x + 0.5, p.y + 0.25, p.z + 0.5],
|
||||
)
|
||||
}
|
||||
/// The `DyeColor` name of the cushion.
|
||||
pub fn color_name(&self) -> &'static str {
|
||||
crate::mobs::render::DYE_NAMES[usize::from(self.color.min(15))]
|
||||
}
|
||||
/// `Cushion.getCushionItemStackWithData`: the matching coloured item.
|
||||
pub fn item(&self) -> String {
|
||||
format!("minecraft:{}_cushion", self.color_name())
|
||||
}
|
||||
/// `Cushion.interact`'s seat: the vehicle's PASSENGER attachment
|
||||
/// `(0, 0.25, 0)` less the player's `(0, 0.6, 0)`.
|
||||
pub fn seat(&self) -> Vec3 {
|
||||
Vec3::new(self.position.x, self.position.y - 0.35, self.position.z)
|
||||
}
|
||||
/// `Entity.getDismountLocationForPassenger`: on top of the cushion.
|
||||
pub fn dismount(&self) -> Vec3 {
|
||||
Vec3::new(self.position.x, self.position.y + 0.25, self.position.z)
|
||||
}
|
||||
}
|
||||
/// An item frame or glow item frame (`ItemFrame`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ItemFrame {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Dropped-item immersion and currents through the shared vanilla fluid helpers.
|
||||
use super::ItemEntity;
|
||||
use crate::{
|
||||
motion::{FluidResources, FluidTags, LAVA, WATER},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Direction, Vec3},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::{
|
||||
entity_fluid::{
|
||||
CurrentTarget, EntityFluidInteraction, FluidInteractionWorld, FluidMembership,
|
||||
FluidSections, FluidTagId,
|
||||
},
|
||||
fluid::{FluidHeightWorld, FluidKind, FluidState},
|
||||
fluid_flow::{FluidFlowWorld, flow_at},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
type Error = std::convert::Infallible;
|
||||
struct ItemFluidWorld<'a> {
|
||||
item: &'a mut ItemEntity,
|
||||
world: &'a World,
|
||||
fluids: &'a FluidResources,
|
||||
}
|
||||
impl ItemEntity {
|
||||
pub(super) fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (f64, f64) {
|
||||
let mut interaction = EntityFluidInteraction::new([WATER, LAVA]);
|
||||
let mut scene = ItemFluidWorld {
|
||||
item: self,
|
||||
world,
|
||||
fluids,
|
||||
};
|
||||
if interaction.update(&mut scene, false, 4096).is_err() {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let water_height = interaction.fluid_height(&scene, WATER);
|
||||
let lava_height = interaction.fluid_height(&scene, LAVA);
|
||||
let _ = interaction.apply_current(&mut scene, WATER, 0.014);
|
||||
let _ = interaction.apply_current(
|
||||
&mut scene,
|
||||
LAVA,
|
||||
if world.fast_lava {
|
||||
0.007
|
||||
} else {
|
||||
0.0023333333333333335
|
||||
},
|
||||
);
|
||||
(water_height, lava_height)
|
||||
}
|
||||
}
|
||||
impl FluidMembership for ItemFluidWorld<'_> {
|
||||
fn fluid_in_tag(&self, kind: FluidKind, tag: FluidTagId) -> bool {
|
||||
FluidTags.fluid_in_tag(kind, tag)
|
||||
}
|
||||
}
|
||||
impl CurrentTarget for ItemFluidWorld<'_> {
|
||||
type Error = Error;
|
||||
fn is_player_class(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn velocity(&mut self) -> Result<Vec3, Error> {
|
||||
Ok(self.item.velocity)
|
||||
}
|
||||
fn add_delta_movement(&mut self, delta: Vec3) -> Result<(), Error> {
|
||||
let v = self.item.velocity;
|
||||
let sum = Vec3::new(v.x + delta.x, v.y + delta.y, v.z + delta.z);
|
||||
if delta.is_finite() && sum.is_finite() {
|
||||
self.item.velocity = sum;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl FluidInteractionWorld for ItemFluidWorld<'_> {
|
||||
type Error = Error;
|
||||
type Chunk = ();
|
||||
fn interaction_box(&mut self) -> Result<Option<Aabb>, Error> {
|
||||
Ok(Some(
|
||||
self.item.bounds().deflate(Vec3::new(0.001, 0.001, 0.001)),
|
||||
))
|
||||
}
|
||||
fn loaded_chunk(&mut self, x: i32, z: i32) -> Result<Option<()>, Error> {
|
||||
Ok(self.world.chunks.contains_key(&(x, z)).then_some(()))
|
||||
}
|
||||
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
|
||||
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
|
||||
Ok(FluidSections {
|
||||
minimum_section_y: crate::chunk::MIN_Y >> 4,
|
||||
has_fluid: &SECTIONS,
|
||||
})
|
||||
}
|
||||
fn bounding_box_min_y(&self) -> f64 {
|
||||
self.item.position.y
|
||||
}
|
||||
fn block_x(&self) -> i32 {
|
||||
self.item.position.x.floor() as i32
|
||||
}
|
||||
fn eye_y(&mut self) -> Result<f64, Error> {
|
||||
Ok(self.item.position.y + f64::from(0.2125f32))
|
||||
}
|
||||
fn block_z(&self) -> i32 {
|
||||
self.item.position.z.floor() as i32
|
||||
}
|
||||
fn fluid_state(&mut self, p: BlockPos) -> Result<FluidState, Error> {
|
||||
Ok(self.fluids.fluid(self.world.block(p.x, p.y, p.z)))
|
||||
}
|
||||
fn fluid_height(&mut self, f: FluidState, p: BlockPos) -> Result<f32, Error> {
|
||||
f.height_at(self, [p.x, p.y, p.z])
|
||||
}
|
||||
fn camera_height(&mut self, f: FluidState, p: BlockPos) -> Result<f32, Error> {
|
||||
f.camera_height_at(self, [p.x, p.y, p.z])
|
||||
}
|
||||
fn flow(&mut self, f: FluidState, p: BlockPos) -> Result<Vec3, Error> {
|
||||
flow_at(self, f, p)
|
||||
}
|
||||
}
|
||||
impl FluidHeightWorld for ItemFluidWorld<'_> {
|
||||
type Error = Error;
|
||||
fn fluid(&mut self, p: [i32; 3]) -> Result<FluidState, Error> {
|
||||
Ok(self.fluids.fluid(self.world.block(p[0], p[1], p[2])))
|
||||
}
|
||||
fn sturdy_down(&mut self, p: [i32; 3]) -> Result<bool, Error> {
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
Ok(self.fluids.sturdy(state, Direction::Down))
|
||||
}
|
||||
}
|
||||
impl FluidFlowWorld for ItemFluidWorld<'_> {
|
||||
type Error = Error;
|
||||
type Block = BlockStateId;
|
||||
fn fluid(&mut self, p: BlockPos) -> Result<FluidState, Error> {
|
||||
Ok(self.fluids.fluid(self.world.block(p.x, p.y, p.z)))
|
||||
}
|
||||
fn block(&mut self, p: BlockPos) -> Result<BlockStateId, Error> {
|
||||
Ok(self.world.block(p.x, p.y, p.z))
|
||||
}
|
||||
fn blocks_fluid_flow(&self, s: BlockStateId) -> bool {
|
||||
self.fluids.blocks_flow(s)
|
||||
}
|
||||
fn is_ice_class(&self, s: BlockStateId) -> bool {
|
||||
self.fluids.ice(s)
|
||||
}
|
||||
fn is_face_sturdy(
|
||||
&mut self,
|
||||
s: BlockStateId,
|
||||
_: BlockPos,
|
||||
d: Direction,
|
||||
) -> Result<bool, Error> {
|
||||
Ok(self.fluids.sturdy(s, d))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn items_follow_flowing_water_but_still_water_has_no_horizontal_push() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let stone = assets.registry.default_state("minecraft:stone").unwrap();
|
||||
let water = assets.registry.default_state("minecraft:water").unwrap();
|
||||
let mut state = assets.registry.state(water).unwrap().clone();
|
||||
state.properties.insert("level".into(), "1".into());
|
||||
let flowing = assets.registry.resolve(&state).unwrap();
|
||||
for x in 19..=21 {
|
||||
for z in 19..=21 {
|
||||
world.set(x, 3, z, stone).unwrap();
|
||||
world.set(x, 4, z, water).unwrap();
|
||||
}
|
||||
}
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
let item = ItemEntity {
|
||||
id: 1,
|
||||
health: 5,
|
||||
fire_ticks: 0,
|
||||
stack: crate::items::ItemStack {
|
||||
item: "minecraft:sugar_cane".into(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
},
|
||||
thrower: None,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
ticks: 0,
|
||||
};
|
||||
let mut still = item.clone();
|
||||
assert!(still.tick(&world, &facts, &collision, false, &fluids));
|
||||
assert_eq!(still.position.x, at.x);
|
||||
assert_eq!(still.velocity.x, 0.0);
|
||||
world.set(21, 4, 20, flowing).unwrap();
|
||||
let mut carried = item;
|
||||
assert!(carried.tick(&world, &facts, &collision, false, &fluids));
|
||||
// First push is damped by buoyancy before motion, second follows drag.
|
||||
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
|
||||
assert!((carried.velocity.x - (0.014 * 0.99 * 0.98 + 0.014)).abs() < 1e-12);
|
||||
for _ in 0..20 {
|
||||
carried.tick(&world, &facts, &collision, false, &fluids);
|
||||
}
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,20 @@ impl Culler {
|
||||
vertices: &mut Vec<crate::mesher::Vertex>,
|
||||
start: usize,
|
||||
opaque: impl FnMut([i32; 3]) -> bool,
|
||||
) -> bool {
|
||||
let hidden = self.hidden(vertices, start, opaque);
|
||||
if hidden {
|
||||
vertices.truncate(start);
|
||||
}
|
||||
hidden
|
||||
}
|
||||
|
||||
/// Whether this entity's completed camera-relative mesh (`vertices[start..]`) is hidden.
|
||||
pub fn hidden(
|
||||
&self,
|
||||
vertices: &[crate::mesher::Vertex],
|
||||
start: usize,
|
||||
opaque: impl FnMut([i32; 3]) -> bool,
|
||||
) -> bool {
|
||||
if start >= vertices.len() || self.remaining.get() == 0 {
|
||||
return false;
|
||||
@@ -42,11 +56,7 @@ impl Culler {
|
||||
}
|
||||
}
|
||||
// Cover floating-point and rasterization edges as well as the vertices.
|
||||
if self.occluded(min.map(|v| v - 0.05), max.map(|v| v + 0.05), opaque) {
|
||||
vertices.truncate(start);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
self.occluded(min.map(|v| v - 0.05), max.map(|v| v + 0.05), opaque)
|
||||
}
|
||||
|
||||
/// Returns true only when a complete opaque block plane covers the bounds.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! survive a Horizon save.
|
||||
use crate::{
|
||||
arrows::Pickup,
|
||||
entities::{ArmorStand, ExperienceOrb, ItemEntity, ItemFrame, Painting},
|
||||
entities::{ArmorStand, Cushion, ExperienceOrb, ItemEntity, ItemFrame, Painting},
|
||||
mobs::{Mob, MobType, Who},
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
save::{
|
||||
@@ -1313,6 +1313,79 @@ pub fn painting_from_nbt(tag: &Tag) -> Option<([i32; 3], u8, String)> {
|
||||
let facing = (number(tag.get("facing")).unwrap_or(0) & 3) as u8;
|
||||
Some((pos, facing, text(tag.get("variant"))?))
|
||||
}
|
||||
/// Cushion.addAdditionalSaveData: the block it sits on and its colour.
|
||||
pub fn cushion_to_nbt(c: &Cushion, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let base = Base {
|
||||
id: "minecraft:cushion",
|
||||
uuid,
|
||||
position: c.position,
|
||||
motion: Vec3::ZERO,
|
||||
rotation: [c.yaw, 0.0],
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
put(&mut tag, "block_pos", Tag::IntArray(c.pos.to_vec()));
|
||||
put(&mut tag, "color", string(c.color_name()));
|
||||
tag
|
||||
}
|
||||
/// The position, block position, yaw and colour of a saved cushion.
|
||||
pub fn cushion_from_nbt(tag: &Tag, id: u32) -> Option<Cushion> {
|
||||
let position = position(tag)?;
|
||||
let pos = match tag.get("block_pos") {
|
||||
Some(Tag::IntArray(v)) if v.len() == 3 => [v[0], v[1], v[2]],
|
||||
_ => [
|
||||
position.x.floor() as i32,
|
||||
position.y.floor() as i32,
|
||||
position.z.floor() as i32,
|
||||
],
|
||||
};
|
||||
let yaw = match tag.get("Rotation") {
|
||||
Some(Tag::List(l)) => l
|
||||
.first()
|
||||
.and_then(|v| crate::save::float(Some(v)))
|
||||
.unwrap_or(0.0) as f32,
|
||||
_ => 0.0,
|
||||
};
|
||||
let color =
|
||||
crate::entities::dye_index(&text(tag.get("color")).unwrap_or_else(|| "white".into()));
|
||||
Some(Cushion {
|
||||
id,
|
||||
position,
|
||||
pos,
|
||||
yaw,
|
||||
color,
|
||||
check_interval: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cushion_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cushion_save_preserves_color_anchor_rotation_and_unknown_data() {
|
||||
let cushion = Cushion {
|
||||
id: 7,
|
||||
position: Vec3::new(-1.5, 64.5, 3.5),
|
||||
pos: [-2, 64, 3],
|
||||
yaw: 270.0,
|
||||
color: 14,
|
||||
check_interval: 55,
|
||||
};
|
||||
let raw = Tag::compound([("CustomName", string("Seat"))]);
|
||||
let tag = cushion_to_nbt(&cushion, [1, 2, 3, 4], Some(&raw));
|
||||
assert_eq!(text(tag.get("CustomName")).as_deref(), Some("Seat"));
|
||||
let loaded = cushion_from_nbt(&tag, 8).unwrap();
|
||||
assert_eq!(loaded.id, 8);
|
||||
assert_eq!(loaded.position, cushion.position);
|
||||
assert_eq!(loaded.pos, cushion.pos);
|
||||
assert_eq!(loaded.yaw, cushion.yaw);
|
||||
assert_eq!(loaded.color_name(), "red");
|
||||
assert_eq!(loaded.check_interval, 0);
|
||||
assert_eq!(loaded.bounds(), cushion.bounds());
|
||||
}
|
||||
}
|
||||
/// ItemFrame.addAdditionalSaveData.
|
||||
pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let (lo, hi) = f.bounds();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Horizon's optional extras (zoom, FPS counter, keystroke overlay): their
|
||||
//! Horizon's optional extras (minimap, lighting, inventory tools): their
|
||||
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
|
||||
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
|
||||
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
|
||||
@@ -6,17 +6,8 @@
|
||||
use std::{collections::VecDeque, fs, path::Path};
|
||||
|
||||
pub const FILE: &str = "horizon-extras.txt";
|
||||
/// The name the options screen uses while the zoom key waits for a key.
|
||||
pub const ZOOM_KEY: &str = "horizon.zoom";
|
||||
/// The same for the sort key.
|
||||
/// The name the options screen uses while the sort key waits for a key.
|
||||
pub const SORT_KEY: &str = "horizon.sort";
|
||||
/// Zoom levels the default cycles through, and the wheel's limits.
|
||||
pub const ZOOM_LEVELS: [u8; 6] = [2, 3, 4, 6, 8, 10];
|
||||
pub const ZOOM_MIN: f32 = 1.25;
|
||||
/// `Camera.tickFov` keeps the field-of-view modifier at 0.1 or more.
|
||||
pub const ZOOM_MAX: f32 = 10.0;
|
||||
/// Each wheel step multiplies the zoom by this.
|
||||
const ZOOM_STEP: f32 = 1.25;
|
||||
|
||||
/// The minimap's sizes in GUI pixels, and its zooms in blocks per pixel.
|
||||
pub const MINIMAP_SIZES: [(u8, &str); 3] = [(64, "Small"), (96, "Medium"), (128, "Large")];
|
||||
@@ -81,6 +72,9 @@ pub struct Extras {
|
||||
pub zoom_smooth: bool,
|
||||
/// Divide mouse turning by the zoom.
|
||||
pub zoom_sensitivity: bool,
|
||||
/// The FPS counter and keystrokes extras are official mods now
|
||||
/// (`moved_extras`): their old settings stay here until the game has
|
||||
/// installed the mod for a player who had one on.
|
||||
pub fps: bool,
|
||||
pub fps_corner: Corner,
|
||||
/// Also show the minimum and 1% low.
|
||||
@@ -89,6 +83,9 @@ pub struct Extras {
|
||||
pub keystrokes_corner: Corner,
|
||||
/// Clicks per second under the mouse buttons.
|
||||
pub keystrokes_cps: bool,
|
||||
/// The official mods installed for the extras they replace, by mod id
|
||||
/// (`movedToMods`): each is done once.
|
||||
pub moved: Vec<String>,
|
||||
pub minimap: bool,
|
||||
pub minimap_corner: Corner,
|
||||
/// Side in GUI pixels (`MINIMAP_SIZES`).
|
||||
@@ -132,6 +129,7 @@ impl Default for Extras {
|
||||
keystrokes: false,
|
||||
keystrokes_corner: Corner::TopLeft,
|
||||
keystrokes_cps: true,
|
||||
moved: Vec::new(),
|
||||
minimap: false,
|
||||
minimap_corner: Corner::TopRight,
|
||||
minimap_size: 96,
|
||||
@@ -177,7 +175,7 @@ impl Extras {
|
||||
"zoomKey" if value.starts_with("key.") => e.zoom_key = value.to_owned(),
|
||||
"zoomLevel" => {
|
||||
if let Ok(v) = value.parse::<u8>() {
|
||||
e.zoom_level = v.clamp(2, ZOOM_MAX as u8);
|
||||
e.zoom_level = v.clamp(2, 10);
|
||||
}
|
||||
}
|
||||
"zoomSmooth" => e.zoom_smooth = flag(e.zoom_smooth),
|
||||
@@ -190,6 +188,14 @@ impl Extras {
|
||||
e.keystrokes_corner = Corner::parse(value, e.keystrokes_corner)
|
||||
}
|
||||
"keystrokesCps" => e.keystrokes_cps = flag(e.keystrokes_cps),
|
||||
"movedToMods" => {
|
||||
e.moved = value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
"minimap" => e.minimap = flag(e.minimap),
|
||||
"minimapCorner" => e.minimap_corner = Corner::parse(value, e.minimap_corner),
|
||||
"minimapSize" => {
|
||||
@@ -219,7 +225,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -233,6 +239,7 @@ impl Extras {
|
||||
bool_text(self.keystrokes),
|
||||
self.keystrokes_corner.name(),
|
||||
bool_text(self.keystrokes_cps),
|
||||
self.moved.join(","),
|
||||
bool_text(self.minimap),
|
||||
self.minimap_corner.name(),
|
||||
self.minimap_size,
|
||||
@@ -254,11 +261,10 @@ impl Extras {
|
||||
bool_text(self.discord_presence),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
|
||||
/// Binds the extra key `name` (`SORT_KEY`); false for
|
||||
/// another name (a vanilla mapping).
|
||||
pub fn set_key(&mut self, name: &str, value: String) -> bool {
|
||||
match name {
|
||||
ZOOM_KEY => self.zoom_key = value,
|
||||
SORT_KEY => self.sort_key = value,
|
||||
_ => return false,
|
||||
}
|
||||
@@ -275,87 +281,12 @@ impl Extras {
|
||||
}
|
||||
}
|
||||
|
||||
/// The zoom key's state: held or not, and the zoom the wheel has set.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Zoom {
|
||||
held: bool,
|
||||
factor: f32,
|
||||
}
|
||||
impl Default for Zoom {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
held: false,
|
||||
factor: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Zoom {
|
||||
/// The key went down: start at the default level.
|
||||
pub fn press(&mut self, level: u8) {
|
||||
self.held = true;
|
||||
self.factor = f32::from(level).clamp(ZOOM_MIN, ZOOM_MAX);
|
||||
}
|
||||
pub fn release(&mut self) {
|
||||
self.held = false;
|
||||
}
|
||||
/// A press or release of the zoom key. Only a press while `playing` (no
|
||||
/// screen or text field open) zooms; anywhere else the key belongs to
|
||||
/// the screen, and the zoom lets go.
|
||||
pub fn key(&mut self, pressed: bool, repeat: bool, playing: bool, level: u8) {
|
||||
if !pressed || !playing {
|
||||
self.release();
|
||||
} else if !repeat {
|
||||
self.press(level);
|
||||
}
|
||||
}
|
||||
pub fn held(&self) -> bool {
|
||||
self.held
|
||||
}
|
||||
/// Wheel steps while held (positive zooms in); false when not zooming.
|
||||
pub fn scroll(&mut self, steps: f32) -> bool {
|
||||
if !self.held {
|
||||
return false;
|
||||
}
|
||||
self.factor = (self.factor * ZOOM_STEP.powf(steps)).clamp(ZOOM_MIN, ZOOM_MAX);
|
||||
true
|
||||
}
|
||||
/// The zoom in effect: 1 when the key is up or the spyglass is in use
|
||||
/// (scoping keeps vanilla's spyglass view).
|
||||
pub fn factor(&self, scoping: bool) -> f32 {
|
||||
if self.held && !scoping {
|
||||
self.factor
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Vanilla key mappings bound to the zoom key. The key still reaches them
|
||||
/// (a key bound twice triggers both, as vanilla's do), so the Extras page
|
||||
/// says so.
|
||||
pub fn zoom_key_conflicts(e: &Extras, keys: &crate::keybinds::KeyBinds) -> Vec<&'static str> {
|
||||
if e.zoom_key == "key.keyboard.unknown" {
|
||||
return Vec::new();
|
||||
}
|
||||
crate::keybinds::MAPPINGS
|
||||
.iter()
|
||||
.map(|m| m.0)
|
||||
.filter(|name| keys.get(name) == e.zoom_key)
|
||||
.collect()
|
||||
}
|
||||
/// The field-of-view modifier target `Camera.tickFov` eases toward: vanilla's
|
||||
/// (`getFieldOfViewModifier`) divided by the zoom. A zoom of 1 leaves it as is.
|
||||
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`render.set-zoom`). A
|
||||
/// zoom of 1 leaves it as is.
|
||||
pub fn fov_target(vanilla: f32, zoom: f32) -> f32 {
|
||||
if zoom == 1.0 { vanilla } else { vanilla / zoom }
|
||||
}
|
||||
/// The mouse turning scale while zoomed.
|
||||
pub fn turn_scale(zoom: f32, lower_sensitivity: bool) -> f64 {
|
||||
if lower_sensitivity && zoom > 1.0 {
|
||||
1.0 / f64::from(zoom)
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame times over the last few seconds: frames per second, the minimum
|
||||
/// and the 1% low, recomputed twice a second.
|
||||
pub struct FpsCounter {
|
||||
@@ -447,23 +378,6 @@ impl Cps {
|
||||
pub enum Item {
|
||||
Fullbright,
|
||||
DynamicLights,
|
||||
Zoom,
|
||||
ZoomKey,
|
||||
ZoomLevel,
|
||||
ZoomSmooth,
|
||||
ZoomSensitivity,
|
||||
Fps,
|
||||
FpsCorner,
|
||||
FpsLows,
|
||||
Keystrokes,
|
||||
KeystrokesCorner,
|
||||
KeystrokesCps,
|
||||
Minimap,
|
||||
MinimapCorner,
|
||||
MinimapSize,
|
||||
MinimapShape,
|
||||
MinimapZoom,
|
||||
MinimapRotate,
|
||||
AutoReconnect,
|
||||
MouseTweaks,
|
||||
MouseTweaksWheel,
|
||||
@@ -482,27 +396,6 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Err("Lighting"),
|
||||
Ok(Fullbright),
|
||||
Ok(DynamicLights),
|
||||
Err("Zoom"),
|
||||
Ok(Zoom),
|
||||
Ok(ZoomKey),
|
||||
Ok(ZoomLevel),
|
||||
Ok(ZoomSmooth),
|
||||
Ok(ZoomSensitivity),
|
||||
Err("Minimap"),
|
||||
Ok(Minimap),
|
||||
Ok(MinimapCorner),
|
||||
Ok(MinimapSize),
|
||||
Ok(MinimapShape),
|
||||
Ok(MinimapZoom),
|
||||
Ok(MinimapRotate),
|
||||
Err("FPS counter"),
|
||||
Ok(Fps),
|
||||
Ok(FpsCorner),
|
||||
Ok(FpsLows),
|
||||
Err("Keystrokes"),
|
||||
Ok(Keystrokes),
|
||||
Ok(KeystrokesCorner),
|
||||
Ok(KeystrokesCps),
|
||||
Err("Inventory"),
|
||||
Ok(MouseTweaks),
|
||||
Ok(MouseTweaksWheel),
|
||||
@@ -512,46 +405,16 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Ok(AutoReconnect),
|
||||
]
|
||||
}
|
||||
/// "1 block", "2 blocks", "½ block".
|
||||
fn block_count(n: f32) -> String {
|
||||
if n < 1.0 {
|
||||
"½ block".into()
|
||||
} else if n == 1.0 {
|
||||
"1 block".into()
|
||||
} else {
|
||||
format!("{n} blocks")
|
||||
}
|
||||
}
|
||||
fn next<T: Copy + PartialEq>(all: &[T], current: T, back: bool) -> T {
|
||||
let i = all.iter().position(|v| *v == current).unwrap_or(0);
|
||||
let n = all.len();
|
||||
all[if back { (i + n - 1) % n } else { (i + 1) % n }]
|
||||
}
|
||||
impl Item {
|
||||
pub fn caption(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "Fullbright",
|
||||
Item::DynamicLights => "Dynamic lights",
|
||||
Item::Zoom => "Zoom",
|
||||
Item::ZoomKey => "Zoom key",
|
||||
Item::ZoomLevel => "Starting zoom",
|
||||
Item::ZoomSmooth => "Smooth zoom",
|
||||
Item::ZoomSensitivity => "Slower turning",
|
||||
Item::Fps => "FPS counter",
|
||||
Item::FpsCorner | Item::KeystrokesCorner | Item::MinimapCorner => "Corner",
|
||||
Item::Minimap => "Minimap",
|
||||
Item::MinimapSize => "Size",
|
||||
Item::MinimapShape => "Shape",
|
||||
Item::MinimapZoom => "Zoom",
|
||||
Item::MinimapRotate => "Turn with the player",
|
||||
Item::AutoReconnect => "Auto-reconnect",
|
||||
Item::MouseTweaks => "Mouse Tweaks controls",
|
||||
Item::MouseTweaksWheel => "Scroll wheel moves items",
|
||||
Item::Sort => "Sort buttons",
|
||||
Item::SortKey => "Sort key",
|
||||
Item::FpsLows => "Minimum and 1% low",
|
||||
Item::Keystrokes => "Keystrokes",
|
||||
Item::KeystrokesCps => "Clicks per second",
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
@@ -561,14 +424,10 @@ impl Item {
|
||||
pub fn parent(self) -> Option<Item> {
|
||||
use Item::*;
|
||||
Some(match self {
|
||||
ZoomKey | ZoomLevel | ZoomSmooth | ZoomSensitivity => Zoom,
|
||||
MinimapCorner | MinimapSize | MinimapShape | MinimapZoom | MinimapRotate => Minimap,
|
||||
FpsCorner | FpsLows => Fps,
|
||||
KeystrokesCorner | KeystrokesCps => Keystrokes,
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | DiscordPresence => {
|
||||
Fullbright | DynamicLights | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
| AgentControl | DiscordPresence => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -580,24 +439,9 @@ impl Item {
|
||||
Item::DynamicLights => {
|
||||
"Torches and other light sources held, dropped or on fire light the blocks around them."
|
||||
}
|
||||
Item::Zoom => "Hold the zoom key to zoom; the wheel changes it while held.",
|
||||
Item::ZoomKey => "Click, then press a key. Escape unbinds it.",
|
||||
Item::ZoomLevel => "The zoom the key starts at.",
|
||||
Item::ZoomSmooth => "Eases in and out like the spyglass.",
|
||||
Item::ZoomSensitivity => "Turns slower the more you zoom in.",
|
||||
Item::Fps => "Frames per second in a corner of the screen.",
|
||||
Item::FpsCorner | Item::KeystrokesCorner | Item::MinimapCorner => {
|
||||
"Overlays in one corner stack."
|
||||
}
|
||||
Item::Minimap => "A map of the loaded terrain around you, in map colours.",
|
||||
Item::MinimapSize => "How big the map is on screen.",
|
||||
Item::MinimapShape => "Square or round.",
|
||||
Item::MinimapZoom => "Blocks per pixel: smaller shows more detail, larger more land.",
|
||||
Item::MinimapRotate => "Forward is up; off keeps north up.",
|
||||
Item::AutoReconnect => {
|
||||
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
|
||||
}
|
||||
Item::FpsLows => "The slowest frame and the 1% low of the last 5 seconds.",
|
||||
Item::MouseTweaks => {
|
||||
"Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot."
|
||||
}
|
||||
@@ -610,8 +454,6 @@ impl Item {
|
||||
Item::SortKey => {
|
||||
"Sorts the part under the cursor. Click, then press a key; Escape unbinds it."
|
||||
}
|
||||
Item::Keystrokes => "Movement keys, jump and mouse buttons as you press them.",
|
||||
Item::KeystrokesCps => "Clicks in the last second under each mouse button.",
|
||||
Item::NoAi => {
|
||||
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
|
||||
}
|
||||
@@ -627,8 +469,8 @@ impl Item {
|
||||
pub fn cost(self) -> Option<crate::graphics_style::Cost> {
|
||||
use crate::graphics_style::Cost;
|
||||
match self {
|
||||
Item::Fullbright | Item::Zoom | Item::Fps | Item::Keystrokes => Some(Cost::None),
|
||||
Item::Minimap | Item::DynamicLights => Some(Cost::Low),
|
||||
Item::Fullbright => Some(Cost::None),
|
||||
Item::DynamicLights => Some(Cost::Low),
|
||||
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
|
||||
_ => None,
|
||||
}
|
||||
@@ -638,15 +480,6 @@ impl Item {
|
||||
Some(match self {
|
||||
Item::Fullbright => e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights,
|
||||
Item::Zoom => e.zoom,
|
||||
Item::ZoomSmooth => e.zoom_smooth,
|
||||
Item::ZoomSensitivity => e.zoom_sensitivity,
|
||||
Item::Fps => e.fps,
|
||||
Item::FpsLows => e.fps_lows,
|
||||
Item::Keystrokes => e.keystrokes,
|
||||
Item::KeystrokesCps => e.keystrokes_cps,
|
||||
Item::Minimap => e.minimap,
|
||||
Item::MinimapRotate => e.minimap_rotate,
|
||||
Item::AutoReconnect => e.auto_reconnect,
|
||||
Item::MouseTweaks => e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
|
||||
@@ -660,65 +493,32 @@ impl Item {
|
||||
/// The value of the other items, as their button shows it.
|
||||
pub fn value(self, e: &Extras) -> String {
|
||||
match self {
|
||||
Item::ZoomKey => crate::keybinds::display_name(&e.zoom_key).plain(),
|
||||
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
|
||||
Item::ZoomLevel => format!("{}x", e.zoom_level),
|
||||
Item::FpsCorner => e.fps_corner.label().into(),
|
||||
Item::KeystrokesCorner => e.keystrokes_corner.label().into(),
|
||||
Item::MinimapCorner => e.minimap_corner.label().into(),
|
||||
Item::MinimapSize => MINIMAP_SIZES
|
||||
.iter()
|
||||
.find(|s| s.0 == e.minimap_size)
|
||||
.map_or("Medium", |s| s.1)
|
||||
.into(),
|
||||
Item::MinimapShape => if e.minimap_round { "Round" } else { "Square" }.into(),
|
||||
Item::MinimapZoom => format!("{} per pixel", block_count(e.minimap_zoom)),
|
||||
other => match other.switch(e) {
|
||||
Some(true) => "On".into(),
|
||||
_ => "Off".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
/// A click (`back` for the right button). The zoom key binds instead.
|
||||
pub fn cycle(self, e: &mut Extras, back: bool) {
|
||||
/// A click (`_back` for the right button: every item left is a switch,
|
||||
/// so both flip it). The sort key binds instead.
|
||||
pub fn cycle(self, e: &mut Extras, _back: bool) {
|
||||
match self {
|
||||
Item::Fullbright => e.fullbright = !e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
|
||||
Item::Zoom => e.zoom = !e.zoom,
|
||||
Item::ZoomSmooth => e.zoom_smooth = !e.zoom_smooth,
|
||||
Item::ZoomSensitivity => e.zoom_sensitivity = !e.zoom_sensitivity,
|
||||
Item::Fps => e.fps = !e.fps,
|
||||
Item::FpsLows => e.fps_lows = !e.fps_lows,
|
||||
Item::Keystrokes => e.keystrokes = !e.keystrokes,
|
||||
Item::KeystrokesCps => e.keystrokes_cps = !e.keystrokes_cps,
|
||||
Item::ZoomLevel => e.zoom_level = next(&ZOOM_LEVELS, e.zoom_level, back),
|
||||
Item::FpsCorner => e.fps_corner = next(&Corner::ALL, e.fps_corner, back),
|
||||
Item::KeystrokesCorner => {
|
||||
e.keystrokes_corner = next(&Corner::ALL, e.keystrokes_corner, back)
|
||||
}
|
||||
Item::Minimap => e.minimap = !e.minimap,
|
||||
Item::MinimapRotate => e.minimap_rotate = !e.minimap_rotate,
|
||||
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
|
||||
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort = !e.sort,
|
||||
Item::MinimapCorner => e.minimap_corner = next(&Corner::ALL, e.minimap_corner, back),
|
||||
Item::MinimapSize => {
|
||||
let sizes: Vec<u8> = MINIMAP_SIZES.iter().map(|s| s.0).collect();
|
||||
e.minimap_size = next(&sizes, e.minimap_size, back);
|
||||
}
|
||||
Item::MinimapShape => e.minimap_round = !e.minimap_round,
|
||||
Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back),
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
Item::ZoomKey | Item::SortKey => {}
|
||||
Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
/// The key name the options screen waits on for a key item.
|
||||
pub fn key_name(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Item::ZoomKey => Some(ZOOM_KEY),
|
||||
Item::SortKey => Some(SORT_KEY),
|
||||
_ => None,
|
||||
}
|
||||
@@ -750,6 +550,7 @@ mod tests {
|
||||
e.fps_corner = Corner::BottomRight;
|
||||
e.keystrokes = true;
|
||||
e.keystrokes_cps = false;
|
||||
e.moved = vec!["fps_overlay".into(), "keystrokes".into()];
|
||||
e.minimap = true;
|
||||
e.minimap_size = 128;
|
||||
e.minimap_round = true;
|
||||
@@ -775,43 +576,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_zoom_key_is_free_in_vanilla() {
|
||||
let key = Extras::default().zoom_key;
|
||||
assert!(crate::keybinds::key_code(&key).is_some());
|
||||
assert!(
|
||||
crate::keybinds::MAPPINGS.iter().all(|m| m.1 != key),
|
||||
"{key} is a vanilla binding"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_is_vanilla_unless_held_and_not_scoping() {
|
||||
let mut z = Zoom::default();
|
||||
assert_eq!(z.factor(false), 1.0);
|
||||
assert_eq!(fov_target(1.1, z.factor(false)), 1.1);
|
||||
assert!(!z.scroll(1.0), "the wheel is the hotbar's when not zooming");
|
||||
z.press(4);
|
||||
assert_eq!(z.factor(false), 4.0);
|
||||
assert_eq!(z.factor(true), 1.0, "the spyglass wins");
|
||||
assert!((fov_target(1.0, 4.0) - 0.25).abs() < 1e-6);
|
||||
assert!(z.scroll(1.0));
|
||||
assert!((z.factor(false) - 5.0).abs() < 1e-5);
|
||||
for _ in 0..40 {
|
||||
z.scroll(1.0);
|
||||
}
|
||||
assert_eq!(z.factor(false), ZOOM_MAX);
|
||||
for _ in 0..80 {
|
||||
z.scroll(-1.0);
|
||||
}
|
||||
assert_eq!(z.factor(false), ZOOM_MIN);
|
||||
z.release();
|
||||
assert_eq!(z.factor(false), 1.0);
|
||||
z.press(2);
|
||||
assert_eq!(z.factor(false), 2.0, "each press starts at the default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_target_eases_like_the_spyglass() {
|
||||
fn a_mods_zoom_eases_like_the_spyglass() {
|
||||
// Camera.tickFov halves the distance to the target each tick.
|
||||
let mut camera = crate::camera::Camera::default();
|
||||
camera.tick(None, 1.0);
|
||||
@@ -835,55 +600,6 @@ mod tests {
|
||||
assert!((floor - 7.0).abs() < 1e-3, "{floor}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_zoom_key_only_zooms_while_playing() {
|
||||
let mut z = Zoom::default();
|
||||
// In chat, a sign, a text field or any screen: no zoom.
|
||||
z.key(true, false, false, 4);
|
||||
assert!(!z.held());
|
||||
z.key(true, false, true, 4);
|
||||
assert!(z.held());
|
||||
// A screen opening while held lets go (its key presses are the screen's).
|
||||
z.key(true, true, false, 4);
|
||||
assert!(!z.held());
|
||||
// Key repeats do not restart a zoom the wheel changed.
|
||||
z.key(true, false, true, 4);
|
||||
z.scroll(1.0);
|
||||
z.key(true, true, true, 4);
|
||||
assert!((z.factor(false) - 5.0).abs() < 1e-5);
|
||||
z.key(false, false, true, 4);
|
||||
assert!(!z.held());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_key_conflicts_name_the_vanilla_mappings() {
|
||||
let mut e = Extras::default();
|
||||
let mut keys = crate::keybinds::KeyBinds::default();
|
||||
assert!(zoom_key_conflicts(&e, &keys).is_empty());
|
||||
e.zoom_key = "key.keyboard.c".into();
|
||||
assert_eq!(
|
||||
zoom_key_conflicts(&e, &keys),
|
||||
[
|
||||
"key.saveToolbarActivator",
|
||||
"key.debug.crash",
|
||||
"key.debug.copyLocation"
|
||||
]
|
||||
);
|
||||
keys.set("key.drop", "key.keyboard.z");
|
||||
e.zoom_key = "key.keyboard.z".into();
|
||||
assert_eq!(zoom_key_conflicts(&e, &keys), ["key.drop"]);
|
||||
e.zoom_key = "key.keyboard.unknown".into();
|
||||
keys.set("key.smoothCamera", "key.keyboard.unknown");
|
||||
assert!(zoom_key_conflicts(&e, &keys).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitivity_scales_with_zoom() {
|
||||
assert_eq!(turn_scale(1.0, true), 1.0);
|
||||
assert_eq!(turn_scale(4.0, false), 1.0);
|
||||
assert_eq!(turn_scale(4.0, true), 0.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fps_counter_reports_rate_minimum_and_low() {
|
||||
let mut c = FpsCounter::default();
|
||||
@@ -937,20 +653,9 @@ mod tests {
|
||||
#[test]
|
||||
fn items_cycle_through_their_values() {
|
||||
let mut e = Extras::default();
|
||||
Item::ZoomLevel.cycle(&mut e, false);
|
||||
assert_eq!(e.zoom_level, 6);
|
||||
Item::ZoomLevel.cycle(&mut e, true);
|
||||
Item::ZoomLevel.cycle(&mut e, true);
|
||||
assert_eq!(e.zoom_level, 3);
|
||||
Item::FpsCorner.cycle(&mut e, true);
|
||||
assert_eq!(e.fps_corner, Corner::BottomRight);
|
||||
Item::Zoom.cycle(&mut e, false);
|
||||
assert_eq!(Item::Zoom.switch(&e), Some(true));
|
||||
assert_eq!(Item::ZoomKey.value(&e), "Z");
|
||||
Item::MinimapZoom.cycle(&mut e, true);
|
||||
assert_eq!(Item::MinimapZoom.value(&e), "½ block per pixel");
|
||||
Item::MinimapSize.cycle(&mut e, false);
|
||||
assert_eq!(Item::MinimapSize.value(&e), "Large");
|
||||
Item::Sort.cycle(&mut e, true);
|
||||
assert_eq!(Item::Sort.value(&e), "On");
|
||||
// The minimap's old keys still parse, for the move to its mod.
|
||||
assert_eq!(
|
||||
Extras::parse("minimapSize:7\nminimapZoom:3").minimap_zoom,
|
||||
1.0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The extras' HUD overlays (FPS counter, keystrokes): small boxes in the
|
||||
//! vanilla font, in GUI pixels, stacked per corner (`docs/extras.md`).
|
||||
//! HUD boxes in the corners: the minimap's place and mods' panels, small
|
||||
//! boxes in GUI pixels stacked per corner (`docs/extras.md`).
|
||||
use crate::{
|
||||
extras::{Corner, FpsReading},
|
||||
extras::Corner,
|
||||
hud::{HudAtlas, HudBatch, Rect, rgba},
|
||||
};
|
||||
|
||||
@@ -99,36 +99,6 @@ pub fn draw_minimap(
|
||||
}
|
||||
}
|
||||
|
||||
/// The FPS counter's text.
|
||||
pub fn fps_text(r: FpsReading, lows: bool) -> String {
|
||||
if lows {
|
||||
format!("{} fps min {} 1% {}", r.fps, r.min, r.low)
|
||||
} else {
|
||||
format!("{} fps", r.fps)
|
||||
}
|
||||
}
|
||||
pub fn draw_fps(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
corners: &mut Corners,
|
||||
corner: Corner,
|
||||
reading: FpsReading,
|
||||
lows: bool,
|
||||
) {
|
||||
let text = fps_text(reading, lows);
|
||||
let w = HudBatch::text_width(atlas, &text) + 4.0;
|
||||
let (x, y) = corners.place(corner, w, 12.0);
|
||||
batch.fill(atlas, x, y, w, 12.0, BACKGROUND);
|
||||
batch.text(
|
||||
atlas,
|
||||
&text,
|
||||
x + 2.0,
|
||||
y + 2.0,
|
||||
rgba(255, 255, 255, 255),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/// A box of text lines (a mod's HUD panel): `(text, 0xRRGGBB)` each.
|
||||
pub fn draw_lines(
|
||||
batch: &mut HudBatch,
|
||||
@@ -155,95 +125,6 @@ pub fn draw_lines(
|
||||
}
|
||||
}
|
||||
|
||||
/// What the keystroke overlay shows.
|
||||
pub struct Keystrokes {
|
||||
/// Forward, left, back, right: held, and the bound key's name.
|
||||
pub moves: [(bool, String); 4],
|
||||
pub jump: bool,
|
||||
pub attack: bool,
|
||||
pub use_: bool,
|
||||
/// Clicks per second of the left and right buttons, when shown.
|
||||
pub cps: Option<(usize, usize)>,
|
||||
}
|
||||
const KEY: f32 = 22.0;
|
||||
const GAP: f32 = 2.0;
|
||||
/// The overlay's size.
|
||||
pub fn keystrokes_size() -> (f32, f32) {
|
||||
(3.0 * KEY + 2.0 * GAP, 3.0 * KEY + 3.0 * GAP + 10.0)
|
||||
}
|
||||
fn key_box(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4], held: bool, lines: &[&str]) {
|
||||
let [x, y, w, h] = rect;
|
||||
let (fill, ink) = if held {
|
||||
(rgba(255, 255, 255, 176), rgba(20, 20, 20, 255))
|
||||
} else {
|
||||
(BACKGROUND, rgba(255, 255, 255, 255))
|
||||
};
|
||||
batch.fill(atlas, x, y, w, h, fill);
|
||||
let total = lines.len() as f32 * 9.0 - 1.0;
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let tw = HudBatch::text_width(atlas, line);
|
||||
let tx = (x + (w - tw) / 2.0).round();
|
||||
let ty = (y + (h - total) / 2.0).round() + i as f32 * 9.0;
|
||||
batch.text(atlas, line, tx, ty, ink, !held);
|
||||
}
|
||||
}
|
||||
pub fn draw_keystrokes(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
corners: &mut Corners,
|
||||
corner: Corner,
|
||||
k: &Keystrokes,
|
||||
) {
|
||||
let (w, h) = keystrokes_size();
|
||||
let (x, y) = corners.place(corner, w, h);
|
||||
let step = KEY + GAP;
|
||||
let [forward, left, back, right] = &k.moves;
|
||||
key_box(
|
||||
batch,
|
||||
atlas,
|
||||
[x + step, y, KEY, KEY],
|
||||
forward.0,
|
||||
&[&forward.1],
|
||||
);
|
||||
for (i, key) in [left, back, right].into_iter().enumerate() {
|
||||
key_box(
|
||||
batch,
|
||||
atlas,
|
||||
[x + i as f32 * step, y + step, KEY, KEY],
|
||||
key.0,
|
||||
&[&key.1],
|
||||
);
|
||||
}
|
||||
let mw = (w - GAP) / 2.0;
|
||||
let my = y + 2.0 * step;
|
||||
for (i, (held, label, cps)) in [
|
||||
(k.attack, "LMB", k.cps.map(|c| c.0)),
|
||||
(k.use_, "RMB", k.cps.map(|c| c.1)),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let rate = cps.map(|n| format!("{n} CPS"));
|
||||
let lines: Vec<&str> = std::iter::once(label).chain(rate.as_deref()).collect();
|
||||
key_box(
|
||||
batch,
|
||||
atlas,
|
||||
[x + i as f32 * (mw + GAP), my, mw, KEY],
|
||||
held,
|
||||
&lines,
|
||||
);
|
||||
}
|
||||
// The space bar: a line in a short key.
|
||||
let sy = my + step;
|
||||
key_box(batch, atlas, [x, sy, w, 10.0], k.jump, &[]);
|
||||
let ink = if k.jump {
|
||||
rgba(20, 20, 20, 255)
|
||||
} else {
|
||||
rgba(255, 255, 255, 255)
|
||||
};
|
||||
batch.fill(atlas, x + w / 2.0 - 12.0, sy + 4.0, 24.0, 2.0, ink);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -259,15 +140,4 @@ mod tests {
|
||||
assert_eq!(c.place(Corner::BottomRight, 50.0, 10.0), (348.0, 274.0));
|
||||
assert_eq!(c.place(Corner::BottomLeft, 10.0, 10.0), (2.0, 288.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fps_text_with_and_without_lows() {
|
||||
let r = FpsReading {
|
||||
fps: 144,
|
||||
min: 61,
|
||||
low: 98,
|
||||
};
|
||||
assert_eq!(fps_text(r, false), "144 fps");
|
||||
assert_eq!(fps_text(r, true), "144 fps min 61 1% 98");
|
||||
}
|
||||
}
|
||||
@@ -1201,6 +1201,31 @@ mod tests {
|
||||
assert!(!font.take_patches().is_empty());
|
||||
}
|
||||
|
||||
/// A renderer rebuilt over the same font (the loading screen's handoff)
|
||||
/// makes its atlas texture from the textures alone, so the glyphs it had
|
||||
/// rasterized into the previous texture must be placed into the new one.
|
||||
#[test]
|
||||
fn a_rebuilt_atlas_gets_its_glyphs_again() {
|
||||
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
|
||||
let default = br#"{"providers":[{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["ab"]}]}"#;
|
||||
let root = root(
|
||||
"rebuild",
|
||||
&[
|
||||
("assets/minecraft/font/default.json", default),
|
||||
("assets/minecraft/textures/font/a.png", &sheet),
|
||||
],
|
||||
);
|
||||
let font = load(&root);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let first = font.glyph('a');
|
||||
assert_eq!(font.take_patches().len(), 1);
|
||||
font.reset_cache();
|
||||
// Nothing is left over: drawing the character again places its pixels.
|
||||
assert!(font.take_patches().is_empty());
|
||||
assert_eq!(font.glyph('a').advance, first.advance);
|
||||
assert_eq!(font.take_patches().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_filters_follow_the_font_options() {
|
||||
let sheet = png(8, 8, |x, _| x < 4);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -300,6 +300,8 @@ struct Glide {
|
||||
pub(super) struct Agent {
|
||||
task: Option<Task>,
|
||||
glide: Option<Glide>,
|
||||
/// Control-fixture barrier: hold simulation and packet handling, not transport threads.
|
||||
pub hold_server: bool,
|
||||
}
|
||||
|
||||
pub(super) enum Handled {
|
||||
@@ -390,6 +392,9 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn screen_label(&self) -> &'static str {
|
||||
if self.dialog_open() {
|
||||
return "dialog";
|
||||
}
|
||||
if self.title.is_some() {
|
||||
return "title";
|
||||
}
|
||||
@@ -476,6 +481,8 @@ impl Game<'_> {
|
||||
"absorption": p.health.absorption(),
|
||||
// The hurt animation's ticks left (the camera's hurt tilt).
|
||||
"hurt_time": p.health.hurt_animation_time,
|
||||
// LivingEntity.deathTime while dead (the camera's death roll).
|
||||
"death_time": self.death_time(),
|
||||
"air": p.air,
|
||||
"food": p.health.food.food,
|
||||
"saturation": p.health.food.saturation,
|
||||
@@ -485,6 +492,7 @@ impl Game<'_> {
|
||||
})
|
||||
});
|
||||
json!({
|
||||
"dialog": self.dialog_status(),
|
||||
"screen": self.screen_label(),
|
||||
"menu": self.title.as_ref().map(|_| self.title_value()),
|
||||
"world": (self.title.is_none()).then(|| match &self.integrated {
|
||||
@@ -663,6 +671,7 @@ impl Game<'_> {
|
||||
});
|
||||
if let Some(chat) = &self.ui.chat_screen {
|
||||
v["chat_input"] = json!(chat.input.value);
|
||||
v["chat_suggestions"] = json!(chat.suggestions_visible());
|
||||
}
|
||||
if self.title.is_some() {
|
||||
v["menu"] = self.title_value();
|
||||
@@ -682,6 +691,18 @@ impl Game<'_> {
|
||||
v["craft_size"] = json!(self.craft_size);
|
||||
v["station"] = json!(self.station.as_ref().map(|s| format!("{:?}", s.kind)));
|
||||
v["container"] = json!(self.container);
|
||||
// The title a server gave the menu, span by span as it is drawn.
|
||||
if let Some(title) = self.remote_menu_title() {
|
||||
let spans = title.spans.iter().map(|s| {
|
||||
json!({
|
||||
"text": s.text,
|
||||
"color": s.style.color.map(|c| format!("#{c:06X}")),
|
||||
"bold": s.style.is_bold(),
|
||||
"italic": s.style.is_italic(),
|
||||
})
|
||||
});
|
||||
v["title"] = json!(spans.collect::<Vec<_>>());
|
||||
}
|
||||
// The recipe book's buttons (GUI pixels): the book button, then
|
||||
// the recipe buttons of the page, page arrows, filter and tabs.
|
||||
let book = self.recipe_book_targets();
|
||||
|
||||
@@ -515,8 +515,7 @@ impl Game<'_> {
|
||||
if self.carried.is_some() {
|
||||
self.draw_carried(batch);
|
||||
} else if let Some(stack) = stack_tip {
|
||||
let lines = self.stack_tooltip(&stack);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(key) = tooltip {
|
||||
let text = self.translate(&key, &key);
|
||||
self.draw_tooltip_lines(
|
||||
|
||||
@@ -8,10 +8,15 @@ use horizon_client::{
|
||||
block_entity_anim::{BellEntity, EndGateway, Shulker, ShulkerStatus, Ticker},
|
||||
block_entity_render::BlockAnim,
|
||||
collisions::{SceneCollisions, no_collision_empty},
|
||||
containers::BlockEntity,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
save::number,
|
||||
skins,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// The handle of the local player among a bell's gathered entities.
|
||||
@@ -84,6 +89,16 @@ impl Game<'_> {
|
||||
self.lid_openers_changed(other, open);
|
||||
}
|
||||
}
|
||||
_ if self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.name
|
||||
.as_ref()
|
||||
== "minecraft:barrel" =>
|
||||
{
|
||||
self.barrel_openers_changed(key, open);
|
||||
}
|
||||
Some(Ticker::Shulker { .. }) => {
|
||||
let shulker = self.be_anims.shulkers.entry(pos).or_default();
|
||||
let (count, _) = if open {
|
||||
@@ -102,15 +117,48 @@ impl Game<'_> {
|
||||
/// `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) {
|
||||
let count = self.lid_openers.entry(key).or_default();
|
||||
*count = (*count + if open { 1 } else { -1 }).max(0);
|
||||
let count = *count;
|
||||
if count == 0 {
|
||||
self.lid_openers.remove(&key);
|
||||
}
|
||||
let (before, count) = self.count_opener(key, open);
|
||||
let pos = [key.0, key.1, key.2];
|
||||
self.block_entity_event(pos, 1, count);
|
||||
self.lan_block_event(pos, 1, count);
|
||||
// TrappedChestBlockEntity.signalOpenCount: its signal is the count
|
||||
// (`TrappedChestBlock.getSignal`), so the blocks around it and
|
||||
// below it hear of a change.
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if before != count && self.res.facts().get(state).name.as_ref() == "minecraft:trapped_chest"
|
||||
{
|
||||
let p = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| {
|
||||
let block = b.block(state);
|
||||
b.update_neighbors_at(level, ticks, p, block);
|
||||
b.update_neighbors_at(level, ticks, p.offset(0, -1, 0), block);
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `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) {
|
||||
let (before, count) = self.count_opener(key, open);
|
||||
if (before == 0) != (count == 0) {
|
||||
self.set_bool_prop(
|
||||
key,
|
||||
"open",
|
||||
count > 0,
|
||||
horizon_world::level::flags::UPDATE_ALL,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// One opener more (`open`) or less at `key`: the count before and after
|
||||
/// (`ContainerOpenersCounter.incrementOpeners` / `decrementOpeners`).
|
||||
fn count_opener(&mut self, key: (i32, i32, i32), open: bool) -> (i32, i32) {
|
||||
let count = self.lid_openers.entry(key).or_default();
|
||||
let before = *count;
|
||||
*count = (before + if open { 1 } else { -1 }).max(0);
|
||||
let after = *count;
|
||||
if after == 0 {
|
||||
self.lid_openers.remove(&key);
|
||||
}
|
||||
(before, after)
|
||||
}
|
||||
/// `BellBlockEntity.updateEntities`: the living entities whose bounding
|
||||
/// box meets the bell's block box inflated by 48.
|
||||
@@ -171,6 +219,11 @@ impl Game<'_> {
|
||||
/// client tick (`ClientLevel.tickBlockEntities`).
|
||||
pub(super) fn tick_block_entity_anims(&mut self) {
|
||||
let Some(r) = &self.renderer else {
|
||||
// A server has no window, but its gateways still run their
|
||||
// server ticker.
|
||||
if self.remote.is_none() {
|
||||
self.tick_gateway_servers();
|
||||
}
|
||||
return;
|
||||
};
|
||||
// Sorted by position: the order they tick in, and a quick lookup.
|
||||
@@ -330,6 +383,31 @@ impl Game<'_> {
|
||||
}
|
||||
self.be_anims.present = positions;
|
||||
}
|
||||
/// `TheEndGatewayBlockEntity.portalTick` (the server ticker) of the
|
||||
/// gateways a server keeps a copy of, in block-ticking chunks
|
||||
/// (`Level.tickBlockEntities`): the cooldown an entry started runs out,
|
||||
/// and every 2400 ticks `triggerCooldown` sends its block event.
|
||||
fn tick_gateway_servers(&mut self) {
|
||||
let levels = self.tickets.levels();
|
||||
let mut positions: Vec<[i32; 3]> = self
|
||||
.be_anims
|
||||
.gateway_servers
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|p| levels.block_ticking_at(p[0], p[2]))
|
||||
.collect();
|
||||
positions.sort_unstable();
|
||||
for pos in positions {
|
||||
let triggered = self
|
||||
.be_anims
|
||||
.gateway_servers
|
||||
.get_mut(&pos)
|
||||
.is_some_and(EndGateway::portal_tick);
|
||||
if triggered {
|
||||
self.block_entity_event(pos, 1, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `EndGatewayBlock.entityInside` on the server: whether the gateway at
|
||||
/// `pos` let the entity in (`triggerCooldown`, with its block event).
|
||||
pub(super) fn gateway_enter(&mut self, pos: [i32; 3]) -> bool {
|
||||
@@ -392,4 +470,25 @@ impl Game<'_> {
|
||||
pub(super) fn chest_lid(&self, pos: [i32; 3], partial: f32) -> f32 {
|
||||
self.be_anims.lid(pos, partial)
|
||||
}
|
||||
/// `SkullBlockRenderer.getPlayerSkin`: the skin sprite of the profile of
|
||||
/// 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> {
|
||||
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) {
|
||||
return Some(location);
|
||||
}
|
||||
}
|
||||
Some(skin.fallback)
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ impl Game<'_> {
|
||||
Some(BlockEntity::Beehive(_)) => self.tick_beehive(pos),
|
||||
Some(BlockEntity::Furnace(_)) => self.tick_furnace(pos)?,
|
||||
Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?,
|
||||
Some(BlockEntity::Campfire(_)) => self.tick_campfire(pos)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +93,10 @@ impl Game<'_> {
|
||||
| "minecraft:vault"
|
||||
| "minecraft:beehive"
|
||||
| "minecraft:bee_nest"
|
||||
| "minecraft:brewing_stand" => Ticking::Yes,
|
||||
| "minecraft:brewing_stand"
|
||||
// Lit, cookTick; unlit, cooldownTick.
|
||||
| "minecraft:campfire"
|
||||
| "minecraft:soul_campfire" => Ticking::Yes,
|
||||
"minecraft:jukebox" => {
|
||||
let record = registry
|
||||
.state(state)
|
||||
|
||||
@@ -55,7 +55,7 @@ pub(super) struct Boat {
|
||||
/// `ChestBoat.CONTAINER_SIZE`.
|
||||
pub(super) const CHEST_SLOTS: usize = 27;
|
||||
pub(super) const WIDTH: f64 = 1.375;
|
||||
const HEIGHT: f64 = 0.5625;
|
||||
pub(super) const HEIGHT: f64 = 0.5625;
|
||||
impl Boat {
|
||||
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
@@ -587,6 +587,9 @@ impl Game<'_> {
|
||||
}
|
||||
/// The player's tick while riding: steer, tick the boat, sit in it.
|
||||
pub(super) fn tick_riding(&mut self, keys: Keys) -> horizon_client::Result<()> {
|
||||
if self.tick_cushion_riding(keys) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(i) = self.boats.iter().position(|b| Some(b.id) == self.riding) else {
|
||||
if self.tick_cart_riding(keys) || self.tick_mount_riding(keys) {
|
||||
return Ok(());
|
||||
|
||||
@@ -17,7 +17,6 @@ use horizon_client::{
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
use horizon_world::level::flags as level_flags;
|
||||
use std::time::Instant;
|
||||
use winit::{
|
||||
event::{ElementState, KeyEvent},
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
@@ -27,8 +26,24 @@ const BLACK: u32 = 0xFF00_0000;
|
||||
const PAGE_CHARS: usize = 1024;
|
||||
const MAX_PAGES: usize = 100;
|
||||
const LINES: usize = 14;
|
||||
/// `BookSignScreen`'s title box.
|
||||
const BOOK_TITLE: horizon_client::clipboard::FieldId = ("book.title", 0);
|
||||
|
||||
fn book_title_box() -> ui::EditBox {
|
||||
let mut edit = ui::EditBox::new(0.0, 0.0, 114.0, 9.0);
|
||||
edit.bordered = false;
|
||||
edit.max_length = 15;
|
||||
edit.text_color = BLACK;
|
||||
edit.focused = true;
|
||||
edit
|
||||
}
|
||||
fn book_page_box() -> ui::EditBox {
|
||||
let mut edit = ui::EditBox::new(0.0, 0.0, 114.0, 9.0);
|
||||
edit.bordered = false;
|
||||
edit.multiline = true;
|
||||
edit.max_length = PAGE_CHARS;
|
||||
edit.text_color = BLACK;
|
||||
edit.focused = true;
|
||||
edit
|
||||
}
|
||||
|
||||
pub(super) enum BookScreen {
|
||||
Edit {
|
||||
@@ -38,7 +53,10 @@ pub(super) enum BookScreen {
|
||||
page: usize,
|
||||
signing: bool,
|
||||
title: String,
|
||||
opened: Instant,
|
||||
/// BookSignScreen's title box (`EditBox` of 15).
|
||||
title_edit: ui::EditBox,
|
||||
/// BookEditScreen's page (`MultiLineEditBox`).
|
||||
page_edit: ui::EditBox,
|
||||
},
|
||||
View {
|
||||
pages: Vec<String>,
|
||||
@@ -84,13 +102,16 @@ impl Game<'_> {
|
||||
if pages.is_empty() {
|
||||
pages.push(String::new());
|
||||
}
|
||||
let mut page_edit = book_page_box();
|
||||
page_edit.set_value(&pages[0]);
|
||||
BookScreen::Edit {
|
||||
slot: self.held_slot(),
|
||||
pages,
|
||||
page: 0,
|
||||
signing: false,
|
||||
title: String::new(),
|
||||
opened: Instant::now(),
|
||||
title_edit: book_title_box(),
|
||||
page_edit,
|
||||
}
|
||||
}
|
||||
"minecraft:written_book" => BookScreen::View {
|
||||
@@ -239,34 +260,24 @@ impl Game<'_> {
|
||||
Some(KeyCode::PageDown) => self.turn_page(true),
|
||||
_ => {}
|
||||
},
|
||||
Some(BookScreen::Edit {
|
||||
signing: true,
|
||||
title,
|
||||
..
|
||||
}) => match key {
|
||||
Some(BookScreen::Edit { signing: true, .. }) => match key {
|
||||
Some(KeyCode::Escape) => {
|
||||
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
|
||||
*signing = false;
|
||||
}
|
||||
}
|
||||
Some(KeyCode::Backspace) => {
|
||||
if !self.text_selection.take(BOOK_TITLE, title) {
|
||||
title.pop();
|
||||
}
|
||||
}
|
||||
Some(KeyCode::Enter | KeyCode::NumpadEnter) => {
|
||||
if !title.trim().is_empty() {
|
||||
let blank = self.book.as_ref().is_some_and(
|
||||
|b| matches!(b, BookScreen::Edit { title, .. } if title.trim().is_empty()),
|
||||
);
|
||||
if !blank {
|
||||
self.save_book(true);
|
||||
}
|
||||
}
|
||||
Some(key) if self.book_title_key(key) => {}
|
||||
_ => {
|
||||
if let Some(typed) = text {
|
||||
self.text_selection.take(BOOK_TITLE, title);
|
||||
for c in typed.chars().filter(|c| !c.is_control()) {
|
||||
if title.chars().count() < 15 {
|
||||
title.push(c);
|
||||
}
|
||||
}
|
||||
self.book_title_insert(typed);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -274,24 +285,13 @@ impl Game<'_> {
|
||||
Some(KeyCode::Escape) => self.save_book(false),
|
||||
Some(KeyCode::PageUp) => self.turn_page(false),
|
||||
Some(KeyCode::PageDown) => self.turn_page(true),
|
||||
Some(KeyCode::Backspace) => {
|
||||
if !self.take_page_selection() {
|
||||
self.edit_page(|p| {
|
||||
p.pop();
|
||||
})
|
||||
}
|
||||
}
|
||||
Some(KeyCode::Enter | KeyCode::NumpadEnter) => {
|
||||
self.take_page_selection();
|
||||
self.type_into_page("\n")
|
||||
}
|
||||
Some(KeyCode::ArrowUp) => self.book_move_line(-1),
|
||||
Some(KeyCode::ArrowDown) => self.book_move_line(1),
|
||||
Some(KeyCode::Enter | KeyCode::NumpadEnter) => self.page_insert("\n"),
|
||||
Some(key) if self.book_page_key(key) => {}
|
||||
_ => {
|
||||
if let Some(typed) = text {
|
||||
let typed: String = typed.chars().filter(|c| !c.is_control()).collect();
|
||||
if !typed.is_empty() {
|
||||
self.take_page_selection();
|
||||
}
|
||||
self.type_into_page(&typed);
|
||||
self.page_insert(typed);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -302,99 +302,258 @@ impl Game<'_> {
|
||||
pub(super) fn book_type(&mut self, text: &str) {
|
||||
let text = text.replace("\\n", "\n");
|
||||
match self.book.as_mut() {
|
||||
Some(BookScreen::Edit {
|
||||
signing: true,
|
||||
title,
|
||||
..
|
||||
}) => {
|
||||
for c in text.chars().filter(|c| !c.is_control()) {
|
||||
if title.chars().count() < 15 {
|
||||
title.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(BookScreen::Edit { .. }) => self.type_into_page(&text),
|
||||
Some(BookScreen::Edit { signing: true, .. }) => self.book_title_insert(&text),
|
||||
Some(BookScreen::Edit { .. }) => self.page_insert(&text),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// Before typing on a page: Select All's selection is cleared (true).
|
||||
fn take_page_selection(&mut self) -> bool {
|
||||
match self.book.as_mut() {
|
||||
Some(BookScreen::Edit { pages, page, .. }) => self
|
||||
.text_selection
|
||||
.take(("book.page", *page), &mut pages[*page]),
|
||||
_ => false,
|
||||
/// `BookSignScreen.keyPressed` on the title box.
|
||||
fn book_title_key(&mut self, key: KeyCode) -> bool {
|
||||
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
|
||||
let Some(BookScreen::Edit {
|
||||
title, title_edit, ..
|
||||
}) = self.book.as_mut()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !title_edit.key(key, ctrl, shift) {
|
||||
return false;
|
||||
}
|
||||
title.clone_from(&title_edit.value);
|
||||
true
|
||||
}
|
||||
/// Ctrl+A/C/V/X: the title box (an `EditBox` of 15) while signing, else
|
||||
/// the page (`MultilineTextField.insertText`: line breaks kept, cut to
|
||||
/// the 1024 characters left, rejected when it overflows the 14 lines).
|
||||
pub(super) fn book_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
|
||||
let font = &self.gui.atlas.font;
|
||||
match self.book.as_mut() {
|
||||
Some(BookScreen::Edit {
|
||||
signing: true,
|
||||
title,
|
||||
..
|
||||
}) => {
|
||||
Self::append_shortcut(
|
||||
&mut self.text_selection,
|
||||
&mut self.clipboard,
|
||||
BOOK_TITLE,
|
||||
title,
|
||||
shortcut,
|
||||
15,
|
||||
);
|
||||
}
|
||||
Some(BookScreen::Edit { pages, page, .. }) => {
|
||||
// truncateInsertionText counts the whole value, selection included.
|
||||
let room = PAGE_CHARS.saturating_sub(pages[*page].chars().count());
|
||||
self.text_selection.apply(
|
||||
("book.page", *page),
|
||||
&mut pages[*page],
|
||||
shortcut,
|
||||
&mut self.clipboard,
|
||||
|value, pasted| {
|
||||
let text: String = horizon_client::clipboard::filter_text(pasted, true)
|
||||
.chars()
|
||||
.take(room)
|
||||
.collect();
|
||||
let next = format!("{value}{text}");
|
||||
font.split(&Text::literal(&next), 114.0).len() <= LINES && {
|
||||
*value = next;
|
||||
true
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn edit_page(&mut self, f: impl FnOnce(&mut String)) {
|
||||
if let Some(BookScreen::Edit { pages, page, .. }) = self.book.as_mut() {
|
||||
f(&mut pages[*page]);
|
||||
}
|
||||
}
|
||||
/// MultiLineEditBox: characters that keep the page within 14 lines and 1024 characters.
|
||||
fn type_into_page(&mut self, typed: &str) {
|
||||
let Some(BookScreen::Edit { pages, page, .. }) = self.book.as_ref() else {
|
||||
fn book_title_insert(&mut self, text: &str) {
|
||||
let Some(BookScreen::Edit {
|
||||
title, title_edit, ..
|
||||
}) = self.book.as_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut text = pages[*page].clone();
|
||||
for c in typed.chars() {
|
||||
let mut next = text.clone();
|
||||
next.push(c);
|
||||
if next.chars().count() <= PAGE_CHARS && self.book_lines(&next).len() <= LINES {
|
||||
text = next;
|
||||
title_edit.insert_text(text);
|
||||
title.clone_from(&title_edit.value);
|
||||
}
|
||||
/// `BookEditScreen.keyPressed` on the page, apart from the line moves.
|
||||
fn book_page_key(&mut self, key: KeyCode) -> bool {
|
||||
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
|
||||
let before = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit { page_edit, .. }) => Some((
|
||||
page_edit.value.clone(),
|
||||
page_edit.cursor,
|
||||
page_edit.highlight,
|
||||
)),
|
||||
_ => return false,
|
||||
};
|
||||
let handled = if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
|
||||
page_edit.key(key, ctrl, shift)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if handled {
|
||||
self.page_keep(before);
|
||||
}
|
||||
handled
|
||||
}
|
||||
/// One character at a time, so a page stops at the last character that fits.
|
||||
fn page_insert(&mut self, text: &str) {
|
||||
for c in text.chars() {
|
||||
self.page_change(|edit| edit.insert_text(&c.to_string()));
|
||||
}
|
||||
}
|
||||
/// Ctrl+A/C/V/X: the title box while signing, else the page.
|
||||
pub(super) fn book_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
|
||||
let signing = matches!(self.book, Some(BookScreen::Edit { signing: true, .. }));
|
||||
if signing {
|
||||
if let Some(BookScreen::Edit {
|
||||
title, title_edit, ..
|
||||
}) = self.book.as_mut()
|
||||
{
|
||||
title_edit.shortcut(shortcut, &mut self.clipboard);
|
||||
title.clone_from(&title_edit.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let before = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit { page_edit, .. }) => Some((
|
||||
page_edit.value.clone(),
|
||||
page_edit.cursor,
|
||||
page_edit.highlight,
|
||||
)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
|
||||
page_edit.shortcut(shortcut, &mut self.clipboard);
|
||||
}
|
||||
self.page_keep(before);
|
||||
}
|
||||
/// Edit the page box, then keep it only within 14 lines and 1024 characters.
|
||||
fn page_change(&mut self, f: impl FnOnce(&mut ui::EditBox)) -> bool {
|
||||
let before = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit { page_edit, .. }) => Some((
|
||||
page_edit.value.clone(),
|
||||
page_edit.cursor,
|
||||
page_edit.highlight,
|
||||
)),
|
||||
_ => return false,
|
||||
};
|
||||
let ran = if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
|
||||
f(page_edit);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
ran && self.page_keep(before)
|
||||
}
|
||||
/// Write the page, or put the previous text back when it no longer fits.
|
||||
fn page_keep(&mut self, before: Option<(String, usize, usize)>) -> bool {
|
||||
let next = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit { page_edit, .. }) => page_edit.value.clone(),
|
||||
_ => return false,
|
||||
};
|
||||
let ok = next.chars().count() <= PAGE_CHARS && self.book_lines(&next).len() <= LINES;
|
||||
let Some(BookScreen::Edit {
|
||||
pages,
|
||||
page,
|
||||
page_edit,
|
||||
..
|
||||
}) = self.book.as_mut()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if ok {
|
||||
pages[*page].clone_from(&page_edit.value);
|
||||
} else if let Some((value, cursor, highlight)) = before {
|
||||
page_edit.value = value;
|
||||
page_edit.cursor = cursor;
|
||||
page_edit.highlight = highlight;
|
||||
}
|
||||
ok
|
||||
}
|
||||
/// ArrowUp and ArrowDown move a visual line, keeping the column.
|
||||
fn book_move_line(&mut self, dir: isize) {
|
||||
let shift = self.has_shift_down();
|
||||
let (text, cursor) = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit {
|
||||
pages,
|
||||
page,
|
||||
page_edit,
|
||||
..
|
||||
}) => (pages[*page].clone(), page_edit.cursor),
|
||||
_ => return,
|
||||
};
|
||||
let rows = self.page_rows(&text);
|
||||
let (x, line) = self.caret_on(&text, &rows, cursor);
|
||||
let next = (line as isize + dir).clamp(0, rows.len() as isize - 1) as usize;
|
||||
let (start, end) = rows[next];
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let slice: String = chars[start..end].iter().collect();
|
||||
let pos = start + self.gui.atlas.font.fit(&slice, x);
|
||||
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
|
||||
page_edit.set_cursor(pos, shift);
|
||||
}
|
||||
}
|
||||
/// Wrapped rows of a page as character ranges, the same breaks as `font.split`.
|
||||
fn page_rows(&self, page: &str) -> Vec<(usize, usize)> {
|
||||
let font = &self.gui.atlas.font;
|
||||
let chars: Vec<char> = page.chars().collect();
|
||||
let mut rows = Vec::new();
|
||||
let mut start = 0;
|
||||
while start < chars.len() {
|
||||
let mut end = chars.len();
|
||||
let mut next = chars.len();
|
||||
let mut last_space = None;
|
||||
for i in start..chars.len() {
|
||||
if chars[i] == '\n' {
|
||||
end = i;
|
||||
next = i + 1;
|
||||
break;
|
||||
}
|
||||
if chars[i] == ' ' {
|
||||
last_space = Some(i);
|
||||
}
|
||||
let slice: String = chars[start..=i].iter().collect();
|
||||
if font.width(&slice) > 114.0 {
|
||||
match last_space {
|
||||
Some(s) if s > start => {
|
||||
end = s;
|
||||
next = s + 1;
|
||||
}
|
||||
_ => {
|
||||
end = i.max(start + 1);
|
||||
next = end;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
rows.push((start, end));
|
||||
start = next;
|
||||
if start == chars.len() && chars.last() == Some(&'\n') {
|
||||
rows.push((chars.len(), chars.len()));
|
||||
}
|
||||
}
|
||||
self.edit_page(|p| *p = text);
|
||||
if rows.is_empty() {
|
||||
rows.push((0, 0));
|
||||
}
|
||||
rows
|
||||
}
|
||||
/// GUI x of the caret and the row it sits on.
|
||||
fn caret_on(&self, page: &str, rows: &[(usize, usize)], cursor: usize) -> (f32, usize) {
|
||||
let chars: Vec<char> = page.chars().collect();
|
||||
for (i, &(start, end)) in rows.iter().enumerate() {
|
||||
if cursor <= end || i + 1 == rows.len() {
|
||||
let take = cursor.saturating_sub(start).min(end.saturating_sub(start));
|
||||
let prefix: String = chars.iter().skip(start).take(take).collect();
|
||||
return (self.gui.atlas.font.width(&prefix), i);
|
||||
}
|
||||
}
|
||||
(0.0, 0)
|
||||
}
|
||||
fn page_index_at(&self, page: &str, local_x: f32, local_y: f32) -> usize {
|
||||
let rows = self.page_rows(page);
|
||||
let line = (local_y / 9.0).floor().max(0.0) as usize;
|
||||
let line = line.min(rows.len().saturating_sub(1));
|
||||
let (start, end) = rows[line];
|
||||
let chars: Vec<char> = page.chars().collect();
|
||||
let slice: String = chars[start..end].iter().collect();
|
||||
start + self.gui.atlas.font.fit(&slice, local_x.max(0.0))
|
||||
}
|
||||
/// A click on the centred title (`EditBox.onClick`).
|
||||
fn book_title_click(&mut self, field_x: f32, mx: f32) {
|
||||
let shift = self.has_shift_down();
|
||||
let Some(BookScreen::Edit {
|
||||
title, title_edit, ..
|
||||
}) = self.book.as_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let tx = field_x + ((114.0 - self.gui.atlas.font.width(title)) / 2.0).floor();
|
||||
let rel = (mx - tx).max(0.0);
|
||||
let pos = self.gui.atlas.font.fit(title, rel);
|
||||
title_edit.set_cursor(pos, shift);
|
||||
}
|
||||
/// A click on the open page.
|
||||
fn book_page_click(&mut self, origin_x: f32, origin_y: f32, mx: f32, my: f32) {
|
||||
let shift = self.has_shift_down();
|
||||
let text = match self.book.as_ref() {
|
||||
Some(BookScreen::Edit { pages, page, .. }) => pages[*page].clone(),
|
||||
_ => return,
|
||||
};
|
||||
let pos = self.page_index_at(&text, mx - origin_x, my - origin_y);
|
||||
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
|
||||
page_edit.set_cursor(pos, shift);
|
||||
}
|
||||
}
|
||||
/// The page buttons (`pageForward` adds a page while editing).
|
||||
fn turn_page(&mut self, forward: bool) {
|
||||
let mut lectern_page = None;
|
||||
match self.book.as_mut() {
|
||||
Some(BookScreen::Edit { pages, page, .. }) => {
|
||||
Some(BookScreen::Edit {
|
||||
pages,
|
||||
page,
|
||||
page_edit,
|
||||
..
|
||||
}) => {
|
||||
let before = *page;
|
||||
if forward {
|
||||
if *page + 1 >= pages.len() && pages.len() < MAX_PAGES {
|
||||
pages.push(String::new());
|
||||
@@ -405,6 +564,10 @@ impl Game<'_> {
|
||||
} else if *page > 0 {
|
||||
*page -= 1;
|
||||
}
|
||||
if *page != before {
|
||||
let text = pages[*page].clone();
|
||||
page_edit.set_value(&text);
|
||||
}
|
||||
}
|
||||
Some(BookScreen::View {
|
||||
lectern: Some(key), ..
|
||||
@@ -764,6 +927,8 @@ impl Game<'_> {
|
||||
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
|
||||
*signing = false;
|
||||
}
|
||||
} else if inside(left + 36.0, 50.0, 114.0, 9.0) {
|
||||
self.book_title_click(left + 36.0, mx);
|
||||
}
|
||||
}
|
||||
Some(BookScreen::Edit { page, .. }) => {
|
||||
@@ -780,6 +945,8 @@ impl Game<'_> {
|
||||
} else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) {
|
||||
click(self);
|
||||
self.save_book(false);
|
||||
} else if inside(left + 36.0, 32.0, 114.0, 14.0 * 9.0) {
|
||||
self.book_page_click(left + 36.0, 32.0, mx, my);
|
||||
}
|
||||
}
|
||||
Some(BookScreen::View {
|
||||
@@ -865,7 +1032,7 @@ impl Game<'_> {
|
||||
BookScreen::Edit {
|
||||
signing: true,
|
||||
title,
|
||||
opened,
|
||||
title_edit,
|
||||
..
|
||||
} => {
|
||||
let edit = tr("book.editTitle", "Enter Book Title:");
|
||||
@@ -881,19 +1048,35 @@ impl Game<'_> {
|
||||
// The title box: an unbordered, centred EditBox of 114 with
|
||||
// black text and no shadow; the value alone is centred, the
|
||||
// cursor follows it.
|
||||
let value = &title_edit.value;
|
||||
let x = left + 36.0;
|
||||
let tx = x + ((114.0 - HudBatch::text_width(atlas, title)) / 2.0).floor();
|
||||
horizon_client::ui::append_field(
|
||||
batch,
|
||||
atlas,
|
||||
title,
|
||||
(tx, 50.0, x + 114.0 - tx),
|
||||
(BLACK, false),
|
||||
15,
|
||||
self.text_selection.covers(BOOK_TITLE, title),
|
||||
true,
|
||||
opened.elapsed().as_millis(),
|
||||
);
|
||||
let tx = x + ((114.0 - HudBatch::text_width(atlas, value)) / 2.0).floor();
|
||||
batch.text(atlas, value, tx, 50.0, BLACK, false);
|
||||
if title_edit.cursor != title_edit.highlight {
|
||||
let (a, b) = (
|
||||
title_edit.cursor.min(title_edit.highlight),
|
||||
title_edit.cursor.max(title_edit.highlight),
|
||||
);
|
||||
let pre: String = value.chars().take(a).collect();
|
||||
let sel: String = value.chars().skip(a).take(b - a).collect();
|
||||
let sx = tx + HudBatch::text_width(atlas, &pre);
|
||||
batch.text_highlight(
|
||||
atlas,
|
||||
(sx, 49.0, HudBatch::text_width(atlas, &sel), 11.0),
|
||||
true,
|
||||
);
|
||||
}
|
||||
if title_edit.cursor_visible() {
|
||||
let pre: String = value.chars().take(title_edit.cursor).collect();
|
||||
batch.fill(
|
||||
atlas,
|
||||
tx + HudBatch::text_width(atlas, &pre),
|
||||
49.0,
|
||||
1.0,
|
||||
9.0,
|
||||
BLACK,
|
||||
);
|
||||
}
|
||||
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", super::chat::PLAYER_NAME);
|
||||
let tw = HudBatch::text_width(atlas, &by);
|
||||
batch.text(
|
||||
@@ -941,12 +1124,12 @@ impl Game<'_> {
|
||||
BookScreen::Edit {
|
||||
pages,
|
||||
page,
|
||||
opened,
|
||||
page_edit,
|
||||
..
|
||||
} => {
|
||||
indicator(batch, *page, pages.len());
|
||||
let blink = (opened.elapsed().as_millis() / 300) % 2 == 0;
|
||||
let lines = self.book_lines(&pages[*page]);
|
||||
let text = &pages[*page];
|
||||
let lines = self.book_lines(text);
|
||||
for (i, line) in lines.iter().take(LINES).enumerate() {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
@@ -957,27 +1140,39 @@ impl Game<'_> {
|
||||
false,
|
||||
);
|
||||
}
|
||||
if blink {
|
||||
// The cursor after the last character.
|
||||
let last = lines
|
||||
.last()
|
||||
.map(|l| atlas.font.width_text(l))
|
||||
.unwrap_or(0.0);
|
||||
let row = lines.len().saturating_sub(1) as f32
|
||||
+ if pages[*page].ends_with('\n') {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let x = if pages[*page].ends_with('\n') {
|
||||
0.0
|
||||
} else {
|
||||
last
|
||||
};
|
||||
let rows = self.page_rows(text);
|
||||
let (sel_a, sel_b) = (
|
||||
page_edit.cursor.min(page_edit.highlight),
|
||||
page_edit.cursor.max(page_edit.highlight),
|
||||
);
|
||||
if sel_a != sel_b {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
for (i, &(start, end)) in rows.iter().enumerate().take(LINES) {
|
||||
let from = sel_a.max(start);
|
||||
let to = sel_b.min(end);
|
||||
if from >= to {
|
||||
continue;
|
||||
}
|
||||
let pre: String = chars[start..from].iter().collect();
|
||||
let sel: String = chars[from..to].iter().collect();
|
||||
batch.text_highlight(
|
||||
atlas,
|
||||
(
|
||||
left + 36.0 + atlas.font.width(&pre),
|
||||
top + 29.0 + i as f32 * 9.0,
|
||||
atlas.font.width(&sel),
|
||||
9.0,
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
if page_edit.cursor_visible() {
|
||||
let (x, row) = self.caret_on(text, &rows, page_edit.cursor);
|
||||
batch.fill(
|
||||
atlas,
|
||||
left + 36.0 + x,
|
||||
top + 29.0 + row * 9.0,
|
||||
top + 29.0 + row as f32 * 9.0,
|
||||
1.0,
|
||||
9.0,
|
||||
BLACK,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Campfires' cooking: `CampfireBlock.useItemOn` (`CampfireBlockEntity.placeFood`)
|
||||
//! and the block entity's `cookTick` (lit) and `cooldownTick` (unlit). The
|
||||
//! items drawn on the fire are `campfire_geometry`'s; a server's clients get
|
||||
//! them through the block entity's update tag (`lan::chunks::entity_tag`).
|
||||
use super::Game;
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{
|
||||
Result, block_entities::Pos, block_entity_render::vertex_light, containers::BlockEntity,
|
||||
items::ItemStack, mesher::Vertex, mobs::Who,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
|
||||
/// The `campfire_cooking` recipe kind (`RecipeType.CAMPFIRE_COOKING`).
|
||||
const KIND: &str = "minecraft:campfire_cooking";
|
||||
|
||||
fn is_campfire(name: &str) -> bool {
|
||||
name == "minecraft:campfire" || name == "minecraft:soul_campfire"
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `CampfireBlock.useItemOn` with a held item that is a campfire recipe's
|
||||
/// input (`RecipePropertySet.CAMPFIRE_INPUT`): on a server level the food
|
||||
/// goes into the first free slot (`placeFood`), and the use is consumed
|
||||
/// whether or not there was room. A client of a server leaves the placing
|
||||
/// to the server. Whether the food went in; None when this isn't a
|
||||
/// campfire use, so the item's own use runs.
|
||||
pub(super) fn use_on_campfire(&mut self, pos: [i32; 3]) -> Result<Option<bool>> {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !is_campfire(&self.res.facts().get(state).name) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(time) = self.res.recipes().cook(KIND, &held).map(|r| r.time as i32) else {
|
||||
return Ok(None);
|
||||
};
|
||||
// A client of a server predicts the placing; the server decides.
|
||||
if self.remote.is_some() {
|
||||
return Ok(Some(true));
|
||||
}
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let placed = match self.world.block_entities.get_mut(&key) {
|
||||
Some(BlockEntity::Campfire(c)) => {
|
||||
let mut one = held.clone();
|
||||
c.place_food(&mut one, time)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if placed {
|
||||
// ItemStack.consumeAndReturn: creative keeps the item.
|
||||
self.consume_held();
|
||||
self.stats.custom("interact_with_campfire", 1);
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_change",
|
||||
pos,
|
||||
Some(Who::Player),
|
||||
Some(state),
|
||||
);
|
||||
self.mark_block_entity_updated(key);
|
||||
}
|
||||
Ok(Some(placed))
|
||||
}
|
||||
|
||||
/// `CampfireBlockEntity.cookTick` of a lit campfire, `cooldownTick` of an
|
||||
/// unlit one. A finished item's result (the recipe's, or the item itself
|
||||
/// when no recipe takes it any more) drops at the block's corner
|
||||
/// (`Containers.dropItemStack` at its integer position), then the clients
|
||||
/// see the slot empty and a `block_change` goes out.
|
||||
pub(super) fn tick_campfire(&mut self, pos: Pos) -> Result<()> {
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let lit = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("lit"))
|
||||
.is_some_and(|v| v == "true");
|
||||
let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else {
|
||||
return Ok(());
|
||||
};
|
||||
// BlockEntity.setChanged: a timer that moved is saved with the
|
||||
// chunk (no update to the clients, who don't see the timers).
|
||||
let changed = if lit {
|
||||
c.items.iter().any(Option::is_some)
|
||||
} else {
|
||||
c.cooldown_tick()
|
||||
};
|
||||
if changed {
|
||||
self.world.modified.insert((pos.0 >> 4, pos.2 >> 4));
|
||||
}
|
||||
if !lit {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else {
|
||||
return Ok(());
|
||||
};
|
||||
let done = c.cook_tick();
|
||||
for (_, item) in done {
|
||||
let result = self
|
||||
.res
|
||||
.recipes()
|
||||
.cook(KIND, &item)
|
||||
.map_or_else(|| item.clone(), |r| r.result.clone());
|
||||
self.campfire_drop(pos, result);
|
||||
self.mark_block_entity_updated(pos);
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_change",
|
||||
[pos.0, pos.1, pos.2],
|
||||
None,
|
||||
Some(state),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `CampfireRenderer`: each cooking item lies on the fire, slot `i`
|
||||
/// turned to `Direction.from2DDataValue((i + facing) % 4)`, laid flat
|
||||
/// (90° about X), at a corner and three eighths of its size, drawn as a
|
||||
/// FIXED item seeded by the block's position plus the slot.
|
||||
pub(super) fn campfire_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
|
||||
for (key, entity) in &self.world.block_entities {
|
||||
let BlockEntity::Campfire(c) = entity else {
|
||||
continue;
|
||||
};
|
||||
if c.items.iter().all(Option::is_none) {
|
||||
continue;
|
||||
}
|
||||
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
|
||||
if at.distance_squared(camera) >= 64.0 * 64.0 {
|
||||
continue;
|
||||
}
|
||||
// Direction.get2DDataValue: south 0, west 1, north 2, east 3.
|
||||
let state = self.world.block(key.0, key.1, key.2);
|
||||
let facing = match self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("facing"))
|
||||
.map(String::as_str)
|
||||
{
|
||||
Some("west") => 1,
|
||||
Some("north") => 2,
|
||||
Some("east") => 3,
|
||||
_ => 0,
|
||||
};
|
||||
let light = vertex_light(self.world.light(key.0, key.1, key.2));
|
||||
let packed = BlockPos::new(key.0, key.1, key.2).pack();
|
||||
for (i, stack) in c.items.iter().enumerate() {
|
||||
let Some(stack) = stack else {
|
||||
continue;
|
||||
};
|
||||
let seed = (packed as i32).wrapping_add(i as i32);
|
||||
let item = self.resolve_item(stack, DisplayContext::Fixed, None, seed);
|
||||
if item.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Direction.toYRot: 90° per 2D step from south.
|
||||
let yaw = -(((i + facing) % 4) as f32 * 90.0);
|
||||
let pose = glam::Mat4::from_translation(
|
||||
(at - camera).as_vec3() + glam::Vec3::new(0.5, 0.449_218_75, 0.5),
|
||||
) * glam::Mat4::from_rotation_y(yaw.to_radians())
|
||||
* glam::Mat4::from_rotation_x(90f32.to_radians())
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(-0.3125, -0.3125, 0.0))
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(0.375));
|
||||
self.submit_item_world(&item, pose, light, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Containers.dropItemStack(level, x, y, z, stack)` at the block's
|
||||
/// integer position.
|
||||
fn campfire_drop(&mut self, pos: Pos, stack: ItemStack) {
|
||||
let at = BlockPos::new(pos.0, pos.1, pos.2);
|
||||
for piece in
|
||||
horizon_world::level::removal::drop_item_stack(&mut self.world.random, at, stack.count)
|
||||
{
|
||||
self.spawn_item(
|
||||
ItemStack {
|
||||
count: piece.count,
|
||||
..stack.clone()
|
||||
},
|
||||
piece.pos,
|
||||
piece.motion,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
//! (game rules, the world clock, difficulty), bound to the game.
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
chat::{Chat, ChatAction, ChatScreen, Commands, SYSTEM_INDICATOR},
|
||||
chat::{
|
||||
Chat, ChatAbilities, ChatAction, ChatScreen, ChatSource, ChatVisibility, Commands,
|
||||
SYSTEM_INDICATOR,
|
||||
},
|
||||
command::Registries,
|
||||
gamerules::GameRules,
|
||||
hud::HudBatch,
|
||||
@@ -425,6 +428,7 @@ impl Game<'_> {
|
||||
self.ui.chat.recent.len(),
|
||||
b.width as i32,
|
||||
b.height as i32,
|
||||
self.chat_abilities(),
|
||||
);
|
||||
screen.auto_suggestions = self.settings.auto_suggestions;
|
||||
self.ui.chat_screen = Some(screen);
|
||||
@@ -443,6 +447,10 @@ impl Game<'_> {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
/// `Minecraft.computeChatAbilities` from the Chat option.
|
||||
pub(super) fn chat_abilities(&self) -> ChatAbilities {
|
||||
ChatAbilities::new(ChatVisibility::from_option(self.settings.chat_visibility))
|
||||
}
|
||||
/// A chat message from the game (ChatComponent.addServerSystemMessage).
|
||||
pub(super) fn system_message(&mut self, text: Text) {
|
||||
if let Some(capture) = self.chat_capture.as_mut() {
|
||||
@@ -452,7 +460,32 @@ impl Game<'_> {
|
||||
// Connected to a server (single player's own included), the chat on
|
||||
// screen is the connection's: the game's own messages join it.
|
||||
if self.remote.is_some() {
|
||||
self.chat_line(text);
|
||||
self.chat_line(text, ChatSource::Server);
|
||||
return;
|
||||
}
|
||||
// The Chat option's Hidden level takes the server's messages too.
|
||||
self.local_chat_line(text, Some(SYSTEM_INDICATOR), ChatSource::Server);
|
||||
}
|
||||
/// A player's chat line on the window's own chat: a LAN guest's message
|
||||
/// while the host runs in-frame (`HostEvent::Chat`). `ChatListener`
|
||||
/// checks `canReceivePlayerMessages` for these, so Commands Only and
|
||||
/// Hidden drop them; command feedback (`/list`, `/tp`) is a system
|
||||
/// message and takes the system-message permission instead.
|
||||
pub(super) fn player_message(&mut self, text: Text) {
|
||||
if let Some(capture) = self.chat_capture.as_mut() {
|
||||
capture.push(text);
|
||||
return;
|
||||
}
|
||||
// A server's client receives the line as a packet (PlayerChat or
|
||||
// DisguisedChat) and filters it there.
|
||||
if self.remote.is_none() {
|
||||
self.local_chat_line(text, None, ChatSource::Player);
|
||||
}
|
||||
}
|
||||
/// A line on the window's own chat when the Chat option lets `source`
|
||||
/// through; running mods hear it.
|
||||
fn local_chat_line(&mut self, text: Text, indicator: Option<u32>, source: ChatSource) {
|
||||
if !self.ui.chat.shows(source) {
|
||||
return;
|
||||
}
|
||||
let focused = self.screen == Screen::Chat;
|
||||
@@ -460,7 +493,8 @@ impl Game<'_> {
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
text,
|
||||
Some(SYSTEM_INDICATOR),
|
||||
indicator,
|
||||
source,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
@@ -570,7 +604,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// ChatScreen.handleChatInput: history, then a command or a chat line.
|
||||
/// The Chat option's restrictions stop the message here too, so a
|
||||
/// control-socket `chat` cannot send what the player's setting hides.
|
||||
pub(super) fn handle_chat_input(&mut self, message: &str) {
|
||||
if !self.chat_abilities().allows(message) {
|
||||
return;
|
||||
}
|
||||
self.ui.chat.add_recent(message);
|
||||
// On a server (single player's own included) the server runs it,
|
||||
// unless a client mod owns the command.
|
||||
@@ -584,7 +623,10 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if let Some(command) = message.strip_prefix('/') {
|
||||
self.run_command(command);
|
||||
// A client mod's command first, then a server mod's, as on a server.
|
||||
if !self.mod_command(command) && !self.local_server_mod_command(command) {
|
||||
self.run_command(command);
|
||||
}
|
||||
} else {
|
||||
let line = Text::translate(
|
||||
"chat.type.text",
|
||||
@@ -592,13 +634,19 @@ impl Game<'_> {
|
||||
);
|
||||
let focused = self.screen == Screen::Chat;
|
||||
let heard = self.mods.running().then(|| line.plain());
|
||||
self.ui
|
||||
.chat
|
||||
.add(&self.gui.atlas, line, None, self.ticks, focused);
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
line,
|
||||
None,
|
||||
ChatSource::Player,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
if let Some(line) = heard {
|
||||
self.mods_chat(&line);
|
||||
}
|
||||
self.lan_host_chat(message);
|
||||
self.mod_player_chat(super::server_mods::Who::Local, message);
|
||||
}
|
||||
}
|
||||
pub(super) fn chat_scroll(&mut self, amount: f32) {
|
||||
@@ -898,9 +946,14 @@ impl Game<'_> {
|
||||
Err(e) => Text::translate("screenshot.failure", &[Text::literal(e.clone())]),
|
||||
};
|
||||
let focused = self.screen == Screen::Chat;
|
||||
self.ui
|
||||
.chat
|
||||
.add(&self.gui.atlas, message, None, self.ticks, focused);
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
message,
|
||||
None,
|
||||
ChatSource::Client,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, local time
|
||||
|
||||
@@ -63,8 +63,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Typing or a backspace in a field of the title screens: in the server
|
||||
/// screens' append-only fields Select All's selection is replaced first.
|
||||
/// Typing or a backspace in a title-screen field. A field that still only
|
||||
/// appends replaces a Select All selection before the edit.
|
||||
pub(super) fn title_edit(&mut self, typed: Option<&str>, backspace: bool) {
|
||||
let Some(title) = self.title.as_mut() else {
|
||||
return;
|
||||
@@ -86,42 +86,31 @@ impl Game<'_> {
|
||||
}
|
||||
/// Shortcuts in the creative inventory's search box (an `EditBox` of 50).
|
||||
pub(super) fn creative_search_shortcut(&mut self, shortcut: EditShortcut) {
|
||||
if Self::append_shortcut(
|
||||
&mut self.text_selection,
|
||||
&mut self.clipboard,
|
||||
CREATIVE_SEARCH,
|
||||
&mut self.creative_screen.search,
|
||||
shortcut,
|
||||
CREATIVE_SEARCH_CHARS,
|
||||
) {
|
||||
if self
|
||||
.creative_screen
|
||||
.search
|
||||
.shortcut(shortcut, &mut self.clipboard)
|
||||
{
|
||||
self.creative_screen.scroll = 0.0;
|
||||
}
|
||||
}
|
||||
/// A backspace in the creative search box: with Ctrl, the whole word
|
||||
/// before the caret (`EditBox.deleteWords(-1)`), otherwise one character.
|
||||
/// A Select All selection is what it deletes, as in `EditBox.keyPressed`.
|
||||
/// A selection is what it deletes, as in `EditBox.keyPressed`.
|
||||
pub(super) fn creative_search_backspace(&mut self, ctrl: bool) {
|
||||
let search = &mut self.creative_screen.search;
|
||||
if !self.text_selection.take(CREATIVE_SEARCH, search) {
|
||||
if ctrl {
|
||||
horizon_client::ui::delete_words(search, -1);
|
||||
} else {
|
||||
search.pop();
|
||||
}
|
||||
}
|
||||
self.creative_screen
|
||||
.search
|
||||
.key(KeyCode::Backspace, ctrl, false);
|
||||
self.creative_screen.scroll = 0.0;
|
||||
}
|
||||
/// Typing in the creative search box.
|
||||
pub(super) fn creative_search_edit(&mut self, typed: Option<&str>) {
|
||||
let search = &mut self.creative_screen.search;
|
||||
self.text_selection.take(CREATIVE_SEARCH, search);
|
||||
if let Some(text) = typed {
|
||||
clipboard::append_limited(search, text, CREATIVE_SEARCH_CHARS);
|
||||
self.creative_screen.search.insert_text(text);
|
||||
self.creative_screen.scroll = 0.0;
|
||||
}
|
||||
self.creative_screen.scroll = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen`'s search box: `setMaxLength(50)`.
|
||||
pub(super) const CREATIVE_SEARCH_CHARS: usize = 50;
|
||||
pub(super) const CREATIVE_SEARCH: horizon_client::clipboard::FieldId = ("creative.search", 0);
|
||||
@@ -52,11 +52,16 @@ impl Game<'_> {
|
||||
/// Remember who deals the next `damage_type` damage to the player.
|
||||
pub(super) fn note_attacker(&mut self, damage_type: &str, mob: u32) {
|
||||
if let Some(name) = self.mob_display_name(mob) {
|
||||
self.combat
|
||||
.pending
|
||||
.push((damage_type.to_owned(), (mob, name)));
|
||||
self.note_named_attacker(damage_type, mob, name);
|
||||
}
|
||||
}
|
||||
/// `note_attacker` for an entity that is not one of `self.mobs` (the
|
||||
/// ender dragon).
|
||||
pub(super) fn note_named_attacker(&mut self, damage_type: &str, id: u32, name: Text) {
|
||||
self.combat
|
||||
.pending
|
||||
.push((damage_type.to_owned(), (id, name)));
|
||||
}
|
||||
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
|
||||
/// source's `DamageEffects` (`Player.getHurtSound`).
|
||||
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Headless command runner for `tools/test_command_exec_parity.py`: with
|
||||
//! `HORIZON_COMMAND_SCRIPT` and `HORIZON_COMMAND_OUTPUT` set, the opened world
|
||||
//! runs without a window. Each script line is a JSON operation: `cmd` runs a
|
||||
//! runs on its server thread with a connected, headless client. Each script
|
||||
//! line is a JSON operation: `cmd` runs a
|
||||
//! command as the player (Commands.performPrefixedCommand) and records every
|
||||
//! system message it produced, as styled spans, with the results reported to
|
||||
//! the player's `CommandResultCallback`; `tick` advances the game; `settle`
|
||||
@@ -41,30 +42,100 @@ pub(super) fn spans(text: &Text) -> Value {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Pump the real client's connection, without predicting movement: the
|
||||
/// oracle's player stays where commands put it. Errors cross the server
|
||||
/// boundary through Integrated::stop, including script and save failures.
|
||||
pub(super) fn wait_command_harness(&mut self) -> Result<()> {
|
||||
let server = self.integrated.take().ok_or("command harness: no server")?;
|
||||
while !server.stopped() {
|
||||
self.poll_stationary_client()?;
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
server.stop()
|
||||
}
|
||||
|
||||
/// The owner's authoritative body and level, using the same swaps as a
|
||||
/// server command. Always put them back, also when a script operation fails.
|
||||
fn harness_owner<T>(&mut self, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
|
||||
let owner = self
|
||||
.serving
|
||||
.as_ref()
|
||||
.ok_or("command harness: not a server")?
|
||||
.owner;
|
||||
let mut host = self.lan.take().ok_or("command harness: no hub")?;
|
||||
let guest = host.hub.guests.iter().find(|g| g.uuid == owner && g.placed);
|
||||
let Some(guest) = guest else {
|
||||
self.lan = Some(host);
|
||||
return Err("command harness: owner is not connected".into());
|
||||
};
|
||||
let (conn, dim, back) = (guest.conn, super::Dim::from_id(&guest.dimension), self.dim);
|
||||
if let Err(error) = self.enter_level(Some(&mut host), dim) {
|
||||
self.lan = Some(host);
|
||||
return Err(error);
|
||||
}
|
||||
let swap = self
|
||||
.enter_guest(&mut host, conn)
|
||||
.expect("the owner is not already acting");
|
||||
self.lan = Some(host);
|
||||
let result = f(self);
|
||||
let mut host = self.lan.take().expect("the operation puts the hub back");
|
||||
self.leave_guest(&mut host, swap);
|
||||
host.hub.sync_all_effects();
|
||||
self.run_level_teleports(&mut host);
|
||||
let restored = self.enter_level(Some(&mut host), back);
|
||||
self.lan = Some(host);
|
||||
restored?;
|
||||
result
|
||||
}
|
||||
|
||||
/// The player's portal process finished in the nether portal it stands
|
||||
/// in (the oracle's player is not ticked, so neither is the wait).
|
||||
fn harness_portal(&mut self) -> Result<()> {
|
||||
let Some(p) = &self.player else {
|
||||
return Err("command harness: portal without a player".into());
|
||||
};
|
||||
let entry = horizon_core::position::BlockPos::containing(p.position);
|
||||
let block = self.world.block(entry.x, entry.y, entry.z);
|
||||
if &*self.res.facts().get(block).name != "minecraft:nether_portal" {
|
||||
return Err("command harness: the player is not in a nether portal".into());
|
||||
}
|
||||
self.player_through_nether_portal(entry)
|
||||
let (conn, plan) = self.harness_owner(|g| {
|
||||
let p = g
|
||||
.player
|
||||
.as_ref()
|
||||
.ok_or("command harness: portal without a player")?;
|
||||
let entry = horizon_core::position::BlockPos::containing(p.position);
|
||||
let block = g.world.block(entry.x, entry.y, entry.z);
|
||||
if &*g.res.facts().get(block).name != "minecraft:nether_portal" {
|
||||
return Err("command harness: the player is not in a nether portal".into());
|
||||
}
|
||||
let plan = g.portal_plan(
|
||||
entry,
|
||||
p.position,
|
||||
p.dimensions.width,
|
||||
p.dimensions.height,
|
||||
true,
|
||||
);
|
||||
Ok((
|
||||
g.lan_acting.ok_or("command harness: no acting owner")?,
|
||||
plan,
|
||||
))
|
||||
})?;
|
||||
// Start travel after restoring the owner's body: change_level updates
|
||||
// its hub record, which leave_guest would otherwise overwrite.
|
||||
self.in_owner_level(|g| {
|
||||
let mut host = g.lan.take().ok_or("command harness: no hub")?;
|
||||
g.start_server_trip(&mut host, conn, plan.dim, Some(plan));
|
||||
g.lan = Some(host);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
/// Load terrain until the player stands in lit chunks.
|
||||
fn harness_settle(&mut self, deadline: Instant) -> Result<()> {
|
||||
loop {
|
||||
self.update_world();
|
||||
self.spawn_player()?;
|
||||
if let Some(p) = &self.player {
|
||||
let (cx, cz) = (
|
||||
(p.position.x.floor() as i32) >> 4,
|
||||
(p.position.z.floor() as i32) >> 4,
|
||||
);
|
||||
if (-1..=1).all(|dz| (-1..=1).all(|dx| self.streaming.is_lit(cx + dx, cz + dz))) {
|
||||
// Finish requested terrain before inspecting it, so worker
|
||||
// timing cannot change which command positions are loaded.
|
||||
if !self.streaming.busy()
|
||||
&& (-1..=1).all(|dz| (-1..=1).all(|dx| self.streaming.is_lit(cx + dx, cz + dz)))
|
||||
{
|
||||
// The oracle's settle also waits for the entities saved
|
||||
// in those chunks (areEntitiesActuallyLoadedAndTicking):
|
||||
// after a dimension change they come back from the save.
|
||||
@@ -95,7 +166,19 @@ impl Game<'_> {
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(600),
|
||||
);
|
||||
self.harness_settle(deadline)?;
|
||||
// Join/configuration uses the normal hub, but never advances the world.
|
||||
while !self
|
||||
.lan
|
||||
.as_ref()
|
||||
.is_some_and(|h| h.hub.guests.iter().any(|g| g.placed && g.loaded))
|
||||
{
|
||||
self.tick_server(true)?;
|
||||
if Instant::now() > deadline {
|
||||
return Err("command harness: owner did not connect".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
self.harness_owner(|g| g.harness_settle(deadline))?;
|
||||
let mut out = String::new();
|
||||
for line in std::fs::read_to_string(script)?.lines() {
|
||||
if line.trim().is_empty() {
|
||||
@@ -107,9 +190,12 @@ impl Game<'_> {
|
||||
let command = op["cmd"].as_str().unwrap_or("");
|
||||
self.chat_capture = Some(Vec::new());
|
||||
self.cmds.results = Some(Vec::new());
|
||||
self.as_console(|g| {
|
||||
g.run_command(command.strip_prefix('/').unwrap_or(command))
|
||||
});
|
||||
self.harness_owner(|g| {
|
||||
g.as_console(|g| {
|
||||
g.run_command(command.strip_prefix('/').unwrap_or(command))
|
||||
});
|
||||
Ok(())
|
||||
})?;
|
||||
let messages = self.chat_capture.take().unwrap_or_default();
|
||||
let results = self.cmds.results.take().unwrap_or_default();
|
||||
if op["record"].as_bool() != Some(false) {
|
||||
@@ -124,17 +210,8 @@ impl Game<'_> {
|
||||
}
|
||||
"tick" => {
|
||||
self.chat_capture = Some(Vec::new());
|
||||
// The oracle's player has no client moving it: hold the
|
||||
// player where commands put it while the game ticks.
|
||||
let held = self.player.as_ref().map(|p| p.position);
|
||||
for _ in 0..op["n"].as_u64().unwrap_or(1) {
|
||||
self.update_world();
|
||||
self.tick()?;
|
||||
if let (Some(p), Some(at)) = (self.player.as_mut(), held) {
|
||||
p.position = at;
|
||||
p.velocity = horizon_core::geometry::Vec3::new(0.0, 0.0, 0.0);
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
self.tick_server(false)?;
|
||||
}
|
||||
let messages = self.chat_capture.take().unwrap_or_default();
|
||||
if op["record"].as_bool() == Some(true) {
|
||||
@@ -147,7 +224,17 @@ impl Game<'_> {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
"settle" => self.harness_settle(deadline)?,
|
||||
"settle" => {
|
||||
// Finish portal arrivals using connection-only passes too.
|
||||
while !self.server_trips.is_empty() {
|
||||
self.tick_server(true)?;
|
||||
if Instant::now() > deadline {
|
||||
return Err("command harness: portal did not arrive".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
self.harness_owner(|g| g.harness_settle(deadline))?;
|
||||
}
|
||||
"portal" => self.harness_portal()?,
|
||||
other => return Err(format!("command harness: unknown op {other}").into()),
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ mod items;
|
||||
mod misc;
|
||||
mod place;
|
||||
mod players;
|
||||
pub(super) mod pregen;
|
||||
mod raid;
|
||||
mod terrain_cmds;
|
||||
mod text_commands;
|
||||
@@ -84,16 +85,16 @@ fn format_double(v: f64) -> String {
|
||||
|
||||
impl Game<'_> {
|
||||
/// Commands.performCommand for a line without its slash.
|
||||
pub(super) fn run_command(&mut self, command: &str) {
|
||||
pub(super) fn run_command(&mut self, command: &str) -> bool {
|
||||
if self.mod_command(command) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
let d = vanilla::game_dispatcher();
|
||||
let parse = d.parse(command, &self.ui.registries);
|
||||
let result = match parse.executable() {
|
||||
Err(e) => {
|
||||
self.syntax_failure(&e, command);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
Ok((ctx, _)) => {
|
||||
let ctx = ctx.clone();
|
||||
@@ -102,6 +103,7 @@ impl Game<'_> {
|
||||
self.run_chain(&root, &ctx)
|
||||
}
|
||||
};
|
||||
let ok = result.is_ok();
|
||||
if let Err(e) = result {
|
||||
if e.input.is_some() {
|
||||
self.syntax_failure(&e, command);
|
||||
@@ -113,6 +115,20 @@ impl Game<'_> {
|
||||
if self.player.is_some() {
|
||||
self.check_inventory_trigger();
|
||||
}
|
||||
ok
|
||||
}
|
||||
/// `run_command` with its output suppressed
|
||||
/// (`CommandSourceStack.withSuppressedOutput`): the player's own source,
|
||||
/// rights and checks, no feedback and no failure in the chat.
|
||||
pub(super) fn run_command_quietly(&mut self, command: &str) -> bool {
|
||||
let quiet = execute::Source {
|
||||
silent: true,
|
||||
..self.player_source()
|
||||
};
|
||||
let outer = self.command_source.replace(quiet);
|
||||
let ok = self.run_command(command);
|
||||
self.command_source = outer;
|
||||
ok
|
||||
}
|
||||
fn syntax_failure(&mut self, e: &CommandError, command: &str) {
|
||||
self.command_failure(e.message.clone());
|
||||
@@ -751,13 +767,15 @@ impl Game<'_> {
|
||||
&& !id.ends_with("get")
|
||||
&& let Ok(t) = self.entities(ctx, "target", false)
|
||||
{
|
||||
self.cmds.waypoint_dirty.push(t[0]);
|
||||
let target = self.waypoint_target(t[0]);
|
||||
self.cmds.waypoint_dirty.push(target);
|
||||
}
|
||||
result
|
||||
}
|
||||
"ride.mount" | "ride.dismount" => self.cmd_ride(id, ctx),
|
||||
"spreadplayers" => self.cmd_spreadplayers(ctx),
|
||||
id if id.starts_with("forceload.") => self.cmd_forceload(id, ctx),
|
||||
id if id.starts_with("pregen.") => self.cmd_pregen(id, ctx),
|
||||
"fillbiome" => self.cmd_fillbiome(ctx),
|
||||
id if id.starts_with("worldborder.") => self.cmd_worldborder(id, ctx),
|
||||
id if id.starts_with("loot.") => self.cmd_loot(id, ctx),
|
||||
@@ -1094,6 +1112,12 @@ impl Game<'_> {
|
||||
Target::Player => {
|
||||
// ServerPlayer.teleport: removeVehicle before placing the player.
|
||||
self.stop_riding();
|
||||
// A server's player teleported within their level is placed
|
||||
// there at once: the arrival they were waiting for elsewhere
|
||||
// in it (whose chunks lose their tickets) is abandoned.
|
||||
if let Some(conn) = self.lan_acting {
|
||||
self.server_trips.retain(|trip| !trip.is(conn));
|
||||
}
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = v;
|
||||
p.velocity = Vec3::new(0.0, 0.0, 0.0);
|
||||
@@ -2499,12 +2523,14 @@ impl Game<'_> {
|
||||
};
|
||||
let max = Self::int(ctx, "maxCount").unwrap_or(-1);
|
||||
let counting = max == 0;
|
||||
// Inventory.clearOrCountMatchingItems: main, armor, crafting grid, carried.
|
||||
// Inventory slots 0..40: main, armor feet to head, off hand;
|
||||
// then clearOrCountMatchingItems visits the crafting grid and carried stack.
|
||||
let mut removed = 0i32;
|
||||
let mut budget = if max < 0 { i32::MAX } else { max };
|
||||
let all = |g: &Self| -> Vec<Option<ItemStack>> {
|
||||
let mut v: Vec<Option<ItemStack>> = g.inventory.clone();
|
||||
v.extend(g.armor.iter().cloned());
|
||||
v.extend(g.armor.iter().rev().cloned());
|
||||
v.push(g.offhand.clone());
|
||||
v.extend(g.craft_grid.iter().cloned());
|
||||
v.push(g.carried.clone());
|
||||
v
|
||||
@@ -2518,7 +2544,8 @@ impl Game<'_> {
|
||||
.collect();
|
||||
let mut slots: Vec<&mut Option<ItemStack>> = Vec::new();
|
||||
slots.extend(self.inventory.iter_mut());
|
||||
slots.extend(self.armor.iter_mut());
|
||||
slots.extend(self.armor.iter_mut().rev());
|
||||
slots.push(&mut self.offhand);
|
||||
slots.extend(self.craft_grid.iter_mut());
|
||||
slots.push(&mut self.carried);
|
||||
for (slot, matches) in slots.into_iter().zip(matching) {
|
||||
@@ -2836,9 +2863,14 @@ impl Game<'_> {
|
||||
capture.push(line.clone());
|
||||
}
|
||||
let focused = self.screen == Screen::Chat;
|
||||
self.ui
|
||||
.chat
|
||||
.add(&self.gui.atlas, line, None, self.ticks, focused);
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
line,
|
||||
None,
|
||||
horizon_client::chat::ChatSource::Player,
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
Ok(1)
|
||||
}
|
||||
/// MessageArgument: `@p`-style selectors in a message become names.
|
||||
|
||||
@@ -57,6 +57,8 @@ pub(in crate::game) struct CommandExtras {
|
||||
/// The `CommandResultCallback` results of the outermost source, when a
|
||||
/// harness records them.
|
||||
pub results: Option<Vec<(bool, i32)>>,
|
||||
/// The running `/pregen` task.
|
||||
pub pregen: Option<super::pregen::PregenTask>,
|
||||
}
|
||||
|
||||
use horizon_core::version::{BUILD_TIME, DATA_VERSION, STABLE, VANILLA_VERSION, VERSION_ID};
|
||||
@@ -212,8 +214,22 @@ impl Game<'_> {
|
||||
|
||||
// ---- /waypoint ------------------------------------------------------
|
||||
|
||||
/// A guest's body is only `Player` during its turn; retained command state
|
||||
/// must use its connection so the next server tick still finds that body.
|
||||
pub(super) fn waypoint_target(&self, t: Target) -> Target {
|
||||
match t {
|
||||
Target::Player => self.lan_acting.map_or(t, Target::Guest),
|
||||
_ => t,
|
||||
}
|
||||
}
|
||||
|
||||
/// A living entity's `waypoint_transmit_range` (`isTransmittingWaypoint`).
|
||||
fn transmit_range(&mut self, t: Target) -> f64 {
|
||||
if matches!(t, Target::Guest(_)) {
|
||||
return self
|
||||
.for_player(t, |g| g.transmit_range(Target::Player))
|
||||
.unwrap_or(0.0);
|
||||
}
|
||||
if t == Target::Player {
|
||||
return self
|
||||
.player
|
||||
@@ -242,7 +258,7 @@ impl Game<'_> {
|
||||
fn waypoint_transmitters(&mut self) -> Vec<Target> {
|
||||
if self.cmds.waypoints.is_none() {
|
||||
let tracked = if self.transmit_range(Target::Player) > 0.0 {
|
||||
vec![Target::Player]
|
||||
vec![self.waypoint_target(Target::Player)]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -255,6 +271,12 @@ impl Game<'_> {
|
||||
.collect();
|
||||
self.cmds.waypoints = Some(alive.clone());
|
||||
alive
|
||||
.into_iter()
|
||||
.map(|t| match t {
|
||||
Target::Guest(conn) if self.lan_acting == Some(conn) => Target::Player,
|
||||
_ => t,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The waypoint part of a living entity's tick: dirty transmit ranges
|
||||
/// track or untrack it.
|
||||
@@ -277,7 +299,7 @@ impl Game<'_> {
|
||||
fn waypoint_icon(&self, t: Target) -> WaypointIcon {
|
||||
self.cmds
|
||||
.waypoint_icons
|
||||
.get(&t)
|
||||
.get(&self.waypoint_target(t))
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -387,6 +409,7 @@ impl Game<'_> {
|
||||
&[Text::colored(name, rgb)],
|
||||
)
|
||||
};
|
||||
let t = self.waypoint_target(t);
|
||||
self.cmds.waypoint_icons.insert(t, icon);
|
||||
// WaypointCommand.mutateIcon: untrack, change, track again.
|
||||
self.waypoint_transmitters();
|
||||
@@ -606,6 +629,7 @@ impl Game<'_> {
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| lan.hub.singleplayer_owner != Some(g.uuid))
|
||||
.filter(|g| match &s.player_name {
|
||||
Some(name) => g.name.eq_ignore_ascii_case(name),
|
||||
None => s.kind == 'a' || s.kind == 'e',
|
||||
@@ -617,7 +641,7 @@ impl Game<'_> {
|
||||
if host.is_empty() && guests.is_empty() {
|
||||
return Err(err("argument.entity.notfound.player", &[]));
|
||||
}
|
||||
if self.lan.is_none() {
|
||||
if self.lan.is_none() || (self.serving() && self.published_port().is_none()) {
|
||||
return Err(err("commands.kick.singleplayer.failed", &[]));
|
||||
}
|
||||
let reason_raw = match ctx.get("reason") {
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
//! `/pregen`, Horizon's own command: generate, light and save every chunk of a
|
||||
//! square or a circle ahead of the players (docs/pregeneration.md). The task
|
||||
//! lives on the server and hands its band to the level's streaming, whose
|
||||
//! workers do the work; chunks behind the band unload and are saved as any
|
||||
//! chunk the players leave.
|
||||
use super::{CommandError, Context, Dim, Game, R};
|
||||
use horizon_client::{
|
||||
lan::data::component_nbt,
|
||||
pregen::{Pregen, Shape},
|
||||
text::{self, Text},
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How often the progress is reported.
|
||||
const REPORT_EVERY: Duration = Duration::from_secs(5);
|
||||
/// How long the band may stay put before the task looks for what holds it.
|
||||
const STALL: Duration = Duration::from_secs(30);
|
||||
|
||||
pub(in crate::game) struct PregenTask {
|
||||
pregen: Pregen,
|
||||
dim: Dim,
|
||||
paused: bool,
|
||||
/// Time spent running, up to the last pause, and when it last resumed.
|
||||
ran: Duration,
|
||||
resumed: Instant,
|
||||
reported: Instant,
|
||||
/// When the band last moved, to notice a task that stopped advancing.
|
||||
moved: Instant,
|
||||
/// The player who started it, told the progress too (a guest's connection).
|
||||
owner: Option<u32>,
|
||||
}
|
||||
|
||||
impl PregenTask {
|
||||
fn running_for(&self) -> Duration {
|
||||
if self.paused {
|
||||
self.ran
|
||||
} else {
|
||||
self.ran + self.resumed.elapsed()
|
||||
}
|
||||
}
|
||||
fn progress(&self) -> Text {
|
||||
let p = &self.pregen;
|
||||
let seconds = self.running_for().as_secs_f64();
|
||||
let rate = p.done as f64 / seconds.max(1e-3);
|
||||
let left = p.total - p.done;
|
||||
let eta = if rate > 0.0 {
|
||||
duration(left as f64 / rate)
|
||||
} else {
|
||||
"unknown".into()
|
||||
};
|
||||
Text::literal(format!(
|
||||
"Pregenerating {}: {} of {} chunks ({:.1}%), {rate:.1} chunks/s, {} left",
|
||||
self.dim.id(),
|
||||
p.done,
|
||||
p.total,
|
||||
100.0 * p.done as f64 / p.total.max(1) as f64,
|
||||
if p.done == p.total {
|
||||
"nothing".into()
|
||||
} else {
|
||||
eta
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(message: &str) -> CommandError {
|
||||
CommandError::new(Text::literal(message))
|
||||
}
|
||||
|
||||
/// `1h 02m 03s`.
|
||||
fn duration(seconds: f64) -> String {
|
||||
let s = seconds.round() as u64;
|
||||
match (s / 3600, s / 60 % 60, s % 60) {
|
||||
(0, 0, s) => format!("{s}s"),
|
||||
(0, m, s) => format!("{m}m {s:02}s"),
|
||||
(h, m, s) => format!("{h}h {m:02}m {s:02}s"),
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn cmd_pregen(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
match id {
|
||||
"pregen.start" => {
|
||||
if self.cmds.pregen.is_some() {
|
||||
return Err(failure(
|
||||
"Pregeneration is already running: /pregen cancel stops it",
|
||||
));
|
||||
}
|
||||
let radius = Self::int(ctx, "radius").unwrap_or(0);
|
||||
let shape = if ctx.literals.iter().any(|l| l == "circle") {
|
||||
Shape::Circle
|
||||
} else {
|
||||
Shape::Square
|
||||
};
|
||||
let [x, z] = if ctx.get("center").is_some() {
|
||||
self.column_arg(ctx, "center")?
|
||||
} else {
|
||||
let p = self.source_position();
|
||||
[p[0].floor() as i32, p[2].floor() as i32]
|
||||
};
|
||||
let pregen = Pregen::new((x >> 4, z >> 4), radius >> 4, shape);
|
||||
let now = Instant::now();
|
||||
let total = pregen.total;
|
||||
self.feedback(Text::literal(format!(
|
||||
"Pregenerating {total} chunks of {}: a {} of radius {radius} around {x} {z}",
|
||||
self.dim.id(),
|
||||
match shape {
|
||||
Shape::Square => "square",
|
||||
Shape::Circle => "circle",
|
||||
},
|
||||
)));
|
||||
self.cmds.pregen = Some(PregenTask {
|
||||
pregen,
|
||||
dim: self.dim,
|
||||
paused: false,
|
||||
ran: Duration::ZERO,
|
||||
resumed: now,
|
||||
reported: now,
|
||||
moved: now,
|
||||
owner: self.lan_acting,
|
||||
});
|
||||
Ok(total as i32)
|
||||
}
|
||||
"pregen.pause" | "pregen.continue" => {
|
||||
let pause = id == "pregen.pause";
|
||||
let task = self
|
||||
.cmds
|
||||
.pregen
|
||||
.as_mut()
|
||||
.ok_or_else(|| failure("No pregeneration is running"))?;
|
||||
if task.paused != pause {
|
||||
task.paused = pause;
|
||||
if pause {
|
||||
task.ran += task.resumed.elapsed();
|
||||
} else {
|
||||
task.resumed = Instant::now();
|
||||
}
|
||||
}
|
||||
let (progress, dim) = (task.progress(), task.dim);
|
||||
if pause && self.dim == dim {
|
||||
self.streaming.set_band(None);
|
||||
}
|
||||
self.feedback(
|
||||
Text::literal(if pause { "Paused. " } else { "Continuing. " }).append(progress),
|
||||
);
|
||||
Ok(1)
|
||||
}
|
||||
"pregen.cancel" => {
|
||||
let task = self
|
||||
.cmds
|
||||
.pregen
|
||||
.take()
|
||||
.ok_or_else(|| failure("No pregeneration is running"))?;
|
||||
if self.dim == task.dim {
|
||||
self.streaming.set_band(None);
|
||||
}
|
||||
self.feedback(Text::literal("Cancelled. ").append(task.progress()));
|
||||
Ok(task.pregen.done as i32)
|
||||
}
|
||||
_ => {
|
||||
let task = self
|
||||
.cmds
|
||||
.pregen
|
||||
.as_ref()
|
||||
.ok_or_else(|| failure("No pregeneration is running"))?;
|
||||
let progress = task.progress();
|
||||
let done = task.pregen.done as i32;
|
||||
self.feedback(progress);
|
||||
Ok(done)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the task's band past the rows that are lit and hand it to the
|
||||
/// level's streaming, and report the progress now and then. Runs before
|
||||
/// each streaming update of the level in place.
|
||||
pub(in crate::game) fn tick_pregen(&mut self) {
|
||||
let Some(task) = self.cmds.pregen.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if task.dim != self.dim || task.paused {
|
||||
return;
|
||||
}
|
||||
// A row is done once its chunks are lit and post-processed
|
||||
// (`postprocess_chunks`, on the tick), as vanilla's are by the time
|
||||
// they are FULL: then they may unload and be saved.
|
||||
let (streaming, chunks) = (&mut self.streaming, &self.world.chunks);
|
||||
if task.pregen.advance(|x, z| {
|
||||
streaming.is_lit(x, z)
|
||||
&& chunks
|
||||
.get(&(x, z))
|
||||
.is_some_and(|c| c.postprocess.is_empty())
|
||||
}) {
|
||||
task.moved = Instant::now();
|
||||
}
|
||||
streaming.set_band(task.pregen.band());
|
||||
if task.moved.elapsed() >= STALL {
|
||||
// Nothing advanced for a while: say what the first row waits
|
||||
// for, and queue light again for chunks that lost their job.
|
||||
task.moved = Instant::now();
|
||||
let row = task.pregen.first_row();
|
||||
let waiting = row
|
||||
.iter()
|
||||
.filter(|k| chunks.get(k).is_some_and(|c| !c.postprocess.is_empty()))
|
||||
.count();
|
||||
let [missing, lighting, unlit] = streaming.unstick_light(&mut self.world, &row);
|
||||
log::warn!(
|
||||
"Horizon: pregeneration waits on row {:?}: {missing} not loaded, {lighting} being lit, \
|
||||
{unlit} unlit with no light job (queued again), {waiting} not post-processed; {:?}",
|
||||
row.first(),
|
||||
streaming.stages()
|
||||
);
|
||||
}
|
||||
let finished = task.pregen.finished();
|
||||
if finished || task.reported.elapsed() >= REPORT_EVERY {
|
||||
task.reported = Instant::now();
|
||||
let (owner, mut message) = (task.owner, task.progress());
|
||||
if finished {
|
||||
message = Text::literal("Done. ")
|
||||
.append(message)
|
||||
.with_color(text::GREEN);
|
||||
self.cmds.pregen = None;
|
||||
}
|
||||
self.pregen_report(owner, message);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the level in place has a running task, which the server pumps
|
||||
/// between its ticks (`pump_pregen`).
|
||||
pub(in crate::game) fn pregen_runs_here(&self) -> bool {
|
||||
self.cmds
|
||||
.pregen
|
||||
.as_ref()
|
||||
.is_some_and(|t| t.dim == self.dim && !t.paused)
|
||||
}
|
||||
|
||||
/// The level a running task needs ticked, whoever is in it.
|
||||
pub(in crate::game) fn pregen_level(&self) -> Option<Dim> {
|
||||
self.cmds.pregen.as_ref().map(|t| t.dim)
|
||||
}
|
||||
|
||||
/// Keep the workers fed until `until`: collect what they finished and
|
||||
/// hand them the next jobs as soon as each one is done, rather than once
|
||||
/// a tick.
|
||||
pub(in crate::game) fn pump_pregen(&mut self, until: Instant) {
|
||||
while self.pregen_runs_here() {
|
||||
let now = Instant::now();
|
||||
if now >= until {
|
||||
break;
|
||||
}
|
||||
self.streaming
|
||||
.wait((until - now).min(Duration::from_millis(5)));
|
||||
self.update_world();
|
||||
}
|
||||
}
|
||||
|
||||
/// The console and the player who started the task get the progress.
|
||||
fn pregen_report(&mut self, owner: Option<u32>, message: Text) {
|
||||
if self.options.dedicated.is_some() {
|
||||
super::super::dedicated::log("INFO", &message.plain());
|
||||
} else {
|
||||
log::info!("{}", message.plain());
|
||||
}
|
||||
if let Some(host) = &self.lan {
|
||||
if let Some(g) = owner.and_then(|c| host.hub.guest(c)) {
|
||||
g.send(&C::SystemChat(play::SystemChat {
|
||||
content: component_nbt(&message.component()),
|
||||
overlay: false,
|
||||
}));
|
||||
}
|
||||
} else if self.options.dedicated.is_none() {
|
||||
self.system_message(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ fn ticks_to_seconds(ticks: i64) -> String {
|
||||
impl Game<'_> {
|
||||
// ---- /forceload ----------------------------------------------------------
|
||||
|
||||
fn column_arg(&self, ctx: &Context, name: &str) -> R<[i32; 2]> {
|
||||
pub(super) fn column_arg(&self, ctx: &Context, name: &str) -> R<[i32; 2]> {
|
||||
let c = self
|
||||
.coords(ctx, name)
|
||||
.ok_or_else(|| err("argument.pos2d.incomplete", &[]))?;
|
||||
|
||||
@@ -11,6 +11,9 @@ use winit::{event::MouseButton, event_loop::ActiveEventLoop, keyboard::KeyCode};
|
||||
|
||||
const HELP: &[&str] = &[
|
||||
"status game, player, streaming and renderer state",
|
||||
"simulation authoritative projectiles in the owner's level",
|
||||
"server-hold on|off hold simulation and player packets at a control-fixture barrier",
|
||||
"stall MILLISECONDS stall the window thread (0..5000), leaving transport alive",
|
||||
"key KEY down|up|tap|repeat press, release or auto-repeat a key (w a s d space shift ctrl e q esc f3 1-9 arrows ...)",
|
||||
"release release every key and mouse button",
|
||||
"mouse left|right|middle down|up|click",
|
||||
@@ -238,9 +241,57 @@ impl Game<'_> {
|
||||
}
|
||||
"sculk" => Ok(Some(self.sculk_command(&words)?)),
|
||||
"status" => Ok(Some(self.control_status())),
|
||||
"stall" => {
|
||||
let millis: u64 = arg(1)?.parse()?;
|
||||
if millis > 5000 {
|
||||
return Err("stall is limited to 5000 milliseconds".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(millis));
|
||||
ok()
|
||||
}
|
||||
"simulation" => {
|
||||
self.require_world()?;
|
||||
let value = if let Some(server) = &self.integrated {
|
||||
server
|
||||
.call(|g| {
|
||||
g.in_owner_level(|g| {
|
||||
json!({ "game_time": g.world.game_time, "projectiles": g.projectiles_status() })
|
||||
})
|
||||
})
|
||||
.ok_or("the integrated server stopped")?
|
||||
} else {
|
||||
json!({ "game_time": self.world.game_time, "projectiles": self.projectiles_status() })
|
||||
};
|
||||
Ok(Some(value))
|
||||
}
|
||||
"server-hold" => {
|
||||
self.require_world()?;
|
||||
let held = match arg(1)? {
|
||||
"on" => true,
|
||||
"off" => false,
|
||||
_ => return Err("server-hold on|off".into()),
|
||||
};
|
||||
if let Some(server) = &self.integrated {
|
||||
server
|
||||
.call(move |g| g.agent.hold_server = held)
|
||||
.ok_or("the integrated server stopped")?;
|
||||
} else if self.remote.is_some() {
|
||||
return Err("server-hold requires the world's host".into());
|
||||
} else {
|
||||
self.agent.hold_server = held;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
"pad" => Ok(Some(self.pad_command(&words)?)),
|
||||
"key" => {
|
||||
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
|
||||
// A server's dialog takes the keys, in configuration too.
|
||||
if self.dialog_open() {
|
||||
if arg(2)? != "up" {
|
||||
self.dialog_key(key, None, None)?;
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
self.require_world()?;
|
||||
// The sign editor takes raw presses (Enter, arrows, Backspace, Escape).
|
||||
if self.sign_edit.is_some() {
|
||||
@@ -288,11 +339,12 @@ impl Game<'_> {
|
||||
self.debug_key(key, KeyAction::Release);
|
||||
}
|
||||
}
|
||||
// The zoom extra sees the key as the keyboard hook does.
|
||||
// Mods' keys (capability `input`) see the key as the keyboard
|
||||
// hook does.
|
||||
let action = arg(2)?;
|
||||
self.extras_key(physical, action != "up", action == "repeat");
|
||||
self.mod_key(physical, action != "up", action == "repeat");
|
||||
if action == "tap" {
|
||||
self.extras_key(physical, false, false);
|
||||
self.mod_key(physical, false, false);
|
||||
}
|
||||
// Text screens consume raw presses; releases follow key binds.
|
||||
let typing = matches!(self.screen, Screen::Chat | Screen::Options);
|
||||
@@ -367,15 +419,19 @@ impl Game<'_> {
|
||||
match arg(2)? {
|
||||
"down" => {
|
||||
self.extras_mouse(button, true);
|
||||
self.mod_mouse_key(button, true);
|
||||
self.play_mouse(button, true)?
|
||||
}
|
||||
"up" => {
|
||||
self.extras_mouse(button, false);
|
||||
self.mod_mouse_key(button, false);
|
||||
self.play_mouse(button, false)?
|
||||
}
|
||||
"click" => {
|
||||
self.extras_mouse(button, true);
|
||||
self.mod_mouse_key(button, true);
|
||||
self.extras_mouse(button, false);
|
||||
self.mod_mouse_key(button, false);
|
||||
self.play_mouse(button, true)?;
|
||||
self.play_mouse(button, false)?;
|
||||
}
|
||||
@@ -496,10 +552,23 @@ impl Game<'_> {
|
||||
"mobs" => {
|
||||
self.require_world()?;
|
||||
let radius: f64 = words.get(1).map_or(Ok(64.0), |r| r.parse())?;
|
||||
if let Some(server) = &self.integrated {
|
||||
let center = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
|
||||
let value = server
|
||||
.call(move |g| g.in_owner_level(|g| g.control_mobs_at(radius, center)))
|
||||
.ok_or("the integrated server stopped")?;
|
||||
return Ok(Some(value));
|
||||
}
|
||||
Ok(Some(self.control_mobs(radius)))
|
||||
}
|
||||
"hurtlog" => {
|
||||
self.require_world()?;
|
||||
if let Some(server) = &self.integrated {
|
||||
let value = server
|
||||
.call(|g| g.control_hurt_log())
|
||||
.ok_or("the integrated server stopped")?;
|
||||
return Ok(Some(value));
|
||||
}
|
||||
Ok(Some(self.control_hurt_log()))
|
||||
}
|
||||
"resume" => {
|
||||
@@ -533,13 +602,17 @@ impl Game<'_> {
|
||||
))
|
||||
}
|
||||
"type" => {
|
||||
self.require_world()?;
|
||||
let text = line
|
||||
.trim_start()
|
||||
.strip_prefix("type")
|
||||
.unwrap_or("")
|
||||
.strip_prefix(' ')
|
||||
.unwrap_or("");
|
||||
// A server's dialog, in configuration too.
|
||||
if self.dialog_type(text) {
|
||||
return ok();
|
||||
}
|
||||
self.require_world()?;
|
||||
if self.book.is_some() {
|
||||
self.book_type(text);
|
||||
return ok();
|
||||
@@ -596,6 +669,7 @@ impl Game<'_> {
|
||||
"font" => Page::Font,
|
||||
"packs" => Page::Packs,
|
||||
"mods" => Page::Mods,
|
||||
"shader-packs" => Page::ShaderPacks,
|
||||
"graphics" => Page::Graphics,
|
||||
"graphics-advanced" => Page::GraphicsAdvanced,
|
||||
"extras" => Page::Extras,
|
||||
@@ -609,6 +683,10 @@ impl Game<'_> {
|
||||
}
|
||||
self.open_options(self.title.is_some());
|
||||
self.ui.options.show(page);
|
||||
if page == Page::ShaderPacks {
|
||||
self.settings.shader_packs =
|
||||
horizon_client::shader_pack_list::scan(&Self::shader_packs_dir());
|
||||
}
|
||||
ok()
|
||||
}
|
||||
"chat-log" => {
|
||||
|
||||
@@ -43,10 +43,6 @@ fn arrow_damage(arrow: &mut Projectile, fallback: &mut impl RandomSource) -> f32
|
||||
#[derive(Default)]
|
||||
pub(super) struct Creatures {
|
||||
pub projectiles: Vec<Projectile>,
|
||||
/// Player status effects applied by mobs: (effect, ticks left, amplifier).
|
||||
pub pearl_cooldown: u32,
|
||||
/// The wind charge's use cooldown (ticks).
|
||||
pub wind_cooldown: u32,
|
||||
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
|
||||
pub wind_bursts: Vec<super::wind::Burst>,
|
||||
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
|
||||
@@ -54,9 +50,9 @@ pub(super) struct Creatures {
|
||||
/// The wandering trader spawner's clock.
|
||||
pub trader: super::wandering_trader::Spawner,
|
||||
}
|
||||
struct ProjWorld<'a, 'w> {
|
||||
w: &'a MobWorld<'w>,
|
||||
targets: Vec<(Who, Aabb)>,
|
||||
pub(super) struct ProjWorld<'a, 'w> {
|
||||
pub w: &'a MobWorld<'w>,
|
||||
pub targets: Vec<(Who, Aabb)>,
|
||||
}
|
||||
impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
|
||||
@@ -316,6 +312,9 @@ impl Game<'_> {
|
||||
/// The `mobs` command: nearby mobs with position, health and running goals.
|
||||
pub(super) fn control_mobs(&self, radius: f64) -> serde_json::Value {
|
||||
let center = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
|
||||
self.control_mobs_at(radius, center)
|
||||
}
|
||||
pub(super) fn control_mobs_at(&self, radius: f64, center: Vec3) -> serde_json::Value {
|
||||
let list: Vec<serde_json::Value> = self
|
||||
.mobs
|
||||
.iter()
|
||||
@@ -374,6 +373,7 @@ impl Game<'_> {
|
||||
// Drawn seated (Entity.isPassenger); set apart because the
|
||||
// json! above is at the macro recursion limit.
|
||||
state["passenger"] = m.extra.passenger.into();
|
||||
state["guardian_beam"] = serde_json::json!(horizon_client::mobs::guardian::beam(m, 0.0));
|
||||
state
|
||||
})
|
||||
.collect();
|
||||
@@ -392,6 +392,9 @@ impl Game<'_> {
|
||||
}
|
||||
pub(super) fn mob_world_base<'s>(&'s self, others: &'s [Seen]) -> MobWorld<'s> {
|
||||
let (rain, thunder) = self.world.weather.levels(1.0);
|
||||
// Level.canHaveWeather: only the overworld's rain and thunder levels
|
||||
// ever rise, so nothing rains or thunders in the Nether or the End.
|
||||
let weather = self.dim == Dim::Overworld;
|
||||
let player = self.player_view();
|
||||
MobWorld {
|
||||
world: &self.world,
|
||||
@@ -408,8 +411,8 @@ impl Game<'_> {
|
||||
daylight: self.dim != Dim::Nether && self.sky_darken() < 4,
|
||||
game_time: self.world.game_time,
|
||||
day_time: self.world.day_time,
|
||||
raining: rain > 0.2,
|
||||
thundering: thunder > 0.9,
|
||||
raining: weather && rain > 0.2,
|
||||
thundering: weather && thunder > 0.9,
|
||||
nether: self.dim == Dim::Nether,
|
||||
block_tags: &self.res.items().block_tags,
|
||||
biomes: self.res.biomes(),
|
||||
@@ -508,6 +511,17 @@ impl Game<'_> {
|
||||
Some(host) if !guests.is_empty() => std::mem::take(&mut host.mob_players),
|
||||
_ => Default::default(),
|
||||
};
|
||||
// The guests' entity ids by connection, for the shulker bullets fired
|
||||
// at the guest a mob sees.
|
||||
let guest_ids: Vec<(u32, u32)> = match self.lan.as_ref() {
|
||||
Some(host) if !guests.is_empty() => host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.map(|g| (g.conn, g.entity_id as u32))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut guest_events = Vec::new();
|
||||
let level_random;
|
||||
{
|
||||
@@ -582,6 +596,19 @@ impl Game<'_> {
|
||||
let kept = m.tick(&w, &mut scratch, &mut events);
|
||||
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
|
||||
if let Some(conn) = seen_guest {
|
||||
// ShulkerBullet.finalTarget is the guest's entity: the
|
||||
// bullet homes on it while the projectiles tick, where
|
||||
// `Who::Player` is the host's own player.
|
||||
if let Some(&(_, id)) = guest_ids.iter().find(|g| g.0 == conn) {
|
||||
for e in &mut events[from..] {
|
||||
if let MobEvent::Projectile(p) = e
|
||||
&& let ProjectileKind::ShulkerBullet { target, .. } = &mut p.kind
|
||||
&& *target == Who::Player
|
||||
{
|
||||
*target = Who::Mob(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut k = from;
|
||||
while k < events.len() {
|
||||
if super::lan_mobs::acts_on_player(&events[k]) {
|
||||
@@ -642,7 +669,6 @@ impl Game<'_> {
|
||||
self.tick_allays();
|
||||
self.tick_breeze_effects();
|
||||
self.tick_sniffer_effects();
|
||||
self.tick_player_effects();
|
||||
self.piglin_pickups();
|
||||
self.sulfur_cube_pickups();
|
||||
// Hurt and death sounds of damage dealt outside the mob ticks.
|
||||
@@ -1040,7 +1066,10 @@ impl Game<'_> {
|
||||
}
|
||||
MobEvent::BreakBlock { pos, drop } => {
|
||||
let old = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.destroy_particles(pos, old);
|
||||
// Block.spawnDestroyParticles: fire breaks leave nothing.
|
||||
if !self.res.facts().get(old).has_class("BaseFireBlock") {
|
||||
self.destroy_particles(pos, old);
|
||||
}
|
||||
if drop {
|
||||
let drops = self.res.items().block_drops_in(
|
||||
self.pack.assets.registry,
|
||||
@@ -1062,6 +1091,9 @@ impl Game<'_> {
|
||||
self.ensure_item_particle_sprites();
|
||||
}
|
||||
let options = horizon_client::particles::ParticleOptions::simple(ty);
|
||||
if ty == horizon_client::particles::ParticleType::SonicBoom {
|
||||
self.lan_sonic_boom(&options, at);
|
||||
}
|
||||
// Count 0: one particle exactly there, at rest.
|
||||
if count == 0 {
|
||||
self.particles.add(&options, at, [0.0; 3]);
|
||||
@@ -1572,13 +1604,6 @@ impl Game<'_> {
|
||||
}
|
||||
self.give_player_effect("minecraft:wither", 40, 0);
|
||||
}
|
||||
/// Burning applied to the player by mobs.
|
||||
fn tick_player_effects(&mut self) {
|
||||
self.creatures.wind_cooldown = self.creatures.wind_cooldown.saturating_sub(1);
|
||||
if self.creatures.pearl_cooldown > 0 {
|
||||
self.creatures.pearl_cooldown -= 1;
|
||||
}
|
||||
}
|
||||
pub(super) fn hurt_player_by(&mut self, damage_type: &str, amount: f32) {
|
||||
if self.creative {
|
||||
return;
|
||||
@@ -2879,14 +2904,14 @@ impl Game<'_> {
|
||||
let kind = match item {
|
||||
"minecraft:snowball" => ProjectileKind::Snowball,
|
||||
"minecraft:egg" | "minecraft:blue_egg" | "minecraft:brown_egg" => ProjectileKind::Egg,
|
||||
"minecraft:ender_pearl" => {
|
||||
if self.creatures.pearl_cooldown > 0 {
|
||||
return Ok(true);
|
||||
"minecraft:ender_pearl" => ProjectileKind::EnderPearl,
|
||||
"minecraft:wind_charge" => {
|
||||
let thrown = self.throw_wind_charge()?;
|
||||
if thrown {
|
||||
self.apply_use_cooldown(stack);
|
||||
}
|
||||
self.creatures.pearl_cooldown = 20;
|
||||
ProjectileKind::EnderPearl
|
||||
return Ok(thrown);
|
||||
}
|
||||
"minecraft:wind_charge" => return self.throw_wind_charge(),
|
||||
_ if item.ends_with("_spawn_egg") => return self.use_spawn_egg(stack),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
@@ -2911,6 +2936,9 @@ impl Game<'_> {
|
||||
);
|
||||
self.creatures.projectiles.push(proj);
|
||||
self.player_projectile_shot();
|
||||
// ItemStack.use's applyAfterUseComponentSideEffects: the pearl's
|
||||
// second is the `use_cooldown` component, as for any thrown item.
|
||||
self.apply_use_cooldown(stack);
|
||||
let sound = match kind {
|
||||
ProjectileKind::Snowball => "minecraft:entity.snowball.throw",
|
||||
ProjectileKind::Egg => "minecraft:entity.egg.throw",
|
||||
@@ -3031,6 +3059,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
self.mark_entity(entity_start, &mob.kind.id);
|
||||
let light = self.world.light(
|
||||
mob.position.x.floor() as i32,
|
||||
(mob.position.y + mob.eye_height()).floor() as i32,
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
//! Cushions: `CushionItem.useOn` placement on the top face of a block,
|
||||
//! `Cushion.canBePlacedAt`/`survives`, `Cushion.interact` (sit),
|
||||
//! `BlockAttachedEntity.hurtServer` (break, dropping the matching colour),
|
||||
//! `BlockAttachedEntity.tick` and `CushionRenderer`.
|
||||
//!
|
||||
//! A cushion is a `BlockAttachedEntity`, not a block: it has no block state,
|
||||
//! only an entity whose `Pos` is the click point's height. Placement uses
|
||||
//! outline bounds, collision shapes and suffocation predicates.
|
||||
use super::{Game, Hit, view_vector};
|
||||
use horizon_client::entities::{Cushion, dye_index};
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_client::mobs::anim::Poses;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// `Direction.fromYRot(yaw).toYRot()`: the nearest 90 degree step.
|
||||
pub(crate) fn snap_yaw(yaw: f32) -> f32 {
|
||||
(((f64::from(yaw) / 90.0 + 0.5).floor() as i64) & 3) as f32 * 90.0
|
||||
}
|
||||
|
||||
fn aabb(bounds: ([f64; 3], [f64; 3])) -> Aabb {
|
||||
Aabb::new(
|
||||
Vec3::new(bounds.0[0], bounds.0[1], bounds.0[2]),
|
||||
Vec3::new(bounds.1[0], bounds.1[1], bounds.1[2]),
|
||||
)
|
||||
}
|
||||
|
||||
fn overlaps(a: Aabb, b: Aabb) -> bool {
|
||||
a.intersects(b)
|
||||
}
|
||||
use horizon_client::cushion::blocks_in;
|
||||
|
||||
struct PlacementWorld<'a, 'r>(&'a Game<'r>);
|
||||
impl horizon_client::cushion::World for PlacementWorld<'_, '_> {
|
||||
fn outline_bounds(&self, p: [i32; 3]) -> Option<Aabb> {
|
||||
let boxes = self.0.outline_boxes(p);
|
||||
let mut shape = *boxes.first()?;
|
||||
for b in &boxes[1..] {
|
||||
for k in 0..3 {
|
||||
shape[k] = shape[k].min(b[k]);
|
||||
shape[k + 3] = shape[k + 3].max(b[k + 3]);
|
||||
}
|
||||
}
|
||||
Some(Aabb::new(
|
||||
Vec3::new(shape[0], shape[1], shape[2]),
|
||||
Vec3::new(shape[3], shape[4], shape[5]),
|
||||
))
|
||||
}
|
||||
|
||||
fn collision_shapes(&self, bounds: Aabb) -> Vec<horizon_core::geometry::VoxelShape> {
|
||||
let binding = self.0.res.collision();
|
||||
let mut world = horizon_client::collisions::SceneCollisions {
|
||||
world: &self.0.world,
|
||||
binding,
|
||||
subject: None,
|
||||
};
|
||||
// Include neighbour blocks whose collision shape can reach outside their cell.
|
||||
blocks_in(bounds.inflate(Vec3::new(1.0, 1.0, 1.0)))
|
||||
.filter_map(|p| {
|
||||
let state = self.0.world.block(p[0], p[1], p[2]);
|
||||
binding
|
||||
.collision_shape(&mut world, state, p)
|
||||
.ok()
|
||||
.map(|s| s.moved(Vec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]))))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn suffocating(&self, p: [i32; 3]) -> bool {
|
||||
let state = self.0.world.block(p[0], p[1], p[2]);
|
||||
let facts = self.0.res.facts().get(state);
|
||||
let name = facts.name.as_ref();
|
||||
// Blocks' explicit predicates, then the default causes_suffocation/full-shape rule.
|
||||
match name {
|
||||
"minecraft:dirt_path"
|
||||
| "minecraft:farmland"
|
||||
| "minecraft:mud"
|
||||
| "minecraft:soul_sand" => true,
|
||||
"minecraft:piston" | "minecraft:sticky_piston" => self
|
||||
.0
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.is_some_and(|s| s.properties.get("extended").is_some_and(|p| p == "false")),
|
||||
"minecraft:mangrove_roots" | "minecraft:moving_piston" => false,
|
||||
_ if name.ends_with("_leaves")
|
||||
|| name.ends_with("glass")
|
||||
|| name.ends_with("copper_grate") =>
|
||||
{
|
||||
false
|
||||
}
|
||||
// ShulkerBoxBlock: suffocating while closed; a box seen open
|
||||
// here is rare enough to count as closed.
|
||||
_ if name.ends_with("shulker_box") => true,
|
||||
_ => {
|
||||
facts.full_collision
|
||||
&& self
|
||||
.0
|
||||
.res
|
||||
.items()
|
||||
.block_in_tag("minecraft:causes_suffocation", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The cushion under the crosshair, its distance and the hit point.
|
||||
pub(super) fn cushion_hit(&self) -> Option<(usize, f64, [f64; 3])> {
|
||||
let eye = self.eye_position()?;
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
|
||||
let mut best: Option<(usize, f64, [f64; 3])> = None;
|
||||
for (i, c) in self.cushions.iter().enumerate() {
|
||||
let Some(d) = super::ray_box(eye, dir, reach, aabb(c.bounds())) else {
|
||||
continue;
|
||||
};
|
||||
if best.is_none_or(|b| d < b.1) {
|
||||
best = Some((i, d, std::array::from_fn(|k| eye[k] + dir[k] * d)));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
pub(super) fn cushion_survives(&self, c: &Cushion) -> bool {
|
||||
horizon_client::cushion::survives(&PlacementWorld(self), aabb(c.bounds()))
|
||||
}
|
||||
|
||||
/// `Cushion.destroyIfInFire`: a fire-tagged block inside the deflated box.
|
||||
fn cushion_in_fire(&self, c: &Cushion) -> bool {
|
||||
let bounds = aabb(c.bounds()).next_deflated();
|
||||
blocks_in(bounds).any(|p| {
|
||||
let facts = self.res.facts().get(self.world.block(p[0], p[1], p[2]));
|
||||
self.res.items().block_in_tag("minecraft:fire", &facts.name)
|
||||
})
|
||||
}
|
||||
|
||||
/// `CushionItem.useOn`: on the top face only, spawning at the click's
|
||||
/// height above the clicked block (or the replaceable block itself).
|
||||
pub(super) fn place_cushion(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
|
||||
let Some(info) = self.res.items().get(&stack.item).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let Some(default_color) = info.cushion_color.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
let color_override = stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:cushion/color"))
|
||||
.and_then(|tag| match tag {
|
||||
horizon_world::nbt::Tag::String(s) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
});
|
||||
let color = dye_index(color_override.as_deref().unwrap_or(default_color));
|
||||
// CushionItem.recalculateContextForSpecialCollisionShapes: open containers
|
||||
// (cauldrons, hoppers and composters) use the collision ray's actual height.
|
||||
let mut adjusted = Hit {
|
||||
pos: hit.pos,
|
||||
face: hit.face,
|
||||
point: hit.point,
|
||||
};
|
||||
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
if self.res.items().block_in_tag(
|
||||
"minecraft:cushion_uses_collision_shape",
|
||||
&self.res.facts().get(state).name,
|
||||
) && let Some(eye) = self.eye_position()
|
||||
{
|
||||
let start = Vec3::new(eye[0], eye[1], eye[2]);
|
||||
let point = Vec3::new(hit.point[0], hit.point[1], hit.point[2]);
|
||||
let end = point.add_vector(point.subtract(start).normalize().scale(0.001));
|
||||
let binding = self.res.collision();
|
||||
let mut world = horizon_client::collisions::SceneCollisions {
|
||||
world: &self.world,
|
||||
binding,
|
||||
subject: None,
|
||||
};
|
||||
if let Ok(shape) = binding.collision_shape(&mut world, state, hit.pos)
|
||||
&& let Some(result) = shape.clip(
|
||||
start,
|
||||
end,
|
||||
horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
|
||||
)
|
||||
{
|
||||
adjusted.face = result.direction.normal();
|
||||
adjusted.point = [result.location.x, result.location.y, result.location.z];
|
||||
}
|
||||
}
|
||||
let hit = &adjusted;
|
||||
if hit.face != [0, 1, 0] {
|
||||
return false;
|
||||
}
|
||||
// BlockPlaceContext: a replaceable clicked block is replaced.
|
||||
let clicked = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]));
|
||||
let pos = if clicked.replaceable {
|
||||
hit.pos
|
||||
} else {
|
||||
std::array::from_fn(|i| hit.pos[i] + hit.face[i])
|
||||
};
|
||||
// `Vec3.atCenterOfWithY(clickedPos, clickLocation.y)`.
|
||||
let y = hit.point[1];
|
||||
let position = Vec3::new(f64::from(pos[0]) + 0.5, y, f64::from(pos[2]) + 0.5);
|
||||
let bounds = aabb((
|
||||
[position.x - 0.5, position.y, position.z - 0.5],
|
||||
[position.x + 0.5, position.y + 0.25, position.z + 0.5],
|
||||
));
|
||||
if !horizon_client::cushion::can_place(&PlacementWorld(self), bounds) {
|
||||
return false;
|
||||
}
|
||||
// `getEntitiesOfClass(Cushion.class, spawnAABB).isEmpty()`.
|
||||
if self
|
||||
.cushions
|
||||
.iter()
|
||||
.any(|c| overlaps(aabb(c.bounds()), bounds))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.next_entity += 1;
|
||||
let id = self.next_entity;
|
||||
// `BlockAttachedEntity.setPos`: `BlockPos.containing(position)`.
|
||||
let attached = [
|
||||
position.x.floor() as i32,
|
||||
position.y.floor() as i32,
|
||||
position.z.floor() as i32,
|
||||
];
|
||||
self.cushions.push(Cushion {
|
||||
id,
|
||||
position,
|
||||
pos: attached,
|
||||
yaw: snap_yaw(self.yaw),
|
||||
color,
|
||||
check_interval: 0,
|
||||
});
|
||||
self.world
|
||||
.modified
|
||||
.insert((attached[0] >> 4, attached[2] >> 4));
|
||||
// `destroyIfInFire`: placed into fire, it breaks at once.
|
||||
let placed = self.cushions.len() - 1;
|
||||
if self.cushion_in_fire(&self.cushions[placed]) {
|
||||
self.break_cushion(placed, false);
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.place",
|
||||
Some([position.x, position.y, position.z]),
|
||||
0.75,
|
||||
0.8,
|
||||
);
|
||||
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
|
||||
self.post_game_event(
|
||||
"minecraft:entity_place",
|
||||
[position.x, position.y, position.z],
|
||||
ctx,
|
||||
);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `Cushion.interact`: sit down unless sneaking or already occupied.
|
||||
pub(super) fn interact_cushion(&mut self) -> bool {
|
||||
let Some((i, d, _)) = self.cushion_hit() else {
|
||||
return false;
|
||||
};
|
||||
if !self.before_block(d) {
|
||||
return false;
|
||||
}
|
||||
// isSecondaryUseActive.
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) {
|
||||
return false;
|
||||
}
|
||||
let id = self.cushions[i].id;
|
||||
let occupied = self.riding == Some(id)
|
||||
|| self
|
||||
.lan
|
||||
.as_ref()
|
||||
.is_some_and(|h| h.hub.guests.iter().any(|g| g.vehicle == Some(id)));
|
||||
// `Cushion.interact`: an occupied cushion (`isVehicle`) passes.
|
||||
if occupied {
|
||||
return false;
|
||||
}
|
||||
// `Player.startRiding` gives up the vehicle it already has; the
|
||||
// client never sends that click, so pass instead.
|
||||
if self.riding.is_some() {
|
||||
return false;
|
||||
}
|
||||
self.riding = Some(id);
|
||||
let p = self.cushions[i].position;
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.sit",
|
||||
Some([p.x, p.y, p.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
|
||||
/// `Cushion.hurtServer` from a player: killed, then the item dropped.
|
||||
pub(super) fn attack_cushion(&mut self) -> bool {
|
||||
let Some((i, d, _)) = self.cushion_hit() else {
|
||||
return false;
|
||||
};
|
||||
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
|
||||
return false;
|
||||
}
|
||||
// `skipAttackInteraction` when `mayInteract` is false.
|
||||
if !self.may_build() {
|
||||
return true;
|
||||
}
|
||||
self.break_cushion(i, true);
|
||||
true
|
||||
}
|
||||
|
||||
/// `Cushion.dropItem`: the break sound and particles, then the coloured
|
||||
/// item unless drops are off or the breaker has infinite materials.
|
||||
pub(super) fn break_cushion(&mut self, index: usize, by_player: bool) {
|
||||
let c = self.cushions.remove(index);
|
||||
let p = c.position;
|
||||
self.world.modified.insert((c.pos[0] >> 4, c.pos[2] >> 4));
|
||||
if self.riding == Some(c.id) {
|
||||
self.riding = None;
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.break",
|
||||
Some([p.x, p.y, p.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
self.cushion_break_particles(&c);
|
||||
if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) {
|
||||
return;
|
||||
}
|
||||
let stack = ItemStack {
|
||||
item: std::sync::Arc::from(c.item()),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
self.spawn_item(stack, [p.x, p.y, p.z], [0.0; 3], 10);
|
||||
}
|
||||
|
||||
/// `Cushion.showBreakingParticles`: ten of the matching wool block.
|
||||
fn cushion_break_particles(&mut self, c: &Cushion) {
|
||||
let name = format!("minecraft:{}_wool", c.color_name());
|
||||
let Some(state) = self.pack.assets.registry.default_state(&name) else {
|
||||
return;
|
||||
};
|
||||
let at = [
|
||||
c.position.x.floor() as i32,
|
||||
c.position.y.floor() as i32,
|
||||
c.position.z.floor() as i32,
|
||||
];
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
|
||||
return;
|
||||
};
|
||||
let options = horizon_client::particles::ParticleOptions::block(
|
||||
horizon_client::particles::ParticleType::Block,
|
||||
sprite,
|
||||
tint,
|
||||
);
|
||||
let p = c.position;
|
||||
self.particles.send_particles(
|
||||
&options,
|
||||
[p.x, p.y + 0.666_666_7, p.z],
|
||||
[0.25, 0.0625, 0.25],
|
||||
0.05,
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
/// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100.
|
||||
pub(super) fn tick_cushions(&mut self) {
|
||||
let ticking = self.entity_ticking_area();
|
||||
let mut i = 0;
|
||||
while i < self.cushions.len() {
|
||||
let p = self.cushions[i].position;
|
||||
if !ticking(p.x.floor() as i32, p.z.floor() as i32) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let previous = self.cushions[i].check_interval;
|
||||
self.cushions[i].check_interval += 1;
|
||||
if previous < 100 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
self.cushions[i].check_interval = 0;
|
||||
let c = self.cushions[i].clone();
|
||||
if self.cushion_in_fire(&c) || !self.cushion_survives(&c) {
|
||||
self.break_cushion(i, false);
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// `Player.rideTick` on a cushion: hold the seat, and get up on sneak.
|
||||
pub(super) fn tick_cushion_riding(&mut self, keys: super::Keys) -> bool {
|
||||
let Some(c) = self
|
||||
.cushions
|
||||
.iter()
|
||||
.find(|c| Some(c.id) == self.riding)
|
||||
.cloned()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if keys.shift {
|
||||
let at = c.dismount();
|
||||
self.riding = None;
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = at;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
// `Cushion.removePassenger`: the get-up sound for a rider that
|
||||
// left rather than one whose cushion was killed.
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.get_up",
|
||||
Some([c.position.x, c.position.y, c.position.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
let seat = c.seat();
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = seat;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `CushionRenderer`: the coloured model, rotated to the nearest
|
||||
/// direction, with its bottom on the cushion's position.
|
||||
pub(super) fn cushion_geometry(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
out: &mut Vec<horizon_client::mesher::Vertex>,
|
||||
) {
|
||||
let Some(model) = self.cushion_model.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
for c in &self.cushions {
|
||||
let entity_start = out.len();
|
||||
let a = aabb(c.bounds());
|
||||
let (lo, hi) = (a.min_position(), a.max_position());
|
||||
let (lo, hi) = (
|
||||
glam::DVec3::new(lo.x, lo.y, lo.z),
|
||||
glam::DVec3::new(hi.x, hi.y, hi.z),
|
||||
);
|
||||
let range =
|
||||
super::entity_culling::default_range(hi - lo, self.settings.entity_distance);
|
||||
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
|
||||
continue;
|
||||
}
|
||||
let Some(uv) = sprites
|
||||
.get(&format!("entity/cushion/{}_cushion", c.color_name()))
|
||||
.map(|r| [r.u0, r.v0, r.u1, r.v1])
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let p = glam::DVec3::new(c.position.x, c.position.y, c.position.z) - camera;
|
||||
let base = glam::Mat4::from_translation(p.as_vec3())
|
||||
* glam::Mat4::from_rotation_y((180.0 - snap_yaw(c.yaw)).to_radians())
|
||||
* glam::Mat4::from_rotation_x(std::f32::consts::PI)
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0));
|
||||
// `EntityRenderer.getPackedLightCoords` probes at the eye height.
|
||||
let l = self.world.light(
|
||||
c.position.x.floor() as i32,
|
||||
(c.position.y + 0.2125).floor() as i32,
|
||||
c.position.z.floor() as i32,
|
||||
);
|
||||
let packed = (u32::from(l >> 4) * 16) | ((u32::from(l & 15) * 16) << 8);
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base,
|
||||
&Poses::new(model),
|
||||
uv,
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -415,6 +415,13 @@ impl Game<'_> {
|
||||
],
|
||||
);
|
||||
}
|
||||
// Horizon's own: the shader pack's GPU time per pass (an empty group would move others).
|
||||
let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
|
||||
debug_overlay::shader_pass_lines(&r.shader_pass_times())
|
||||
});
|
||||
if !pass_lines.is_empty() {
|
||||
d.group("shader pack", pass_lines);
|
||||
}
|
||||
// tps: DebugEntryTps for local and remote connections.
|
||||
if self.player.is_some() {
|
||||
let [tx, rx] = self.debug_charts.packet_rates.map(|n| n.round() as i64);
|
||||
|
||||
@@ -155,6 +155,14 @@ pub(super) fn preflight(options: &mut Options) -> Result<bool> {
|
||||
for w in &props.warnings {
|
||||
log("WARN", w);
|
||||
}
|
||||
// Horizon's own commands are the owner's choice, off as on vanilla.
|
||||
if props
|
||||
.raw
|
||||
.get(horizon_client::command::vanilla::PREGEN_PROPERTY)
|
||||
.is_some_and(|v| v.trim().eq_ignore_ascii_case("true"))
|
||||
{
|
||||
horizon_client::command::vanilla::set_horizon_commands(true);
|
||||
}
|
||||
props.store(&settings)?;
|
||||
let eula_file = PathBuf::from("eula.txt");
|
||||
let agreed = match eula::agreed(&eula_file) {
|
||||
@@ -667,7 +675,9 @@ impl Game<'_> {
|
||||
self.poll_server_control(&mut state);
|
||||
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
|
||||
if pause_after > 0 {
|
||||
if players == 0 {
|
||||
// A `/pregen` task keeps the server busy as a player would:
|
||||
// paused, nothing would post-process its chunks.
|
||||
if players == 0 && self.pregen_level().is_none() {
|
||||
state.empty_ticks += 1;
|
||||
} else {
|
||||
state.empty_ticks = 0;
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
//! A server's dialogs bound to the game (`ClientCommonPacketListenerImpl.
|
||||
//! showDialog` and `clearDialog`, `DialogScreen`, its `WarningScreen` and the
|
||||
//! `WaitingForResponseScreen`): the screen over whatever is open (the
|
||||
//! connecting screen in configuration), its inputs, and the actions its
|
||||
//! buttons send back.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
clipboard::EditShortcut,
|
||||
confirm_screen::Confirm,
|
||||
dialog::{AfterAction, Click, Control, Dialog, InputValue},
|
||||
dialog_screen::{BOX, Layout, Part, WARNING_SPRITES, waiting_button},
|
||||
hud::{HudBatch, rgba},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::time::Instant;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// An open `DialogScreen`.
|
||||
struct Open {
|
||||
dialog: Dialog,
|
||||
values: Vec<InputValue>,
|
||||
/// The box of each text input.
|
||||
fields: Vec<Option<ui::EditBox>>,
|
||||
focus: Option<usize>,
|
||||
scroll: ui::Scroll,
|
||||
}
|
||||
|
||||
/// The dialog screens a server can open.
|
||||
#[derive(Default)]
|
||||
pub(super) struct DialogState {
|
||||
open: Option<Open>,
|
||||
/// `WaitingForResponseScreen`, since when.
|
||||
waiting: Option<Instant>,
|
||||
/// `DialogScreen.WarningScreen`, over the dialog.
|
||||
warning: bool,
|
||||
}
|
||||
|
||||
fn warning_texts() -> (Text, Text, [Text; 2]) {
|
||||
let t = |key: &str| Text::translate(key, &[]);
|
||||
(
|
||||
t("menu.custom_screen_info.title"),
|
||||
t("menu.custom_screen_info.contents"),
|
||||
[t("menu.disconnect"), t("gui.back")],
|
||||
)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether a dialog screen takes the input.
|
||||
pub(super) fn dialog_open(&self) -> bool {
|
||||
let d = &self.dialogs;
|
||||
d.open.is_some() || d.waiting.is_some() || d.warning
|
||||
}
|
||||
|
||||
/// `showDialog`: the dialog replaces an open one (and a waiting screen);
|
||||
/// under the warning screen, it is what Back returns to.
|
||||
pub(super) fn show_dialog(&mut self, tag: &Tag) {
|
||||
let Some(dialog) = Dialog::parse(tag) else {
|
||||
log::warn!("Horizon: failed to show dialog for data {tag:?}");
|
||||
return;
|
||||
};
|
||||
let values = dialog.inputs.iter().map(|i| i.control.initial()).collect();
|
||||
let fields = dialog
|
||||
.inputs
|
||||
.iter()
|
||||
.map(|i| match &i.control {
|
||||
Control::Text {
|
||||
width,
|
||||
initial,
|
||||
max_length,
|
||||
..
|
||||
} => {
|
||||
let mut edit = ui::EditBox::new(0.0, 0.0, *width as f32, 20.0);
|
||||
edit.max_length = (*max_length).max(1) as usize;
|
||||
edit.set_value(initial);
|
||||
edit.focused = false;
|
||||
Some(edit)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
self.dialogs.open = Some(Open {
|
||||
dialog,
|
||||
values,
|
||||
fields,
|
||||
focus: None,
|
||||
scroll: ui::Scroll::default(),
|
||||
});
|
||||
self.dialogs.waiting = None;
|
||||
self.capture(false);
|
||||
}
|
||||
|
||||
/// `clearDialog`: the dialog closes (under the warning screen, Back then
|
||||
/// returns to what was open before it).
|
||||
pub(super) fn clear_dialog(&mut self) {
|
||||
if self.dialogs.open.take().is_some() && !self.dialog_open() {
|
||||
self.dialog_closed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Every dialog screen goes (a disconnect, or the level loading screen).
|
||||
pub(super) fn close_dialogs(&mut self) {
|
||||
self.dialogs = DialogState::default();
|
||||
}
|
||||
|
||||
fn dialog_closed(&mut self) {
|
||||
if self.player.is_some() && self.screen == super::Screen::Playing && self.title.is_none() {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// The input values as the controls hold them now.
|
||||
fn dialog_values(open: &mut Open) {
|
||||
for (value, field) in open.values.iter_mut().zip(&open.fields) {
|
||||
if let Some(f) = field {
|
||||
*value = InputValue::Text(f.value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `DialogScreen.runAction`: the click event, then the screen the dialog's
|
||||
/// `after_action` leaves.
|
||||
fn run_dialog_action(&mut self, click: Option<Click>, after: AfterAction) -> Result<()> {
|
||||
match after {
|
||||
AfterAction::None => {}
|
||||
AfterAction::Close => self.dialogs.open = None,
|
||||
AfterAction::WaitForResponse => {
|
||||
self.dialogs.open = None;
|
||||
self.dialogs.waiting = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
match click {
|
||||
Some(Click::Custom { id, payload }) => {
|
||||
if let Some(r) = &self.remote {
|
||||
r.session.send_custom_click(&id, payload);
|
||||
}
|
||||
}
|
||||
Some(Click::RunCommand(command)) => {
|
||||
// In configuration there is nothing to run it (vanilla warns).
|
||||
if self.player.is_some() {
|
||||
let command = command.strip_prefix('/').unwrap_or(&command);
|
||||
self.send_chat(&format!("/{command}"));
|
||||
} else {
|
||||
log::warn!(
|
||||
"Horizon: commands are not supported in configuration, trying to run '{command}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(Click::ShowDialog(dialog)) => self.show_dialog_ref(&dialog),
|
||||
Some(Click::CopyToClipboard(value)) => {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&value);
|
||||
}
|
||||
Some(other) => log::info!("Horizon: dialog action {other:?} is not supported yet"),
|
||||
None => {}
|
||||
}
|
||||
if !self.dialog_open() {
|
||||
self.dialog_closed();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A `show_dialog` click event's dialog: a registry id or the dialog itself.
|
||||
fn show_dialog_ref(&mut self, dialog: &Tag) {
|
||||
match dialog {
|
||||
Tag::String(id) => {
|
||||
let id = id.to_string_lossy();
|
||||
let name = if id.contains(':') {
|
||||
id.clone()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
};
|
||||
let found = self.remote.as_ref().and_then(|r| {
|
||||
let entries = r.registries.registries.get("minecraft:dialog")?;
|
||||
entries.iter().find(|(n, _)| *n == name)?.1.clone()
|
||||
});
|
||||
match found {
|
||||
Some(tag) => self.show_dialog(&tag),
|
||||
None => log::warn!("Horizon: unknown dialog {id}"),
|
||||
}
|
||||
}
|
||||
tag => self.show_dialog(tag),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientboundShowDialogPacket` in play: a registry entry or the dialog.
|
||||
pub(super) fn show_play_dialog(
|
||||
&mut self,
|
||||
dialog: &horizon_protocol::packets::play::DialogHolder,
|
||||
) {
|
||||
use horizon_protocol::packets::play::DialogHolder;
|
||||
match dialog {
|
||||
DialogHolder::Direct(tag) => self.show_dialog(tag),
|
||||
DialogHolder::Registry(id) => {
|
||||
let tag = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.registries.data("minecraft:dialog", *id).cloned());
|
||||
match tag {
|
||||
Some(tag) => self.show_dialog(&tag),
|
||||
None => log::warn!("Horizon: unknown dialog {id}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dialog_layout(&self) -> Option<Layout> {
|
||||
let open = self.dialogs.open.as_ref()?;
|
||||
Some(Layout::new(
|
||||
&open.dialog,
|
||||
&self.gui.atlas.font,
|
||||
self.gui_size(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn draw_dialog(&mut self, batch: &mut HudBatch) {
|
||||
if !self.dialog_open() {
|
||||
return;
|
||||
}
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let cursor = self.cursor;
|
||||
let atlas = &self.gui.atlas;
|
||||
if self.player.is_none() {
|
||||
// In place of the connecting screen, on its background.
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, rgba(20, 20, 20, 255));
|
||||
}
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.player.is_some());
|
||||
if self.dialogs.warning {
|
||||
let (title, message, [disconnect, back]) = warning_texts();
|
||||
Confirm::new(&atlas.font, (w, h), &title, &message).draw(
|
||||
batch,
|
||||
atlas,
|
||||
cursor,
|
||||
[&disconnect, &back],
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Some(since) = self.dialogs.waiting {
|
||||
// HeaderAndFooterLayout(33, 0) centred: the title, the button 33 below.
|
||||
let ticks = (since.elapsed().as_millis() / 50) as u32;
|
||||
let (label, visible, active) = waiting_button(ticks);
|
||||
let title = Text::translate("gui.waitingForResponse.title", &[]);
|
||||
let top = ((h - 53.0) / 2.0).trunc();
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&title,
|
||||
((w - tw) / 2.0).trunc(),
|
||||
top + 12.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
if visible {
|
||||
let (bx, by) = (((w - 200.0) / 2.0).trunc(), top + 33.0);
|
||||
let hover = ui::hovered(cursor, bx, by, 200.0, 20.0);
|
||||
ui::button(batch, atlas, bx, by, 200.0, 20.0, &label, active, hover);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return;
|
||||
};
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
let [ax, ay, aw, ah] = layout.area;
|
||||
open.scroll.clamp(ay, ay + ah, layout.content);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&open.dialog.title,
|
||||
layout.title.0,
|
||||
layout.title.1,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
let [wx, wy, ww, wh] = layout.warning;
|
||||
let warn_hover = ui::hovered(cursor, wx, wy, ww, wh);
|
||||
horizon_client::widgets::record("warning", layout.warning, true);
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
WARNING_SPRITES[if warn_hover { 2 } else { 0 }],
|
||||
wx,
|
||||
wy,
|
||||
ww,
|
||||
wh,
|
||||
u32::MAX,
|
||||
);
|
||||
let buttons = open.dialog.buttons();
|
||||
let in_area = |y: f32| y >= ay && y < ay + ah;
|
||||
let mut tooltip = None;
|
||||
let mut start = None;
|
||||
for (part, clipped) in layout.placed(open.scroll.amount) {
|
||||
if clipped && start.is_none() {
|
||||
start = Some(batch.vertices.len());
|
||||
} else if !clipped && let Some(s) = start.take() {
|
||||
batch.clip_since(s, ax, ay, ax + aw, ay + ah);
|
||||
}
|
||||
match part {
|
||||
Part::Line(text, x, y) => {
|
||||
batch.text_styled(atlas, &text, x, y, u32::MAX, true);
|
||||
}
|
||||
Part::Field { input, rect } => {
|
||||
if let Some(Some(edit)) = open.fields.get_mut(input) {
|
||||
[edit.x, edit.y, edit.width, edit.height] = rect;
|
||||
edit.focused = open.focus == Some(input);
|
||||
edit.draw(batch, atlas, None);
|
||||
}
|
||||
}
|
||||
Part::Check { input, rect } => {
|
||||
let [x, y, cw, ch] = rect;
|
||||
let hover = in_area(cursor.1) && ui::hovered(cursor, x, y, cw, ch);
|
||||
let on = open.values.get(input) == Some(&InputValue::Bool(true));
|
||||
let sprite = match (on, hover) {
|
||||
(true, true) => "gui/sprites/widget/checkbox_selected_highlighted",
|
||||
(true, false) => "gui/sprites/widget/checkbox_selected",
|
||||
(false, true) => "gui/sprites/widget/checkbox_highlighted",
|
||||
(false, false) => "gui/sprites/widget/checkbox",
|
||||
};
|
||||
let Control::Boolean { label, .. } = &open.dialog.inputs[input].control else {
|
||||
continue;
|
||||
};
|
||||
horizon_client::widgets::record(&label.plain(), rect, true);
|
||||
batch.sprite_scaled(atlas, sprite, x, y, BOX, BOX, u32::MAX);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
label,
|
||||
x + BOX + 4.0,
|
||||
y + 4.0,
|
||||
rgba(224, 224, 224, 255),
|
||||
true,
|
||||
);
|
||||
}
|
||||
Part::Button { index, rect } => {
|
||||
let [x, y, bw, bh] = rect;
|
||||
let hover =
|
||||
(!clipped || in_area(cursor.1)) && ui::hovered(cursor, x, y, bw, bh);
|
||||
let b = buttons[index];
|
||||
ui::button(batch, atlas, x, y, bw, bh, &b.label, true, hover);
|
||||
if hover && let Some(t) = &b.tooltip {
|
||||
tooltip = Some(t.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = start {
|
||||
batch.clip_since(s, ax, ay, ax + aw, ay + ah);
|
||||
}
|
||||
if layout.content > ah {
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
ax + aw - 6.0,
|
||||
ay,
|
||||
ay + ah,
|
||||
layout.content,
|
||||
open.scroll.amount,
|
||||
);
|
||||
}
|
||||
if warn_hover {
|
||||
tooltip = Some(Text::translate("menu.custom_screen_info.tooltip", &[]));
|
||||
}
|
||||
if let Some(t) = tooltip {
|
||||
let lines: Vec<Text> = atlas
|
||||
.font
|
||||
.split_lines(&t, 170.0)
|
||||
.into_iter()
|
||||
.map(|l| l.0)
|
||||
.collect();
|
||||
ui::tooltip(batch, atlas, &lines, cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// A left click on a dialog screen.
|
||||
pub(super) fn dialog_click(&mut self) -> Result<()> {
|
||||
let cursor = self.cursor;
|
||||
if self.dialogs.warning {
|
||||
let (title, message, _) = warning_texts();
|
||||
let answer = Confirm::new(&self.gui.atlas.font, self.gui_size(), &title, &message)
|
||||
.answer_at(cursor);
|
||||
match answer {
|
||||
Some(true) => {
|
||||
self.audio.play_button_click();
|
||||
let reason = Text::translate("menu.custom_screen_info.disconnect", &[]);
|
||||
return self.leave_server(Some(reason.plain()));
|
||||
}
|
||||
Some(false) => {
|
||||
self.audio.play_button_click();
|
||||
self.dialogs.warning = false;
|
||||
if !self.dialog_open() {
|
||||
self.dialog_closed();
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(since) = self.dialogs.waiting {
|
||||
let (w, h) = self.gui_size();
|
||||
let active = waiting_button((since.elapsed().as_millis() / 50) as u32).2;
|
||||
let (bx, by) = (
|
||||
((w - 200.0) / 2.0).trunc(),
|
||||
((h - 53.0) / 2.0).trunc() + 33.0,
|
||||
);
|
||||
if active && ui::hovered(cursor, bx, by, 200.0, 20.0) {
|
||||
self.audio.play_button_click();
|
||||
self.dialogs.waiting = None;
|
||||
self.dialog_closed();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return Ok(());
|
||||
};
|
||||
let [wx, wy, ww, wh] = layout.warning;
|
||||
if ui::hovered(cursor, wx, wy, ww, wh) {
|
||||
self.audio.play_button_click();
|
||||
self.dialogs.warning = true;
|
||||
return Ok(());
|
||||
}
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let [_, ay, _, ah] = layout.area;
|
||||
let mut action = None;
|
||||
open.focus = None;
|
||||
for (part, clipped) in layout.placed(open.scroll.amount) {
|
||||
if clipped && !(cursor.1 >= ay && cursor.1 < ay + ah) {
|
||||
continue;
|
||||
}
|
||||
match part {
|
||||
Part::Field {
|
||||
input,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => {
|
||||
open.focus = Some(input);
|
||||
if let Some(Some(edit)) = open.fields.get_mut(input) {
|
||||
edit.focused = true;
|
||||
edit.click(&self.gui.atlas, cursor.0, false, false);
|
||||
}
|
||||
}
|
||||
Part::Check {
|
||||
input,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => {
|
||||
if let Some(InputValue::Bool(b)) = open.values.get_mut(input) {
|
||||
*b = !*b;
|
||||
}
|
||||
action = Some(None);
|
||||
}
|
||||
Part::Button {
|
||||
index,
|
||||
rect: [x, y, w, h],
|
||||
} if ui::hovered(cursor, x, y, w, h) => {
|
||||
action = Some(Some(index));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let Some(action) = action else {
|
||||
return Ok(());
|
||||
};
|
||||
self.audio.play_button_click();
|
||||
let Some(index) = action else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
Self::dialog_values(open);
|
||||
let d = &open.dialog;
|
||||
let click = d.buttons()[index]
|
||||
.action
|
||||
.as_ref()
|
||||
.and_then(|a| a.click(&d.inputs, &open.values));
|
||||
let after = d.after_action;
|
||||
self.run_dialog_action(click, after)
|
||||
}
|
||||
|
||||
/// The mouse wheel over a dialog: its body scrolls 10 a step.
|
||||
pub(super) fn dialog_scroll(&mut self, delta: f32) {
|
||||
let Some(layout) = self.dialog_layout() else {
|
||||
return;
|
||||
};
|
||||
if let Some(open) = self.dialogs.open.as_mut() {
|
||||
let [_, ay, _, ah] = layout.area;
|
||||
open.scroll.wheel(delta, 10.0, ay, ay + ah, layout.content);
|
||||
}
|
||||
}
|
||||
|
||||
/// A key pressed on a dialog screen: Escape closes what allows it, Tab
|
||||
/// moves between the text inputs, the rest goes to the focused one
|
||||
/// (`shortcut`: the press as an edit shortcut; `text`: what it types).
|
||||
pub(super) fn dialog_key(
|
||||
&mut self,
|
||||
key: KeyCode,
|
||||
shortcut: Option<EditShortcut>,
|
||||
text: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let escape = key == KeyCode::Escape;
|
||||
if self.dialogs.warning {
|
||||
if escape {
|
||||
self.dialogs.warning = false;
|
||||
if !self.dialog_open() {
|
||||
self.dialog_closed();
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(since) = self.dialogs.waiting {
|
||||
// shouldCloseOnEsc: once Back works.
|
||||
if escape && waiting_button((since.elapsed().as_millis() / 50) as u32).2 {
|
||||
self.dialogs.waiting = None;
|
||||
self.dialog_closed();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let ctrl = self.modifiers.control_key() || self.modifiers.super_key();
|
||||
let shift = self.modifiers.shift_key();
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
if escape {
|
||||
if !open.dialog.can_close_with_escape {
|
||||
return Ok(());
|
||||
}
|
||||
// onClose: the cancel action, and the screen closes.
|
||||
Self::dialog_values(open);
|
||||
let d = &open.dialog;
|
||||
let click = d.on_cancel().and_then(|a| a.click(&d.inputs, &open.values));
|
||||
return self.run_dialog_action(click, AfterAction::Close);
|
||||
}
|
||||
if key == KeyCode::Tab {
|
||||
let texts: Vec<usize> = (0..open.fields.len())
|
||||
.filter(|&i| open.fields[i].is_some())
|
||||
.collect();
|
||||
if !texts.is_empty() {
|
||||
let at = open.focus.and_then(|f| texts.iter().position(|&i| i == f));
|
||||
let next = match at {
|
||||
None if shift => texts.len() - 1,
|
||||
None => 0,
|
||||
Some(p) if shift => (p + texts.len() - 1) % texts.len(),
|
||||
Some(p) => (p + 1) % texts.len(),
|
||||
};
|
||||
open.focus = Some(texts[next]);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let Some(Some(edit)) = open.focus.and_then(|f| open.fields.get_mut(f)) else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(shortcut) = shortcut {
|
||||
edit.shortcut(shortcut, &mut self.clipboard);
|
||||
return Ok(());
|
||||
}
|
||||
if edit.key(key, ctrl, shift) {
|
||||
return Ok(());
|
||||
}
|
||||
let typed: String = text
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.collect();
|
||||
if !typed.is_empty() {
|
||||
edit.insert_text(&typed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `type TEXT` with a dialog open: into the focused text input, or the
|
||||
/// first one.
|
||||
pub(super) fn dialog_type(&mut self, text: &str) -> bool {
|
||||
let Some(open) = self.dialogs.open.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let focus = open
|
||||
.focus
|
||||
.or_else(|| open.fields.iter().position(Option::is_some));
|
||||
let Some(Some(edit)) = focus.and_then(|f| open.fields.get_mut(f)) else {
|
||||
return false;
|
||||
};
|
||||
open.focus = focus;
|
||||
edit.insert_text(text);
|
||||
true
|
||||
}
|
||||
|
||||
/// What `state` reports: the open dialog and its values, or the waiting
|
||||
/// and warning screens.
|
||||
pub(super) fn dialog_status(&self) -> serde_json::Value {
|
||||
let d = &self.dialogs;
|
||||
let screen = if d.warning {
|
||||
Some("warning")
|
||||
} else if d.waiting.is_some() {
|
||||
Some("waiting_for_response")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let open = d.open.as_ref().map(|open| {
|
||||
let mut values = open.values.clone();
|
||||
for (value, field) in values.iter_mut().zip(&open.fields) {
|
||||
if let Some(f) = field {
|
||||
*value = InputValue::Text(f.value.clone());
|
||||
}
|
||||
}
|
||||
open.dialog.json(&values)
|
||||
});
|
||||
serde_json::json!({ "open": open, "screen": screen })
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,10 @@ enum Target {
|
||||
impl Game<'_> {
|
||||
/// Run every case of `spec` ("CASES,OUTPUT") and write the results.
|
||||
pub(super) fn dispense_fixture(&mut self, spec: &str) -> Result<()> {
|
||||
assert!(
|
||||
!self.options.execution_role.is_client(),
|
||||
"fixture requires a server Game"
|
||||
);
|
||||
let (input, output) = spec
|
||||
.split_once(',')
|
||||
.ok_or("HORIZON_DISPENSE_FIXTURE=CASES,OUTPUT")?;
|
||||
|
||||
@@ -12,11 +12,13 @@ use horizon_client::{
|
||||
hud_layout, hud_overlays,
|
||||
mesher::Vertex,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// Mob ids from here on are dragon parts (`SPECIAL + part`) and crystals
|
||||
@@ -1691,25 +1693,8 @@ impl Game<'_> {
|
||||
}
|
||||
if d.random.next_int_bound(10).unwrap_or(1) == 0 {
|
||||
let p = d.position;
|
||||
d.nearest_crystal = self
|
||||
.crystals
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| {
|
||||
(c.position.x - p.x).abs() <= 32.0
|
||||
&& (c.position.y - p.y).abs() <= 32.0
|
||||
&& (c.position.z - p.z).abs() <= 32.0
|
||||
})
|
||||
.min_by(|a, b| {
|
||||
let da = (a.1.position.x - p.x).powi(2)
|
||||
+ (a.1.position.y - p.y).powi(2)
|
||||
+ (a.1.position.z - p.z).powi(2);
|
||||
let db = (b.1.position.x - p.x).powi(2)
|
||||
+ (b.1.position.y - p.y).powi(2)
|
||||
+ (b.1.position.z - p.z).powi(2);
|
||||
da.total_cmp(&db)
|
||||
})
|
||||
.map(|(i, _)| i);
|
||||
let nearest = self.nearest_crystal_to(p);
|
||||
self.dragon.as_mut().expect("dragon").nearest_crystal = nearest;
|
||||
}
|
||||
let d = self.dragon.as_mut().expect("dragon");
|
||||
d.yaw = wrap(d.yaw);
|
||||
@@ -1870,7 +1855,7 @@ impl Game<'_> {
|
||||
pl.velocity.z += zd / dd * 4.0;
|
||||
}
|
||||
if !sitting {
|
||||
g.hurt_player_by("minecraft:mob_attack", 5.0);
|
||||
g.dragon_hurts_player("minecraft:mob_attack", 5.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1879,7 +1864,7 @@ impl Game<'_> {
|
||||
for h in heads {
|
||||
if h.inflate(Vec3::new(1.0, 1.0, 1.0)).intersects(p.bounds) {
|
||||
self.as_fight_player(p.who, |g| {
|
||||
g.hurt_player_by("minecraft:mob_attack", 10.0);
|
||||
g.dragon_hurts_player("minecraft:mob_attack", 10.0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1971,9 +1956,10 @@ impl Game<'_> {
|
||||
self.spawn_orbs([p.x, p.y, p.z], (xp as f32 * 0.08) as u32);
|
||||
}
|
||||
if t == 1 {
|
||||
// Level event 1028: the death roar, heard everywhere.
|
||||
self.audio
|
||||
.play("minecraft:entity.ender_dragon.death", None, 5.0, 1.0);
|
||||
// EnderDragon.tickDeath: global level event 1028, the death
|
||||
// roar, heard everywhere.
|
||||
let at = [p.x, p.y, p.z].map(|v| v.floor() as i32);
|
||||
self.global_level_event(1028, at, 0);
|
||||
}
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
d.position.y += 0.1;
|
||||
@@ -2160,10 +2146,20 @@ impl Game<'_> {
|
||||
self.breath_clouds.append(&mut clouds);
|
||||
for (who, amount) in harmed {
|
||||
self.as_fight_player(who, |g| {
|
||||
g.hurt_player_by("minecraft:indirect_magic", amount);
|
||||
g.dragon_hurts_player("minecraft:indirect_magic", amount);
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Damage from the dragon, which is its causing entity
|
||||
/// (`DamageSources.mobAttack(dragon)`, `indirectMagic(cloud, dragon)`): the
|
||||
/// death message names it.
|
||||
fn dragon_hurts_player(&mut self, damage_type: &str, amount: f32) {
|
||||
if let Some(id) = self.dragon.as_ref().map(|d| d.id) {
|
||||
let name = Text::translate("entity.minecraft.ender_dragon", &[]);
|
||||
self.note_named_attacker(damage_type, id, name);
|
||||
}
|
||||
self.hurt_player_by(damage_type, amount);
|
||||
}
|
||||
/// `BossHealthOverlay.extractRenderState`: whatever `publish_boss_bars`
|
||||
/// (or a server's packets) put on this client's overlay.
|
||||
pub(super) fn draw_boss_bars(&self, batch: &mut HudBatch) {
|
||||
@@ -3131,6 +3127,204 @@ impl Game<'_> {
|
||||
let _ = self.activate_exit_portal();
|
||||
self.fight.dirty = true;
|
||||
}
|
||||
/// `EnderDragon.checkCrystals`' search: the nearest crystal within 32
|
||||
/// blocks on each axis.
|
||||
fn nearest_crystal_to(&self, p: Vec3) -> Option<usize> {
|
||||
self.crystals
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| {
|
||||
(c.position.x - p.x).abs() <= 32.0
|
||||
&& (c.position.y - p.y).abs() <= 32.0
|
||||
&& (c.position.z - p.z).abs() <= 32.0
|
||||
})
|
||||
.min_by(|a, b| {
|
||||
let da = (a.1.position.x - p.x).powi(2)
|
||||
+ (a.1.position.y - p.y).powi(2)
|
||||
+ (a.1.position.z - p.z).powi(2);
|
||||
let db = (b.1.position.x - p.x).powi(2)
|
||||
+ (b.1.position.y - p.y).powi(2)
|
||||
+ (b.1.position.z - p.z).powi(2);
|
||||
da.total_cmp(&db)
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
}
|
||||
}
|
||||
|
||||
/// The dragon on a server's client (`ClientPacketListener.handleAddEntity`
|
||||
/// and the client side of `EnderDragon.aiStep`): the server flies it and
|
||||
/// sends its moves, health and phase; the client flaps its wings, keeps
|
||||
/// its flight history and places its parts, so it can be drawn and hit.
|
||||
impl Game<'_> {
|
||||
/// `handleAddEntity` for `minecraft:ender_dragon`. Its constructor
|
||||
/// sets the full health and the hovering phase until the server's
|
||||
/// synched data says otherwise.
|
||||
pub(super) fn spawn_remote_dragon(&mut self, id: i32, position: [f64; 3], yaw: f32) {
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
let mut d = Dragon::new(at, yaw, i64::from(id));
|
||||
d.id = id as u32;
|
||||
d.phase = Phase::from_id(10);
|
||||
self.dragon = Some(d);
|
||||
self.place_dragon_parts();
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
for part in &mut d.parts {
|
||||
part.1 = part.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The remote dragon, if `id` is it.
|
||||
fn remote_dragon_mut(&mut self, id: i32) -> Option<&mut Dragon> {
|
||||
self.dragon.as_mut().filter(|d| d.id as i32 == id)
|
||||
}
|
||||
/// `handleSetEntityData`: LivingEntity.DATA_HEALTH_ID and
|
||||
/// EnderDragon.DATA_PHASE (`onSyncedDataUpdated` sets the phase).
|
||||
pub(super) fn remote_dragon_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
let Some(d) = self.remote_dragon_mut(id) else {
|
||||
return;
|
||||
};
|
||||
for (index, _, value) in values {
|
||||
match (index, value) {
|
||||
(9, Value::Float(health)) => d.health = *health,
|
||||
(16, Value::Int(phase)) => d.set_phase(Phase::from_id(*phase)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `handleSetEntityMotion`: the velocity its wings flap by.
|
||||
pub(super) fn remote_dragon_motion(&mut self, id: i32, velocity: [f64; 3]) {
|
||||
if let Some(d) = self.remote_dragon_mut(id) {
|
||||
d.velocity = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
}
|
||||
}
|
||||
/// `handleHurtAnimation` and `handleDamageEvent`: the red flash.
|
||||
pub(super) fn remote_dragon_hurt(&mut self, id: i32) {
|
||||
if let Some(d) = self.remote_dragon_mut(id) {
|
||||
d.hurt_time = 10;
|
||||
}
|
||||
}
|
||||
/// `handleRemoveEntities`, or a crystal removed: the healing beam
|
||||
/// keeps pointing at the same crystal.
|
||||
pub(super) fn remove_remote_dragon(&mut self, id: i32) {
|
||||
if self.remote_dragon_mut(id).is_some() {
|
||||
self.dragon = None;
|
||||
return;
|
||||
}
|
||||
let Some(index) = self.crystals.iter().position(|c| c.id as i32 == id) else {
|
||||
return;
|
||||
};
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
match d.nearest_crystal {
|
||||
Some(n) if n == index => d.nearest_crystal = None,
|
||||
Some(n) if n > index => d.nearest_crystal = Some(n - 1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// One client tick of the dragon. `lerp`: the step toward its last
|
||||
/// sent position and yaw (LivingEntity's interpolation), and the
|
||||
/// steps left including this one.
|
||||
pub(super) fn tick_remote_dragon(&mut self, lerp: Option<([f64; 3], Option<f32>, u32)>) {
|
||||
let Some(d) = self.dragon.as_mut() else {
|
||||
return;
|
||||
};
|
||||
d.previous = d.position;
|
||||
d.previous_yaw = d.yaw;
|
||||
d.old_flap = d.flap;
|
||||
d.ticks += 1;
|
||||
d.hurt_time = (d.hurt_time - 1).max(0);
|
||||
for part in &mut d.parts {
|
||||
part.1 = part.0;
|
||||
}
|
||||
if let Some((target, yaw, steps)) = lerp {
|
||||
let n = f64::from(steps.max(1));
|
||||
d.position = Vec3::new(
|
||||
d.position.x + (target[0] - d.position.x) / n,
|
||||
d.position.y + (target[1] - d.position.y) / n,
|
||||
d.position.z + (target[2] - d.position.z) / n,
|
||||
);
|
||||
if let Some(yaw) = yaw {
|
||||
d.yaw += wrap(yaw - d.yaw) / steps.max(1) as f32;
|
||||
}
|
||||
}
|
||||
if !d.alive() {
|
||||
// EnderDragon.tickDeath on a client: it rises, and bursts
|
||||
// from tick 180 to 200; the server removes it.
|
||||
d.death_time += 1;
|
||||
let (t, p) = (d.death_time, d.position);
|
||||
if (180..=200).contains(&t) {
|
||||
let r = &mut d.random;
|
||||
let o = [
|
||||
f64::from((r.next_float() - 0.5) * 8.0),
|
||||
f64::from((r.next_float() - 0.5) * 4.0),
|
||||
f64::from((r.next_float() - 0.5) * 8.0),
|
||||
];
|
||||
self.particles.add(
|
||||
&ParticleOptions::simple(ParticleType::ExplosionEmitter),
|
||||
[p.x + o[0], p.y + 2.0 + o[1], p.z + o[2]],
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
d.position.y += 0.1;
|
||||
for part in &mut d.parts {
|
||||
part.0.y += 0.1;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// processFlappingMovement and onFlap: its wing beats, and their sound.
|
||||
let flap_was = (d.old_flap * std::f32::consts::TAU).cos();
|
||||
let v = d.velocity;
|
||||
let mut flap_speed = 0.2 / ((v.x.hypot(v.z) as f32) * 10.0 + 1.0);
|
||||
flap_speed *= 2f32.powf(v.y as f32);
|
||||
d.flap += if d.phase.sitting() { 0.1 } else { flap_speed };
|
||||
let flap_now = (d.flap * std::f32::consts::TAU).cos();
|
||||
let at = [d.position.x, d.position.y, d.position.z];
|
||||
if flap_was <= -0.3 && flap_now >= -0.3 {
|
||||
let pitch = 0.8 + self.world.random.next_float() * 0.3;
|
||||
self.audio
|
||||
.play("minecraft:entity.ender_dragon.flap", Some(at), 5.0, pitch);
|
||||
}
|
||||
let d = self.dragon.as_mut().expect("dragon");
|
||||
if !d.phase.sitting() {
|
||||
d.growl -= 1;
|
||||
if d.growl < 0 {
|
||||
d.growl = 200 + d.random.next_int_bound(200).unwrap_or(0);
|
||||
let pitch = 0.8 + self.world.random.next_float() * 0.3;
|
||||
self.audio
|
||||
.play("minecraft:entity.ender_dragon.growl", Some(at), 2.5, pitch);
|
||||
}
|
||||
}
|
||||
// checkCrystals' search runs on a client too (the healing beam).
|
||||
let d = self.dragon.as_mut().expect("dragon");
|
||||
if d.random.next_int_bound(10).unwrap_or(1) == 0 {
|
||||
let p = d.position;
|
||||
let nearest = self.nearest_crystal_to(p);
|
||||
self.dragon.as_mut().expect("dragon").nearest_crystal = nearest;
|
||||
}
|
||||
let d = self.dragon.as_mut().expect("dragon");
|
||||
d.yaw = wrap(d.yaw);
|
||||
d.record();
|
||||
self.place_dragon_parts();
|
||||
}
|
||||
/// The dragon's parts as a server's client attacks them: the server's
|
||||
/// ids for `EnderDragonPart`s follow the dragon's.
|
||||
pub(super) fn remote_dragon_part_hit(
|
||||
&self,
|
||||
eye: [f64; 3],
|
||||
dir: [f64; 3],
|
||||
reach: f64,
|
||||
) -> Option<(i32, f64, [f64; 3])> {
|
||||
let d = self.dragon.as_ref().filter(|d| d.alive() && d.id != 0)?;
|
||||
(0..8)
|
||||
.filter_map(|i| {
|
||||
let b = d.part_box(i);
|
||||
let p = d.parts[i].0;
|
||||
super::ray_box(eye, dir, reach, b)
|
||||
.map(|t| ((d.id + 1 + i as u32) as i32, t, [p.x, p.y, p.z]))
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -129,15 +129,32 @@ impl EndState {
|
||||
survive: false,
|
||||
});
|
||||
}
|
||||
/// One tick of a server's client: `step` moves each toward where the
|
||||
/// server put it.
|
||||
pub(super) fn step_remote_eyes(&mut self, mut step: impl FnMut(u32, [f64; 3]) -> [f64; 3]) {
|
||||
/// `EyeOfEnder.tick` on a server's client: it moves by its last known
|
||||
/// motion; only the server steers it.
|
||||
pub(super) fn step_remote_eyes(&mut self) {
|
||||
for e in &mut self.eyes {
|
||||
e.previous = e.position;
|
||||
e.life += 1;
|
||||
e.position = glam::DVec3::from_array(step(e.id, e.position.to_array()));
|
||||
e.position += e.velocity;
|
||||
}
|
||||
}
|
||||
/// A server's move: an eye has no interpolation handler, so
|
||||
/// `moveOrInterpolateTo` puts it there at once. False when `id` isn't one.
|
||||
pub(super) fn move_remote_eye(&mut self, id: u32, at: [f64; 3]) -> bool {
|
||||
let Some(e) = self.eyes.iter_mut().find(|e| e.id == id) else {
|
||||
return false;
|
||||
};
|
||||
e.position = glam::DVec3::from_array(at);
|
||||
true
|
||||
}
|
||||
/// `handleSetEntityMotion` for an eye. False when `id` isn't one.
|
||||
pub(super) fn remote_eye_motion(&mut self, id: u32, velocity: [f64; 3]) -> bool {
|
||||
let Some(e) = self.eyes.iter_mut().find(|e| e.id == id) else {
|
||||
return false;
|
||||
};
|
||||
e.velocity = glam::DVec3::from_array(velocity);
|
||||
true
|
||||
}
|
||||
pub(super) fn remove_remote_eye(&mut self, id: u32) {
|
||||
self.eyes.retain(|e| e.id != id);
|
||||
}
|
||||
@@ -316,9 +333,10 @@ impl Game<'_> {
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// Global LevelEvent 1038: END_PORTAL_SPAWN.
|
||||
self.audio
|
||||
.play("minecraft:block.end_portal.spawn", None, 1.0, 1.0);
|
||||
// EnderEyeItem.useOn: global level event 1038 (END_PORTAL_SPAWN),
|
||||
// heard by every player (vanilla places it at the frame's corner
|
||||
// + (1, 0, 1); only its direction matters).
|
||||
self.global_level_event(1038, c, 0);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@@ -1098,6 +1116,7 @@ impl Game<'_> {
|
||||
/// EyeOfEnder.tick: fly, then drop (4 in 5) or shatter after 80 ticks.
|
||||
fn tick_eyes(&mut self) {
|
||||
let mut eyes = std::mem::take(&mut self.end.eyes);
|
||||
let mut shattered = Vec::new();
|
||||
eyes.retain_mut(|eye| {
|
||||
eye.previous = eye.position;
|
||||
let next = eye.position + eye.velocity;
|
||||
@@ -1121,10 +1140,44 @@ impl Game<'_> {
|
||||
extra: None,
|
||||
};
|
||||
self.spawn_item(stack, at, [vx, 0.2, vz], 0);
|
||||
} else {
|
||||
shattered.push(at.map(|v| v.floor() as i32));
|
||||
}
|
||||
false
|
||||
});
|
||||
self.end.eyes = eyes;
|
||||
for pos in shattered {
|
||||
self.level_event_at(2003, pos, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `LevelEventHandler` 2003: a shattered eye's item crumbs and two rings
|
||||
/// of portal particles drawn in towards it.
|
||||
pub(super) fn eye_break_particles(&mut self, pos: [i32; 3]) {
|
||||
let (x, y, z) = (
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
);
|
||||
let crumbs = self.item_particle(&ItemStack::new("minecraft:ender_eye", 1));
|
||||
for _ in 0..8 {
|
||||
let r = self.particles.random();
|
||||
let v = [
|
||||
r.next_gaussian() * 0.15,
|
||||
r.next_double() * 0.2,
|
||||
r.next_gaussian() * 0.15,
|
||||
];
|
||||
self.particles.add(&crumbs, [x, y, z], v);
|
||||
}
|
||||
let portal = ParticleOptions::simple(ParticleType::Portal);
|
||||
let mut angle = 0.0f64;
|
||||
while angle < std::f64::consts::PI * 2.0 {
|
||||
let (c, s) = (angle.cos(), angle.sin());
|
||||
let at = [x + c * 5.0, y - 0.4, z + s * 5.0];
|
||||
self.particles.add(&portal, at, [c * -5.0, 0.0, s * -5.0]);
|
||||
self.particles.add(&portal, at, [c * -7.0, 0.0, s * -7.0]);
|
||||
angle += std::f64::consts::PI / 20.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// `EyeOfEnder.tick` on a client: behind each eye (a quarter of its last
|
||||
|
||||
@@ -15,6 +15,11 @@ fn opaque(facts: &StateFacts) -> bool {
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn prepare_entity_culling(&mut self, camera: glam::DVec3) {
|
||||
// Spans tell the pack's passes which entities are hidden; without them, nothing changes.
|
||||
self.pack_shadow_casters = match (&self.renderer, &self.pack_ids) {
|
||||
(Some(r), Some(_)) => r.pack_shadow_casters(),
|
||||
_ => (false, false),
|
||||
};
|
||||
let at = camera.to_array().map(|v| v.floor() as i32);
|
||||
self.entity_culler = (self.settings.horizon.entity_culling
|
||||
// Packs can replace an opaque block's model or texture.
|
||||
@@ -28,24 +33,29 @@ impl Game<'_> {
|
||||
/// Use complete generated geometry so equipment, animation and custom
|
||||
/// models cannot stick out of an assumed collision-sized culling box.
|
||||
pub(super) fn cull_entity_vertices(&self, vertices: &mut Vec<Vertex>, start: usize) {
|
||||
if let Some(culler) = &self.entity_culler {
|
||||
culler.cull_vertices(vertices, start, |pos| {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !opaque(self.res.facts().get(state)) {
|
||||
return false;
|
||||
}
|
||||
let Some(y) = pos[1].checked_sub(MIN_Y) else {
|
||||
return false;
|
||||
};
|
||||
let section = (pos[0] >> 4, y.div_euclid(16), pos[2] >> 4);
|
||||
// Newly edited or not-yet-uploaded terrain is not an occluder.
|
||||
self.renderer
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.has_section(section))
|
||||
&& !self.world.dirty_sections.contains(§ion)
|
||||
&& !self.streaming.is_meshing(section)
|
||||
&& !self.pending_uploads.iter().any(|(key, _)| *key == section)
|
||||
});
|
||||
let Some(culler) = &self.entity_culler else {
|
||||
return;
|
||||
};
|
||||
let hidden = culler.hidden(vertices, start, |pos| {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !opaque(self.res.facts().get(state)) {
|
||||
return false;
|
||||
}
|
||||
let Some(y) = pos[1].checked_sub(MIN_Y) else {
|
||||
return false;
|
||||
};
|
||||
let section = (pos[0] >> 4, y.div_euclid(16), pos[2] >> 4);
|
||||
// Newly edited or not-yet-uploaded terrain is not an occluder.
|
||||
self.renderer
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.has_section(section))
|
||||
&& !self.world.dirty_sections.contains(§ion)
|
||||
&& !self.streaming.is_meshing(section)
|
||||
&& !self.pending_uploads.iter().any(|(key, _)| *key == section)
|
||||
});
|
||||
// A shader pack's shadow pass still draws what the camera cannot see.
|
||||
if hidden && !(self.pack_shadow_casters.0 && self.hide_spans_from(start, vertices.len())) {
|
||||
vertices.truncate(start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
//! Horizon's extras in the game (`docs/extras.md`): the zoom key and wheel,
|
||||
//! clicks for the keystroke overlay, frame times for the FPS counter, the
|
||||
//! minimap's tiles and image, and their HUD. Each hook does nothing while its
|
||||
//! extra is off.
|
||||
//! Horizon's extras in the game (`docs/extras.md`); a mod's minimap
|
||||
//! (`render.set-minimap`: its tiles, image and HUD) and zoom (`render.set-zoom`) on the camera; clicks
|
||||
//! and frame times for mods (`input.clicks-per-second`, `render.frame-stats`).
|
||||
//! Each hook does nothing while its extra is off and no mod runs.
|
||||
use super::{Dim, Game, Screen};
|
||||
use horizon_client::{
|
||||
extras::{Corner, Cps, FpsCounter, Zoom, fov_target, turn_scale},
|
||||
extras_hud::{self, Corners, Keystrokes},
|
||||
extras::{Corner, Cps, FpsCounter, fov_target},
|
||||
extras_hud::{self, Corners},
|
||||
hud::{HudBatch, MINIMAP_SPRITE},
|
||||
keybinds,
|
||||
minimap::{self, Layer, Minimap, View},
|
||||
multiplayer,
|
||||
reconnect::{Retry, Tracker},
|
||||
title::Mode,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
use winit::event::MouseButton;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ExtrasState {
|
||||
zoom: Zoom,
|
||||
fps: FpsCounter,
|
||||
/// Left and right mouse buttons: held, and their clicks.
|
||||
mouse: [bool; 2],
|
||||
@@ -36,41 +34,16 @@ impl Game<'_> {
|
||||
fn extras_now(&self) -> f64 {
|
||||
self.started.elapsed().as_secs_f64()
|
||||
}
|
||||
fn zoom_key(&self) -> Option<KeyCode> {
|
||||
let e = &self.settings.extras;
|
||||
e.zoom.then(|| keybinds::key_code(&e.zoom_key)).flatten()
|
||||
}
|
||||
/// A key press or release. The zoom key zooms only while playing with
|
||||
/// no text open; the key itself always goes on to the screen and the key
|
||||
/// binds, as a vanilla key mapping's would.
|
||||
pub(super) fn extras_key(&mut self, key: KeyCode, pressed: bool, repeat: bool) {
|
||||
if self.zoom_key() != Some(key) {
|
||||
return;
|
||||
}
|
||||
let playing = self.screen == Screen::Playing
|
||||
&& self.title.is_none()
|
||||
&& self.sign_edit.is_none()
|
||||
&& self.book.is_none();
|
||||
let level = self.settings.extras.zoom_level;
|
||||
self.extras.zoom.key(pressed, repeat, playing, level);
|
||||
}
|
||||
/// Wheel steps while playing; true when zooming took them from the hotbar.
|
||||
pub(super) fn extras_scroll(&mut self, steps: f32) -> bool {
|
||||
self.settings.extras.zoom && self.extras.zoom.scroll(steps)
|
||||
}
|
||||
/// Mouse buttons while playing, for zoom and the keystroke overlay.
|
||||
/// Mouse buttons while playing, for mods' clicks.
|
||||
pub(super) fn extras_mouse(&mut self, button: MouseButton, pressed: bool) {
|
||||
let e = &self.settings.extras;
|
||||
if e.zoom && e.zoom_key == keybinds::mouse_name(button) {
|
||||
self.extras.zoom.key(pressed, false, true, e.zoom_level);
|
||||
}
|
||||
let i = match button {
|
||||
MouseButton::Left => 0,
|
||||
MouseButton::Right => 1,
|
||||
_ => return,
|
||||
};
|
||||
self.extras.mouse[i] = pressed;
|
||||
if pressed && self.settings.extras.keystrokes {
|
||||
// For mods (`input.clicks-per-second`).
|
||||
if pressed && self.mods.running() {
|
||||
let now = self.extras_now();
|
||||
self.extras.cps[i].click(now);
|
||||
}
|
||||
@@ -80,20 +53,32 @@ impl Game<'_> {
|
||||
// Under agent control the window may lack focus without pausing.
|
||||
let focused = self.focused || self.control.is_some();
|
||||
let playing = self.screen == Screen::Playing && self.title.is_none() && focused;
|
||||
if !playing || !self.settings.extras.zoom {
|
||||
self.extras.zoom.release();
|
||||
}
|
||||
if !playing {
|
||||
self.extras.mouse = [false; 2];
|
||||
}
|
||||
self.update_minimap();
|
||||
if self.settings.extras.fps {
|
||||
// For mods (`render.frame-stats`).
|
||||
if self.mods.running() {
|
||||
let now = self.extras_now();
|
||||
self.extras.fps.record(now, elapsed.as_secs_f32() * 1000.0);
|
||||
} else {
|
||||
self.extras.fps.clear();
|
||||
}
|
||||
}
|
||||
/// The frame counter for mods: fps, min, 1% low.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) fn frame_reading(&self) -> (u32, u32, u32) {
|
||||
let r = self.extras.fps.reading();
|
||||
(r.fps, r.min, r.low)
|
||||
}
|
||||
/// Attack and use clicks in the last second, and whether each is held.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) fn mouse_reading(&mut self) -> ((u32, u32), [bool; 2]) {
|
||||
let now = self.extras_now();
|
||||
let [l, r] = &mut self.extras.cps;
|
||||
let cps = (l.per_second(now) as u32, r.per_second(now) as u32);
|
||||
(cps, self.extras.mouse)
|
||||
}
|
||||
/// Auto-reconnect: a connection to `address` as `name` starts.
|
||||
pub(super) fn extras_connecting(&mut self, address: &str, name: &str) {
|
||||
self.extras.reconnect.connecting(address, name);
|
||||
@@ -143,25 +128,29 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The minimap's view of the player, if it shows.
|
||||
fn minimap_view(&self) -> Option<View> {
|
||||
let e = &self.settings.extras;
|
||||
/// The minimap a mod shows (`render.set-minimap`): its corner, its side
|
||||
/// in GUI pixels and its view of the player.
|
||||
fn minimap_shown(&self) -> Option<(Corner, f32, View)> {
|
||||
let p = self.player.as_ref()?;
|
||||
let shown = e.minimap && self.title.is_none() && !self.ui.hide_gui;
|
||||
shown.then_some(View {
|
||||
if self.title.is_some() || self.ui.hide_gui {
|
||||
return None;
|
||||
}
|
||||
let m = self.mod_minimap()?;
|
||||
let view = View {
|
||||
x: p.position.x,
|
||||
z: p.position.z,
|
||||
yaw: self.yaw,
|
||||
rotate: e.minimap_rotate,
|
||||
blocks_per_pixel: e.minimap_zoom,
|
||||
round: e.minimap_round,
|
||||
})
|
||||
rotate: m.rotate,
|
||||
blocks_per_pixel: m.blocks_per_pixel,
|
||||
round: m.round,
|
||||
};
|
||||
Some((m.corner, m.size, view))
|
||||
}
|
||||
/// Refresh the minimap's tiles within the frame budget, recompose its
|
||||
/// image when the view or a tile changed, and queue the upload.
|
||||
fn update_minimap(&mut self) {
|
||||
let Some(view) = self.minimap_view() else {
|
||||
if !self.settings.extras.minimap || self.player.is_none() {
|
||||
let Some((_, _, view)) = self.minimap_shown() else {
|
||||
if self.mod_minimap().is_none() || self.player.is_none() {
|
||||
self.extras.minimap.clear();
|
||||
self.extras.minimap_dim = None;
|
||||
}
|
||||
@@ -209,45 +198,43 @@ impl Game<'_> {
|
||||
rgba: self.extras.minimap.image.clone(),
|
||||
});
|
||||
}
|
||||
/// The zoom in effect (1 unless the key is held).
|
||||
fn zoom_factor(&self) -> f32 {
|
||||
self.extras.zoom.factor(self.scoping())
|
||||
/// A mod's zoom in effect (`render.set-zoom`): (factor, smooth, turning
|
||||
/// scale); none is 1.
|
||||
fn zoom_now(&self) -> (f32, bool, f64) {
|
||||
self.mod_zoom().map_or((1.0, true, 1.0), |z| {
|
||||
(z.factor, z.smooth, f64::from(z.turn))
|
||||
})
|
||||
}
|
||||
/// The field-of-view modifier target, divided by a smooth zoom.
|
||||
pub(super) fn zoomed_fov_target(&self, vanilla: f32) -> f32 {
|
||||
if self.settings.extras.zoom_smooth {
|
||||
fov_target(vanilla, self.zoom_factor())
|
||||
} else {
|
||||
vanilla
|
||||
match self.zoom_now() {
|
||||
(factor, true, _) => fov_target(vanilla, factor),
|
||||
_ => vanilla,
|
||||
}
|
||||
}
|
||||
/// The field of view in degrees, divided by an instant zoom.
|
||||
pub(super) fn zoomed_fov_degrees(&self, fov: f32) -> f32 {
|
||||
if self.settings.extras.zoom_smooth {
|
||||
fov
|
||||
} else {
|
||||
fov / self.zoom_factor()
|
||||
match self.zoom_now() {
|
||||
(factor, false, _) => fov / factor,
|
||||
_ => fov,
|
||||
}
|
||||
}
|
||||
/// Mouse turning while zoomed.
|
||||
pub(super) fn zoom_turn_scale(&self) -> f64 {
|
||||
turn_scale(self.zoom_factor(), self.settings.extras.zoom_sensitivity)
|
||||
self.zoom_now().2
|
||||
}
|
||||
/// The FPS counter and keystrokes, in the HUD layer (hidden with F1 by
|
||||
/// the caller). The top left corner gives way to the debug screen.
|
||||
/// The minimap and mods' corner panels, in the HUD layer (hidden with F1
|
||||
/// by the caller). The top left corner gives way to the debug screen.
|
||||
pub(super) fn draw_extras(&mut self, batch: &mut HudBatch) {
|
||||
let e = &self.settings.extras;
|
||||
let mod_panels = self.mods.has_panels();
|
||||
if !e.fps && !e.keystrokes && !e.minimap && !mod_panels {
|
||||
let map = self.minimap_shown();
|
||||
if map.is_none() && !mod_panels {
|
||||
return;
|
||||
}
|
||||
let (fps_on, fps_corner, lows) = (e.fps, e.fps_corner, e.fps_lows);
|
||||
let (keys_on, keys_corner, cps_on) = (e.keystrokes, e.keystrokes_corner, e.keystrokes_cps);
|
||||
let (map_corner, map_size) = (e.minimap_corner, f32::from(e.minimap_size));
|
||||
let visible = |corner| !(self.debug && corner == Corner::TopLeft);
|
||||
let mut corners = Corners::new(batch.width, batch.height, self.effect_rows_height());
|
||||
let atlas = &self.gui.atlas;
|
||||
if let Some(view) = self.minimap_view()
|
||||
if let Some((map_corner, map_size, view)) = map
|
||||
&& visible(map_corner)
|
||||
&& !self.extras.minimap.image.is_empty()
|
||||
&& let Some(image) = atlas.sprites.get(MINIMAP_SPRITE).copied()
|
||||
@@ -266,32 +253,6 @@ impl Game<'_> {
|
||||
};
|
||||
extras_hud::draw_minimap(batch, atlas, &mut corners, &look);
|
||||
}
|
||||
if fps_on && visible(fps_corner) {
|
||||
let reading = self.extras.fps.reading();
|
||||
extras_hud::draw_fps(batch, atlas, &mut corners, fps_corner, reading, lows);
|
||||
}
|
||||
if keys_on && visible(keys_corner) {
|
||||
let now = self.extras_now();
|
||||
let cps = cps_on.then(|| {
|
||||
let [l, r] = &mut self.extras.cps;
|
||||
(l.per_second(now), r.per_second(now))
|
||||
});
|
||||
let held = |k| self.keys.contains(&k);
|
||||
let label = |name| keybinds::display_name(self.settings.keys.get(name)).plain();
|
||||
let k = Keystrokes {
|
||||
moves: [
|
||||
(held(KeyCode::KeyW), label("key.forward")),
|
||||
(held(KeyCode::KeyA), label("key.left")),
|
||||
(held(KeyCode::KeyS), label("key.back")),
|
||||
(held(KeyCode::KeyD), label("key.right")),
|
||||
],
|
||||
jump: held(KeyCode::Space),
|
||||
attack: self.extras.mouse[0],
|
||||
use_: self.extras.mouse[1],
|
||||
cps,
|
||||
};
|
||||
extras_hud::draw_keystrokes(batch, &self.gui.atlas, &mut corners, keys_corner, &k);
|
||||
}
|
||||
if mod_panels {
|
||||
self.draw_mod_panels(batch, &mut corners);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ use horizon_core::{
|
||||
random::RandomSource,
|
||||
};
|
||||
|
||||
pub(super) const FIREWORK_EXPLODE_EVENT: i8 = 17;
|
||||
|
||||
pub(super) struct Rocket {
|
||||
/// The entity id a server tracks it by (0 until it is tracked).
|
||||
pub id: u32,
|
||||
@@ -71,6 +73,18 @@ impl Rocket {
|
||||
(self.position, self.velocity)
|
||||
}
|
||||
}
|
||||
/// `damageAmount * (float)Math.sqrt((5.0 - this.distanceTo(target)) / 5.0)`
|
||||
/// from the rocket at `from` to a target at `to`; `Entity.distanceTo` sums
|
||||
/// the squared offsets in floats.
|
||||
fn blast_damage(damage: f32, from: Vec3, to: Vec3) -> f32 {
|
||||
let (x, y, z) = (
|
||||
(from.x - to.x) as f32,
|
||||
(from.y - to.y) as f32,
|
||||
(from.z - to.z) as f32,
|
||||
);
|
||||
let distance = f64::from(x * x + y * y + z * z).sqrt() as f32;
|
||||
damage * ((5.0 - f64::from(distance)) / 5.0).sqrt() as f32
|
||||
}
|
||||
pub(super) fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
|
||||
// The item's default component: flight 1, no explosions.
|
||||
stack
|
||||
@@ -249,23 +263,34 @@ impl Game<'_> {
|
||||
/// `FireworkRocketEntity.explode` and `dealExplosionDamage`.
|
||||
fn explode_rocket(&mut self, r: &Rocket) {
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
// FireworkRocketEntity.explode broadcasts before discard. Send now:
|
||||
// tick_lan removes missing tracked entities before flushing effects.
|
||||
if r.id != 0
|
||||
&& let Some(host) = &self.lan
|
||||
{
|
||||
host.hub
|
||||
.entity_event(r.id as i32, FIREWORK_EXPLODE_EVENT, at);
|
||||
}
|
||||
if !r.explosions.is_empty() {
|
||||
let damage = 5.0 + r.explosions.len() as f32 * 2.0;
|
||||
// The player it boosts takes the full damage, a guest as well.
|
||||
if r.attached {
|
||||
match r.guest {
|
||||
None => self.hurt_player_by("minecraft:fireworks", damage),
|
||||
Some(conn) => {
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
self.as_guest(&mut host, conn, |g| {
|
||||
g.hurt_player_by("minecraft:fireworks", damage);
|
||||
});
|
||||
self.lan = Some(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let from = r.position;
|
||||
// `attachedToEntity` takes the full damage, a guest as well; every
|
||||
// other player in range and in sight takes the blast.
|
||||
let boosted = r.guest.filter(|_| r.attached);
|
||||
if r.attached && boosted.is_none() {
|
||||
self.rocket_hurt_player(damage);
|
||||
} else {
|
||||
self.rocket_blast_player(from, damage);
|
||||
}
|
||||
if let Some(conn) = boosted
|
||||
&& let Some(mut host) = self.lan.take()
|
||||
{
|
||||
self.as_guest(&mut host, conn, |g| g.rocket_hurt_player(damage));
|
||||
self.lan = Some(host);
|
||||
}
|
||||
// Every other player in the level (LAN guests, the players of a
|
||||
// server thread), each swapped in as the player.
|
||||
self.each_guest_hit_except(boosted, |g| g.rocket_blast_player(from, damage));
|
||||
let hits: Vec<(u32, f32)> = {
|
||||
let world = self.mob_world_base(&[]);
|
||||
self.mobs
|
||||
@@ -285,7 +310,7 @@ impl Game<'_> {
|
||||
);
|
||||
world.clear_between(from, to, false)
|
||||
});
|
||||
sees.then(|| (m.id, damage * ((5.0 - d2.sqrt()) / 5.0).sqrt() as f32))
|
||||
sees.then(|| (m.id, blast_damage(damage, from, m.position)))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
@@ -307,6 +332,59 @@ impl Game<'_> {
|
||||
self.particles
|
||||
.fireworks(at, v, r.explosions.clone(), camera);
|
||||
}
|
||||
/// FireworkRocketEntity.handleEntityEvent: use the synchronized item data.
|
||||
pub(super) fn remote_firework_explosion(&mut self, id: i32) {
|
||||
let camera = self.eye_position();
|
||||
if let Some(r) = self.rockets.iter().find(|r| r.id == id as u32) {
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
let v = [r.velocity.x, r.velocity.y, r.velocity.z];
|
||||
self.particles
|
||||
.fireworks(at, v, r.explosions.clone(), camera.unwrap_or(at));
|
||||
}
|
||||
}
|
||||
/// `dealExplosionDamage` on a player that is not the rocket's
|
||||
/// `attachedToEntity`: within 5 blocks, when the rocket sees its feet or
|
||||
/// its middle (`ClipContext.Block.COLLIDER`, no fluids).
|
||||
fn rocket_blast_player(&mut self, from: Vec3, damage: f32) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let feet = p.position;
|
||||
let d2 = (feet.x - from.x).powi(2) + (feet.y - from.y).powi(2) + (feet.z - from.z).powi(2);
|
||||
if d2 > 25.0 {
|
||||
return;
|
||||
}
|
||||
let height = f64::from(p.dimensions.height);
|
||||
let sees = {
|
||||
let world = self.mob_world_base(&[]);
|
||||
(0..2).any(|step| {
|
||||
let to = Vec3::new(feet.x, feet.y + height * 0.5 * f64::from(step), feet.z);
|
||||
world.clear_between(from, to, false)
|
||||
})
|
||||
};
|
||||
if sees {
|
||||
self.rocket_hurt_player(blast_damage(damage, from, feet));
|
||||
}
|
||||
}
|
||||
/// `hurtServer` with `damageSources().fireworks`: armor absorbs it and
|
||||
/// wears (`hurtArmor`); creative and spectator players are immune.
|
||||
fn rocket_hurt_player(&mut self, amount: f32) {
|
||||
if self.creative || self.spectator() {
|
||||
return;
|
||||
}
|
||||
let hurt = if let Some(p) = self.player.as_mut()
|
||||
&& !p.health.dead_or_dying()
|
||||
&& let Some(id) = self.motion.health.damage.id("minecraft:fireworks")
|
||||
{
|
||||
p.hurt(&self.motion.health, self.world.health_rules, id, amount)
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if hurt {
|
||||
self.hurt_armor(amount);
|
||||
}
|
||||
}
|
||||
/// `FireworkEntityRenderer.submit`: the item (GROUND) facing the camera,
|
||||
/// lit at the rocket's light probe.
|
||||
pub(super) fn rocket_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
|
||||
@@ -64,14 +64,14 @@ impl Game<'_> {
|
||||
});
|
||||
self.view.tick(eye, target);
|
||||
}
|
||||
/// The player's `deathTime` while dead or dying.
|
||||
fn death_time(&self) -> Option<i32> {
|
||||
/// The player's `deathTime` while dead or dying: whole ticks counted by
|
||||
/// `LivingEntity.tickDeath`, so `deathTime + partialTicks` in
|
||||
/// `Camera.extractRenderState` rises smoothly across frames.
|
||||
pub(super) fn death_time(&self) -> Option<i32> {
|
||||
let p = self.player.as_ref()?;
|
||||
p.health.dead_or_dying().then(|| {
|
||||
self.ui
|
||||
.died_at
|
||||
.map_or(0, |t| (t.elapsed().as_secs_f32() * 20.0) as i32)
|
||||
})
|
||||
p.health
|
||||
.dead_or_dying()
|
||||
.then(|| self.ui.death_time.min(i32::MAX as u32) as i32)
|
||||
}
|
||||
/// The fluid at the camera, as `Camera.getFluidInCamera` reports it.
|
||||
pub(super) fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid {
|
||||
|
||||
@@ -355,12 +355,23 @@ impl Game<'_> {
|
||||
* glam::DMat4::from_rotation_y(y_rot.to_radians());
|
||||
let light = self.world.light(f.pos[0], f.pos[1], f.pos[2]);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
// ItemFrameRenderer.getBlockLightLevel: a glow frame's model is at least 5 bright.
|
||||
let frame_light = if f.glow {
|
||||
(light & 0xF0) | (light & 15).max(5)
|
||||
} else {
|
||||
light
|
||||
};
|
||||
let frame_packed =
|
||||
(u32::from(frame_light >> 4) * 16) | ((u32::from(frame_light & 15) * 16) << 8);
|
||||
let mut quad =
|
||||
|m: &glam::DMat4, corners: [[f64; 3]; 4], uv: [[f32; 2]; 4], light: u32| {
|
||||
for (c, uv) in corners.iter().zip(uv) {
|
||||
let p = m.transform_point3(glam::DVec3::from_array(*c));
|
||||
// The frame model is drawn with the entity shader, lit by its face normal.
|
||||
let at = corners.map(|c| m.transform_point3(glam::DVec3::from_array(c)));
|
||||
let n = (at[1] - at[0]).cross(at[2] - at[0]).normalize_or_zero();
|
||||
let normal = [0, 1, 2].map(|k| (n[k].clamp(-1.0, 1.0) * 127.0) as i8);
|
||||
for (p, uv) in at.into_iter().zip(uv) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
normal: [normal[0], normal[1], normal[2], -127],
|
||||
pos: p.as_vec3().to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
@@ -393,7 +404,7 @@ impl Game<'_> {
|
||||
]
|
||||
}),
|
||||
map_uv(r, *uv),
|
||||
packed,
|
||||
frame_packed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ pub(super) struct RulesScreen {
|
||||
initial: GameRules,
|
||||
/// IntegerRuleEntry's boxes, by rule.
|
||||
numbers: Vec<(&'static str, EditBox)>,
|
||||
scroll: f32,
|
||||
/// RuleList's scrolling: the wheel and the held scrollbar.
|
||||
scroll: ui::Scroll,
|
||||
/// ConfirmScreen "Game Rule Changes" in its place.
|
||||
confirm: bool,
|
||||
}
|
||||
@@ -120,7 +121,7 @@ impl RulesScreen {
|
||||
values: rules.clone(),
|
||||
initial: rules.clone(),
|
||||
numbers,
|
||||
scroll: 0.0,
|
||||
scroll: ui::Scroll::default(),
|
||||
confirm: false,
|
||||
}
|
||||
}
|
||||
@@ -156,12 +157,17 @@ impl RulesScreen {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn max_scroll(rows: usize, h: f32) -> f32 {
|
||||
(ROW * rows as f32 + 4.0 - (h - HEADER - FOOTER)).max(0.0)
|
||||
}
|
||||
/// Entry `i`'s top: 2 under the list's top, less the scroll.
|
||||
fn row_top(&self, i: usize) -> f32 {
|
||||
HEADER + 2.0 - self.scroll.trunc() + ROW * i as f32
|
||||
HEADER + 2.0 - self.scroll.amount.trunc() + ROW * i as f32
|
||||
}
|
||||
/// The list's scrollbar column (AbstractSelectionList: 6 pixels right of
|
||||
/// the entries) and the pixels it scrolls.
|
||||
fn bar(w: f32) -> f32 {
|
||||
row_left(w) + ROW_WIDTH + 6.0 + 2.0
|
||||
}
|
||||
fn content(&self) -> f32 {
|
||||
ROW * self.rows().len() as f32 + 4.0
|
||||
}
|
||||
/// getEntryAtPosition: the row under the pointer, inside the list.
|
||||
fn hovered(&self, rows: usize, (x, y): (f32, f32), (w, h): (f32, f32)) -> Option<usize> {
|
||||
@@ -212,10 +218,28 @@ impl RulesScreen {
|
||||
/// The mouse wheel (AbstractSelectionList: half an entry a step).
|
||||
pub(super) fn wheel(&mut self, delta: f32, h: f32) {
|
||||
if !self.confirm {
|
||||
let max = Self::max_scroll(self.rows().len(), h);
|
||||
self.scroll = (self.scroll - delta * ROW / 2.0).clamp(0.0, max);
|
||||
let content = self.content();
|
||||
self.scroll
|
||||
.wheel(delta, ROW / 2.0, HEADER, h - FOOTER, content);
|
||||
}
|
||||
}
|
||||
/// A press on the scrollbar holds it (AbstractSelectionList.updateScrolling).
|
||||
pub(super) fn press_scrollbar(&mut self, cursor: (f32, f32), (w, h): (f32, f32)) -> bool {
|
||||
let content = self.content();
|
||||
self.scroll
|
||||
.press(cursor, Self::bar(w), HEADER, h - FOOTER, content)
|
||||
}
|
||||
/// The pointer moved while the scrollbar is held.
|
||||
pub(super) fn drag(&mut self, y: f32, h: f32) {
|
||||
if self.scroll.held() {
|
||||
let content = self.content();
|
||||
self.scroll.drag(y, HEADER, h - FOOTER, content);
|
||||
}
|
||||
}
|
||||
/// The button came up: the scrollbar lets go.
|
||||
pub(super) fn release(&mut self) {
|
||||
self.scroll.release();
|
||||
}
|
||||
fn focused(&mut self) -> Option<(Option<&'static str>, &mut EditBox)> {
|
||||
if self.search.focused {
|
||||
return Some((None, &mut self.search));
|
||||
@@ -229,7 +253,7 @@ impl RulesScreen {
|
||||
/// number sets its rule when valid (IntegerRuleEntry's responder).
|
||||
fn edited(&mut self, rule: Option<&'static str>) {
|
||||
let Some(name) = rule else {
|
||||
self.scroll = 0.0;
|
||||
self.scroll.amount = 0.0;
|
||||
return;
|
||||
};
|
||||
let Some(r) = gamerules::rule(name) else {
|
||||
@@ -286,6 +310,11 @@ impl RulesScreen {
|
||||
if on(cx, cy, 150.0, 20.0) {
|
||||
return self.close();
|
||||
}
|
||||
// A press on the scrollbar holds it and clicks nothing else.
|
||||
if self.press_scrollbar(cursor, size) {
|
||||
self.unfocus();
|
||||
return None;
|
||||
}
|
||||
// A click moves the focus to the widget under it.
|
||||
let (sx, sy) = search_at(w);
|
||||
if on(sx, sy, SEARCH_WIDTH, 15.0) {
|
||||
@@ -360,7 +389,8 @@ impl RulesScreen {
|
||||
return;
|
||||
}
|
||||
let rows = self.rows();
|
||||
self.scroll = self.scroll.clamp(0.0, Self::max_scroll(rows.len(), h));
|
||||
let content = ROW * rows.len() as f32 + 4.0;
|
||||
self.scroll.clamp(HEADER, h - FOOTER, content);
|
||||
// RuleList: the in-world list background, its entries clipped to it,
|
||||
// then the separators and scrollbar.
|
||||
ui::list_background(batch, atlas, HEADER, h - FOOTER, true);
|
||||
@@ -421,16 +451,14 @@ impl RulesScreen {
|
||||
}
|
||||
batch.clip_since(first, 0.0, HEADER, w, h - FOOTER);
|
||||
ui::separators(batch, atlas, HEADER, h - FOOTER, true);
|
||||
let content = ROW * rows.len() as f32 + 4.0;
|
||||
let bar_x = left + ROW_WIDTH + 6.0 + 2.0;
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
bar_x,
|
||||
Self::bar(w),
|
||||
HEADER,
|
||||
h - FOOTER,
|
||||
content,
|
||||
self.scroll.trunc(),
|
||||
self.scroll.amount.trunc(),
|
||||
);
|
||||
// The header: the title centred over the search box.
|
||||
let title = Text::translate("editGamerule.title", &[]);
|
||||
@@ -536,6 +564,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// AbstractScrollArea: a press on the bar holds it, a drag moves the
|
||||
/// list (to the ends past them), a release lets go.
|
||||
#[test]
|
||||
fn the_scroll_bar_can_be_held_dragged_and_let_go() {
|
||||
let mut s = RulesScreen::new(&GameRules::default());
|
||||
let (w, h) = (427.0, 240.0);
|
||||
let (top, bottom, content) = (HEADER, h - FOOTER, s.content());
|
||||
assert!(
|
||||
content > bottom - top,
|
||||
"the rules overflow a 240-high screen"
|
||||
);
|
||||
let bar = RulesScreen::bar(w);
|
||||
assert!(!s.press_scrollbar((bar - 4.0, top + 5.0), (w, h)));
|
||||
assert!(s.press_scrollbar((bar + 3.0, top + 5.0), (w, h)));
|
||||
s.drag(top + 40.0, h);
|
||||
assert!(s.scroll.amount > 0.0);
|
||||
s.drag(bottom + 10.0, h);
|
||||
assert_eq!(s.scroll.amount, content - (bottom - top));
|
||||
s.release();
|
||||
s.drag(top, h);
|
||||
assert_eq!(s.scroll.amount, content - (bottom - top));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_change_rules_and_bad_numbers_hold_done() {
|
||||
let mut s = RulesScreen::new(&GameRules::default());
|
||||
|
||||
@@ -151,6 +151,7 @@ impl Game<'_> {
|
||||
before(self.painting_hit().map(|h| h.1))
|
||||
|| before(self.frame_hit().map(|h| h.1))
|
||||
|| before(self.armor_stand_hit().map(|h| h.1))
|
||||
|| before(self.cushion_hit().map(|h| h.1))
|
||||
|| before(self.boat_hit().map(|h| h.1))
|
||||
|| before(self.cart_hit().map(|h| h.1))
|
||||
|| before(self.dragon_hit().map(|h| h.1))
|
||||
|
||||
@@ -143,7 +143,7 @@ pub(super) struct Serving {
|
||||
flags: Arc<Flags>,
|
||||
tasks: Receiver<Task>,
|
||||
/// The window's profile.
|
||||
owner: u128,
|
||||
pub(super) owner: u128,
|
||||
/// Open to LAN or closed from inside a player's command, while the hub
|
||||
/// is out of its place: applied after the tick.
|
||||
pending: Option<Publish>,
|
||||
@@ -222,7 +222,6 @@ impl Game<'_> {
|
||||
options.report = None;
|
||||
options.server = None;
|
||||
options.world = None;
|
||||
options.dev_mods.clear();
|
||||
self.join_world_rest()?;
|
||||
let (res, pack, settings) = (self.res.clone(), self.pack.clone(), self.settings.clone());
|
||||
let flags = Arc::new(Flags::default());
|
||||
@@ -292,6 +291,9 @@ impl Game<'_> {
|
||||
launch.create.as_ref(),
|
||||
)?)?;
|
||||
let saved = Box::new(self.level.clone());
|
||||
// IntegratedServer runs the server mods; the window the client mods.
|
||||
self.mods.dev = self.options.dev_mods.clone();
|
||||
self.start_mods();
|
||||
// Math.max(2, options.renderDistance): past vanilla's 32 when the
|
||||
// window's render distance is (Horizon's options go to 64).
|
||||
let view_distance = self.settings.render_distance.max(2);
|
||||
@@ -357,9 +359,25 @@ impl Game<'_> {
|
||||
});
|
||||
let _ = ready.send(Ok((pipe, self.level.name.clone())));
|
||||
self.settle_spawn()?;
|
||||
if let (Some(script), Some(output)) = (
|
||||
std::env::var_os("HORIZON_COMMAND_SCRIPT"),
|
||||
std::env::var_os("HORIZON_COMMAND_OUTPUT"),
|
||||
) {
|
||||
// The script owns the clock: connection setup and terrain loading
|
||||
// cannot add wall-clock-dependent world ticks to oracle output.
|
||||
let result = self.run_command_harness(script.as_ref(), output.as_ref());
|
||||
let stopped = self.stop_integrated();
|
||||
return result.and(stopped);
|
||||
}
|
||||
let mut next = Instant::now();
|
||||
while !flags.stop.load(Ordering::Relaxed) {
|
||||
self.run_tasks();
|
||||
if self.agent.hold_server {
|
||||
// Keep servicing control requests so the fixture can release the barrier.
|
||||
// Unlike /tick freeze, no player's queued packets are applied here.
|
||||
self.wait_for_next_server_tick(&mut next, true);
|
||||
continue;
|
||||
}
|
||||
let paused = flags.paused.load(Ordering::Relaxed);
|
||||
if paused {
|
||||
// Only the connections run.
|
||||
@@ -692,6 +710,48 @@ fn serve(
|
||||
tasks: Receiver<Task>,
|
||||
ready: &Sender<Ready>,
|
||||
) -> Result<()> {
|
||||
let mut server = server_game(res, pack, options, settings)?;
|
||||
server.run_integrated(launch, flags, tasks, ready)
|
||||
}
|
||||
|
||||
/// Component oracles supply their own synthetic levels and entity phases.
|
||||
/// They still execute on a server Game, never the client's world.
|
||||
pub(super) enum ComponentHarness {
|
||||
Dispenser(String),
|
||||
Sculk(String),
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn run_component_harness(&self, fixture: ComponentHarness) -> Result<()> {
|
||||
let (res, pack, settings) = (self.res.clone(), self.pack.clone(), self.settings.clone());
|
||||
let mut options = self.options.clone();
|
||||
options.execution_role = ExecutionRole::IntegratedServer;
|
||||
options.dedicated = Some(Vec::new());
|
||||
options.server_thread = false;
|
||||
let result = std::thread::Builder::new()
|
||||
.name("Server thread".into())
|
||||
.spawn(move || {
|
||||
let run = || -> Result<()> {
|
||||
let mut server = server_game(&res, pack, options, settings)?;
|
||||
match fixture {
|
||||
ComponentHarness::Dispenser(spec) => server.dispense_fixture(&spec),
|
||||
ComponentHarness::Sculk(spec) => server.sculk_fixture(&spec),
|
||||
}
|
||||
};
|
||||
run().map_err(|e| e.to_string())
|
||||
})?
|
||||
.join()
|
||||
.map_err(|_| "the component harness server panicked")?;
|
||||
result.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
fn server_game(
|
||||
res: &Arc<Resources>,
|
||||
pack: PackResources,
|
||||
options: Options,
|
||||
settings: Settings,
|
||||
) -> Result<Game<'_>> {
|
||||
// It draws nothing: only the font, which books and chat measure with.
|
||||
let atlas = HudAtlas::fonts(&options.resources)?;
|
||||
let gui = UiResources {
|
||||
@@ -702,8 +762,7 @@ fn serve(
|
||||
}),
|
||||
};
|
||||
let audio = Audio::silent(&options.resources);
|
||||
let mut server = Game::new(res, pack, gui, options, settings, audio, None)?;
|
||||
server.run_integrated(launch, flags, tasks, ready)
|
||||
Game::new(res, pack, gui, options, settings, audio, None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -14,7 +14,7 @@ use horizon_client::{
|
||||
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
playerdata,
|
||||
},
|
||||
mobs::{Mob, leash::Holder},
|
||||
mobs::{Mob, Who, leash::Holder},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
@@ -71,6 +71,8 @@ pub(super) struct LanHost {
|
||||
/// Guests' commands waiting for the hub to be back in `self.lan`, where
|
||||
/// a command can reach every player (game/lan_actions.rs).
|
||||
pub commands: Vec<super::lan_actions::GuestCommand>,
|
||||
/// Mods' quiet commands waiting to run a few a tick (game/lan_quiet.rs).
|
||||
pub quiet: std::collections::VecDeque<super::lan_quiet::QuietBatch>,
|
||||
/// The entities the guests track, described again every tick
|
||||
/// (`lan_entities`) over last tick's states.
|
||||
states: lan::entity::States,
|
||||
@@ -145,6 +147,7 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
|
||||
fn mob_meta(
|
||||
m: &Mob,
|
||||
hub: &Hub,
|
||||
player_entity: Option<i32>,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
@@ -194,6 +197,32 @@ fn mob_meta(
|
||||
let e = &m.extra;
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
match kind {
|
||||
"minecraft:guardian" | "minecraft:elder_guardian" => {
|
||||
// Guardian.DATA_ID_MOVING and DATA_ID_ATTACK_TARGET.
|
||||
if e.flag2 {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
let target = if e.flag {
|
||||
m.ai.target.and_then(|who| match who {
|
||||
Who::Player => player_entity,
|
||||
Who::Mob(id) => Some(id as i32),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(target) = target {
|
||||
meta.push((17, serializer::INT, Value::Int(target)));
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
defaults.push((17, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
// Bat.DATA_ID_FLAGS: resting in bit 0, flying by default.
|
||||
"minecraft:bat" => {
|
||||
if e.flag {
|
||||
meta.push((16, serializer::BYTE, Value::Byte(1)));
|
||||
}
|
||||
defaults.push((16, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 when sheared.
|
||||
"minecraft:sheep" => {
|
||||
let value = (e.color & 15) | if e.sheared { 16 } else { 0 };
|
||||
@@ -596,6 +625,7 @@ impl Game<'_> {
|
||||
waypoint_dead: Default::default(),
|
||||
border_sent: None,
|
||||
commands: Vec::new(),
|
||||
quiet: std::collections::VecDeque::new(),
|
||||
states: Default::default(),
|
||||
});
|
||||
self.audio.capture = Some(Vec::new());
|
||||
@@ -634,6 +664,7 @@ impl Game<'_> {
|
||||
self.world.light_log = None;
|
||||
self.audio.capture = None;
|
||||
self.lan_events.clear();
|
||||
self.lan_critical_hits.clear();
|
||||
self.streaming.extra.clear();
|
||||
if announce {
|
||||
self.system_message(Text::translate(
|
||||
@@ -820,12 +851,13 @@ impl Game<'_> {
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
// The lead's holder: the player the mob sees (a guest, else the
|
||||
// host) or another mob. A fence knot isn't an entity here.
|
||||
let player_entity = mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id)
|
||||
.or(host_entity);
|
||||
let leash = m.extra.leash.and_then(|h| match h {
|
||||
Holder::Player => mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id)
|
||||
.or(host_entity),
|
||||
Holder::Player => player_entity,
|
||||
Holder::Mob(id) => Some(id as i32),
|
||||
Holder::Knot(_) => None,
|
||||
});
|
||||
@@ -835,7 +867,7 @@ impl Game<'_> {
|
||||
e.on_ground = m.on_ground;
|
||||
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
|
||||
e.needs_sync = m.needs_sync;
|
||||
mob_meta(m, hub, &mut e.meta, &mut e.defaults);
|
||||
mob_meta(m, hub, player_entity, &mut e.meta, &mut e.defaults);
|
||||
mob_equipment(m, hub, &mut e.equipment);
|
||||
self.mob_attributes(m, &mut e.attributes);
|
||||
e.leash = leash;
|
||||
@@ -960,6 +992,9 @@ impl Game<'_> {
|
||||
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
|
||||
self.lan_log_event(event);
|
||||
}
|
||||
HostEvent::PlayerChat { sender, text } => {
|
||||
self.mod_player_chat(super::server_mods::Who::Uuid(sender), &text);
|
||||
}
|
||||
HostEvent::Joined(conn) => {
|
||||
self.lan_guest_joined(&mut host, conn, state.as_ref(), entities, spawn);
|
||||
if self.options.dedicated.is_some()
|
||||
@@ -1029,6 +1064,14 @@ impl Game<'_> {
|
||||
}
|
||||
host.states = states;
|
||||
}
|
||||
if !host.quiet.is_empty() {
|
||||
self.lan = Some(host);
|
||||
self.run_quiet_commands();
|
||||
host = self
|
||||
.lan
|
||||
.take()
|
||||
.expect("the hub is back after the quiet commands");
|
||||
}
|
||||
self.lan_flush_effects(&mut host);
|
||||
if !paused {
|
||||
self.tick_guests(&mut host);
|
||||
@@ -1136,9 +1179,9 @@ impl Game<'_> {
|
||||
for e in events {
|
||||
if let horizon_world::level::LevelEvent::LevelEvent { id, pos, data } = e {
|
||||
// The acting guest does not predict the egg's teleport
|
||||
// particles, nor bone meal's growth (BoneMealItem.useOn's
|
||||
// levelEvent names no player).
|
||||
let except = if matches!(*id, 2015 | 1505) {
|
||||
// particles, bone meal's growth, or anvil result sounds
|
||||
// (these levelEvent calls name no excluded player).
|
||||
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
|
||||
None
|
||||
} else {
|
||||
self.lan_acting
|
||||
@@ -1162,8 +1205,30 @@ impl Game<'_> {
|
||||
self.lan_entity_events.push((id, event, at));
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.crit`: this window's emitter, and an animation for the
|
||||
/// attacker's client and observers once the hub is back in place.
|
||||
pub(super) fn critical_hit(
|
||||
&mut self,
|
||||
attacker: Option<u32>,
|
||||
id: i32,
|
||||
feet: [f64; 3],
|
||||
width: f64,
|
||||
height: f64,
|
||||
) {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
self.particles.tracking_emitter(
|
||||
ParticleOptions::simple(ParticleType::Crit),
|
||||
feet,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_critical_hits.push((attacker, id));
|
||||
}
|
||||
}
|
||||
/// Send the tick's level events, sounds and mob animations.
|
||||
fn lan_flush_effects(&mut self, host: &mut LanHost) {
|
||||
let critical_hits = std::mem::take(&mut self.lan_critical_hits);
|
||||
for (kind, pos, data, acting) in std::mem::take(&mut self.lan_events) {
|
||||
if kind == 2001 {
|
||||
host.broken
|
||||
@@ -1177,6 +1242,9 @@ impl Game<'_> {
|
||||
for (id, event, at) in std::mem::take(&mut self.lan_entity_events) {
|
||||
host.hub.entity_event(id, event, at);
|
||||
}
|
||||
for (kind, pos, data) in std::mem::take(&mut self.lan_global_events) {
|
||||
host.hub.global_level_event(kind, pos, data);
|
||||
}
|
||||
self.lan_broadcast_effects(host);
|
||||
if host.hub.guests.is_empty() {
|
||||
host.mob_state.clear();
|
||||
@@ -1233,6 +1301,14 @@ impl Game<'_> {
|
||||
seen.insert(m.id, (m.hurt_time, dead, swing));
|
||||
}
|
||||
host.mob_state.insert(self.dim, seen);
|
||||
for (attacker, target) in critical_hits {
|
||||
let Some(attacker) = attacker.map_or(Some(host.host_entity), |conn| {
|
||||
host.hub.guest(conn).map(|g| g.entity_id)
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
host.hub.critical_hit(attacker, target);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the chunks around every guest loaded.
|
||||
@@ -1400,7 +1476,11 @@ impl Game<'_> {
|
||||
let tag = if secure { "" } else { "[Not Secure] " };
|
||||
super::dedicated::log("INFO", &format!("{tag}{line}"));
|
||||
}
|
||||
HostEvent::Chat { line, .. } => self.system_message(Text::literal(line)),
|
||||
// Player chat (and `/say`'s or `/me`'s, which are chat messages
|
||||
// too): the host's Chat option decides, as `ChatListener` does
|
||||
// for the guests' packets. Command feedback is a system message
|
||||
// and takes the system-message permission instead.
|
||||
HostEvent::Chat { line, .. } => self.player_message(Text::literal(line)),
|
||||
HostEvent::Warn(line) if dedicated => super::dedicated::log("WARN", &line),
|
||||
HostEvent::Warn(line) => log::warn!("{line}"),
|
||||
HostEvent::Error(line) if dedicated => super::dedicated::log("ERROR", &line),
|
||||
|
||||
@@ -38,6 +38,11 @@ pub(super) struct GuestPlay {
|
||||
use_delay: u32,
|
||||
/// The shield's ItemCooldowns entry: an axe disabled it for this many ticks.
|
||||
pub(super) shield_cooldown: u32,
|
||||
/// `ServerGamePacketListenerImpl.dropSpamThrottler` (`TickThrottler(20, 1480)`): 20 for each
|
||||
/// item a creative player drops from the creative screen, one less each tick.
|
||||
creative_drops: u32,
|
||||
/// The guest's `Player.getCooldowns()` (`Game::cooldowns` for its turn).
|
||||
pub(super) cooldowns: horizon_client::cooldowns::Cooldowns,
|
||||
attack_ticks: u32,
|
||||
/// The main hand's item at the guest's last tick (`Game::attack_item`).
|
||||
attack_item: Option<std::sync::Arc<str>>,
|
||||
@@ -72,6 +77,10 @@ pub(super) struct GuestPlay {
|
||||
last_inventory: u64,
|
||||
/// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs).
|
||||
pub(super) portal: horizon_client::nether_portal::PortalState,
|
||||
/// `ServerPlayer.wonGame`: the End poem is showing, the player waits for
|
||||
/// its client's PERFORM_RESPAWN (`respawn_requested`).
|
||||
pub(super) won_game: bool,
|
||||
pub(super) respawn_requested: bool,
|
||||
/// `Player.takeXpDelay`: ticks until the next orb can be taken.
|
||||
xp_delay: u32,
|
||||
/// What last killed the guest outright (`/kill`): the death message's key.
|
||||
@@ -84,15 +93,19 @@ pub(super) struct GuestPlay {
|
||||
equipped: Option<Vec<Option<ItemStack>>>,
|
||||
/// The guest's cast fishing hook (`Player.fishing`).
|
||||
pub(super) hook: Option<super::fishing::Hook>,
|
||||
sculk: super::sculk::PlayerEvents,
|
||||
}
|
||||
|
||||
/// A guest's command waiting for the hub (`LanHost::commands`).
|
||||
pub(super) struct GuestCommand {
|
||||
conn: u32,
|
||||
command: String,
|
||||
permission: u8,
|
||||
pub(super) conn: u32,
|
||||
pub(super) command: String,
|
||||
pub(super) permission: u8,
|
||||
/// An integrated server's: no level teleports (yet).
|
||||
integrated: bool,
|
||||
pub(super) integrated: bool,
|
||||
/// A mod's quiet command (game/lan_quiet.rs): its output suppressed, as
|
||||
/// `CommandSourceStack.withSuppressedOutput` does.
|
||||
pub(super) quiet: bool,
|
||||
}
|
||||
|
||||
/// The game's own state while a guest's is swapped in (`enter_guest`), and
|
||||
@@ -125,6 +138,8 @@ pub(super) struct GuestSwap {
|
||||
using: Option<(usize, u32)>,
|
||||
use_delay: u32,
|
||||
pub(super) shield_cooldown: u32,
|
||||
/// The guest's item cooldowns (`Game::cooldowns` for its turn).
|
||||
cooldowns: horizon_client::cooldowns::Cooldowns,
|
||||
attack_ticks: u32,
|
||||
attack_item: Option<std::sync::Arc<str>>,
|
||||
swing: horizon_client::first_person::SwingState,
|
||||
@@ -252,6 +267,7 @@ impl Game<'_> {
|
||||
if fresh {
|
||||
// A new body (joined, respawned, changed level): nothing on it yet.
|
||||
play.equipped = None;
|
||||
play.sculk = Default::default();
|
||||
}
|
||||
let g = &mut host.hub.guests[i];
|
||||
// The guest's body.
|
||||
@@ -259,6 +275,7 @@ impl Game<'_> {
|
||||
host.play.insert(conn, play);
|
||||
return None;
|
||||
};
|
||||
self.sculk.swap_player(&mut play.sculk);
|
||||
// The guest's own CombatTracker: damage to them is not the host's.
|
||||
// A new body starts with no record of the last one.
|
||||
let guest_combat = if fresh {
|
||||
@@ -328,6 +345,7 @@ impl Game<'_> {
|
||||
let using = std::mem::replace(&mut self.using, play.using.take());
|
||||
let use_delay = std::mem::replace(&mut self.use_delay, play.use_delay);
|
||||
let shield_cooldown = std::mem::replace(&mut self.shield_cooldown, play.shield_cooldown);
|
||||
let cooldowns = std::mem::replace(&mut self.cooldowns, std::mem::take(&mut play.cooldowns));
|
||||
let attack_ticks = std::mem::replace(&mut self.attack_ticks, play.attack_ticks);
|
||||
let attack_item = std::mem::replace(&mut self.attack_item, play.attack_item.take());
|
||||
let swing = std::mem::replace(&mut self.swing, play.swing);
|
||||
@@ -412,6 +430,7 @@ impl Game<'_> {
|
||||
using,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
cooldowns,
|
||||
attack_ticks,
|
||||
attack_item,
|
||||
swing,
|
||||
@@ -469,6 +488,7 @@ impl Game<'_> {
|
||||
using,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
cooldowns,
|
||||
attack_ticks,
|
||||
attack_item,
|
||||
swing,
|
||||
@@ -492,6 +512,7 @@ impl Game<'_> {
|
||||
projectiles,
|
||||
modifiers,
|
||||
} = swap;
|
||||
self.sculk.swap_player(&mut play.sculk);
|
||||
// The guest's damage record back into their slot, the host's in place.
|
||||
let guest_combat = std::mem::replace(&mut self.combat, combat);
|
||||
self.guest_combat.insert(conn, guest_combat);
|
||||
@@ -508,6 +529,16 @@ impl Game<'_> {
|
||||
for packet in std::mem::replace(&mut self.guest_packets, packets) {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
// ServerItemCooldowns.onCooldownStarted: the guest's own cooldown.
|
||||
for (group, duration) in std::mem::take(&mut self.guest_cooldowns) {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::Cooldown(play::Cooldown {
|
||||
group: group.to_string(),
|
||||
duration,
|
||||
}),
|
||||
);
|
||||
}
|
||||
(self.lan_acting, self.lan_acting_name, self.lan_acting_uuid) = acting;
|
||||
play.death_cause = std::mem::replace(&mut self.ui.death_cause, death_cause);
|
||||
// An ender chest the guest opened: the menu shows their inventory
|
||||
@@ -537,6 +568,7 @@ impl Game<'_> {
|
||||
play.using = std::mem::replace(&mut self.using, using);
|
||||
play.use_delay = std::mem::replace(&mut self.use_delay, use_delay);
|
||||
play.shield_cooldown = std::mem::replace(&mut self.shield_cooldown, shield_cooldown);
|
||||
play.cooldowns = std::mem::replace(&mut self.cooldowns, cooldowns);
|
||||
play.attack_ticks = std::mem::replace(&mut self.attack_ticks, attack_ticks);
|
||||
play.attack_item = std::mem::replace(&mut self.attack_item, attack_item);
|
||||
play.swing = std::mem::replace(&mut self.swing, swing);
|
||||
@@ -847,7 +879,11 @@ impl Game<'_> {
|
||||
.filter(|g| host.hub.here(g))
|
||||
.filter(|g| {
|
||||
g.menu.as_ref().is_some_and(|m| {
|
||||
m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos)
|
||||
(m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos))
|
||||
|| m.pos.is_some_and(|k| {
|
||||
k.1 == super::minecarts::CART_KEY_Y
|
||||
&& !self.chest_vehicle_still_valid(k.2, g)
|
||||
})
|
||||
})
|
||||
})
|
||||
.map(|g| g.conn)
|
||||
@@ -861,6 +897,35 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ContainerEntity.isChestVehicleStillValid`: the chest minecart or chest
|
||||
/// boat is still there and within the guest's entity interaction range
|
||||
/// plus 4 of its box.
|
||||
fn chest_vehicle_still_valid(&self, id: i32, g: &horizon_client::lan::guest::Guest) -> bool {
|
||||
let bounds = self
|
||||
.carts
|
||||
.iter()
|
||||
.find(|c| c.id as i32 == id)
|
||||
.map(|c| c.bounds())
|
||||
.or_else(|| {
|
||||
self.boats
|
||||
.iter()
|
||||
.find(|b| b.id as i32 == id)
|
||||
.map(|b| b.bounds())
|
||||
});
|
||||
let Some(b) = bounds else {
|
||||
return false;
|
||||
};
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
let d = [
|
||||
eye[0] - eye[0].clamp(lo.x, hi.x),
|
||||
eye[1] - eye[1].clamp(lo.y, hi.y),
|
||||
eye[2] - eye[2].clamp(lo.z, hi.z),
|
||||
];
|
||||
let range = if g.creative() { 5.0 } else { 3.0 } + 4.0;
|
||||
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < range * range
|
||||
}
|
||||
|
||||
/// A packet from a guest that the hub does not handle itself.
|
||||
pub(super) fn lan_guest_packet(&mut self, host: &mut LanHost, conn: u32, packet: S) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
@@ -1064,6 +1129,35 @@ impl Game<'_> {
|
||||
}
|
||||
S::MoveVehicle(p) => self.guest_move_vehicle(host, conn, &p),
|
||||
S::PaddleBoat(p) => self.guest_paddle_boat(host, conn, [p.left, p.right]),
|
||||
// handleSetCreativeModeSlot's drop: a slot below 0 is a stack thrown out of the
|
||||
// creative screen (`handleCreativeModeItemDrop`), dropped from the player's hand.
|
||||
S::SetCreativeModeSlot(p) if p.slot < 0 => {
|
||||
if !host.hub.guests[gi].creative() {
|
||||
return;
|
||||
}
|
||||
let Some(stack) = self.item_from_network(host, &p.item) else {
|
||||
return;
|
||||
};
|
||||
if stack.count
|
||||
> self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.map_or(64, |i| i.max_stack)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
if play.creative_drops >= 1480 {
|
||||
eprintln!(
|
||||
"Player {} was dropping items too fast in creative mode, ignoring.",
|
||||
host.hub.guests[gi].name
|
||||
);
|
||||
return;
|
||||
}
|
||||
play.creative_drops += 20;
|
||||
self.as_guest(host, conn, |g| g.player_drop(stack));
|
||||
}
|
||||
S::SetCreativeModeSlot(p) => {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if !g.creative() || !(0..46).contains(&p.slot) {
|
||||
@@ -1138,6 +1232,10 @@ impl Game<'_> {
|
||||
{
|
||||
self.container_changed(key);
|
||||
}
|
||||
// A vehicle's menu edits the vehicle's own items.
|
||||
if let Some(Slots::Block(key)) = slots {
|
||||
self.store_cart_items(key);
|
||||
}
|
||||
self.drop_guest_items(host, gi, drops);
|
||||
}
|
||||
S::ContainerClose(p) => {
|
||||
@@ -1152,6 +1250,9 @@ impl Game<'_> {
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
S::ChatCommand(c) => self.guest_chat_command(host, conn, &c.command),
|
||||
S::CustomPayload(p) if p.channel == horizon_protocol::quiet::CHANNEL => {
|
||||
self.guest_quiet_commands(host, conn, &p.data)
|
||||
}
|
||||
S::ChatCommandSigned(p) => self.guest_signed_command(host, conn, p),
|
||||
S::ChangeDifficulty(p) if host.hub.guests[gi].permission >= 2 => {
|
||||
if !self.level.difficulty_locked && (0..=3).contains(&p.difficulty) {
|
||||
@@ -1250,6 +1351,13 @@ impl Game<'_> {
|
||||
self.guest_suggestions(host, conn, &payload);
|
||||
}
|
||||
S::PlaceRecipe(p) => self.lan_place_recipe(host, conn, &p),
|
||||
// handleClientCommand: PERFORM_RESPAWN after the poem leaves the End
|
||||
// (the next server tick sends the player on).
|
||||
S::ClientCommand(c)
|
||||
if c.action == 0 && host.play.get(&conn).is_some_and(|p| p.won_game) =>
|
||||
{
|
||||
host.play.entry(conn).or_default().respawn_requested = true;
|
||||
}
|
||||
S::ClientCommand(c) if c.action == 0 => self.guest_respawn(host, conn),
|
||||
// ServerStatsCounter.sendStats.
|
||||
S::ClientCommand(c) if c.action == 1 => {
|
||||
@@ -1475,6 +1583,10 @@ impl Game<'_> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A server mod's command, for every player whatever their level.
|
||||
if self.guest_server_mod_command(host, conn, command.trim_start_matches('/')) {
|
||||
return;
|
||||
}
|
||||
// An integrated server runs every player's commands at their own
|
||||
// permission level (`CommandSourceStack.permissions`); a LAN world
|
||||
// follows its Allow Commands, and a dedicated server leaves it to
|
||||
@@ -1501,6 +1613,7 @@ impl Game<'_> {
|
||||
command: command.trim_start_matches('/').to_owned(),
|
||||
permission,
|
||||
integrated,
|
||||
quiet: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1515,41 +1628,54 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let c = host.commands.remove(0);
|
||||
let mut host = self.lan.take().expect("checked above");
|
||||
let Some(swap) = self.enter_guest(&mut host, c.conn) else {
|
||||
self.lan = Some(host);
|
||||
continue;
|
||||
};
|
||||
self.lan = Some(host);
|
||||
self.chat_capture = Some(Vec::new());
|
||||
let level = std::mem::replace(&mut self.ui.registries.permission, c.permission);
|
||||
self.run_command(&c.command);
|
||||
self.ui.registries.permission = level;
|
||||
let lines = self.chat_capture.take().unwrap_or_default();
|
||||
let mut host = self.lan.take().expect("a command puts the hub back");
|
||||
self.leave_guest(&mut host, swap);
|
||||
host.hub.sync_all_effects();
|
||||
if !c.integrated {
|
||||
// A teleport into another level happens before its feedback is sent.
|
||||
self.run_level_teleports(&mut host);
|
||||
}
|
||||
// What the command earned is announced before its feedback.
|
||||
self.announce_guest_advancements(&mut host);
|
||||
for line in lines {
|
||||
host.hub.send_to(
|
||||
c.conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: horizon_client::lan::data::component_nbt(&line.component()),
|
||||
overlay: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// The other operators hear of it; never the player whose command it was.
|
||||
self.send_admin_feedback(&mut host, Some(c.conn));
|
||||
self.lan = Some(host);
|
||||
self.run_guest_command(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// One guest's command as that guest, its feedback sent to them; false
|
||||
/// when it failed (or the guest is gone).
|
||||
pub(super) fn run_guest_command(&mut self, c: GuestCommand) -> bool {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return false;
|
||||
};
|
||||
let Some(swap) = self.enter_guest(&mut host, c.conn) else {
|
||||
self.lan = Some(host);
|
||||
return false;
|
||||
};
|
||||
self.lan = Some(host);
|
||||
self.chat_capture = Some(Vec::new());
|
||||
let level = std::mem::replace(&mut self.ui.registries.permission, c.permission);
|
||||
let ok = if c.quiet {
|
||||
self.run_command_quietly(&c.command)
|
||||
} else {
|
||||
self.run_command(&c.command)
|
||||
};
|
||||
self.ui.registries.permission = level;
|
||||
let lines = self.chat_capture.take().unwrap_or_default();
|
||||
let mut host = self.lan.take().expect("a command puts the hub back");
|
||||
self.leave_guest(&mut host, swap);
|
||||
host.hub.sync_all_effects();
|
||||
if !c.integrated {
|
||||
// A teleport into another level happens before its feedback is sent.
|
||||
self.run_level_teleports(&mut host);
|
||||
}
|
||||
// What the command earned is announced before its feedback.
|
||||
self.announce_guest_advancements(&mut host);
|
||||
for line in lines {
|
||||
host.hub.send_to(
|
||||
c.conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: horizon_client::lan::data::component_nbt(&line.component()),
|
||||
overlay: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// The other operators hear of it; never the player whose command it was.
|
||||
self.send_admin_feedback(&mut host, Some(c.conn));
|
||||
self.lan = Some(host);
|
||||
ok
|
||||
}
|
||||
|
||||
/// The `broadcastToAdmins` lines a command queued (`source`: the guest
|
||||
/// whose it was; none for the host's or the console's): every other
|
||||
/// operator gets them, and the console logs them as the server's own
|
||||
@@ -1777,12 +1903,28 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
if self.on_server_trip(conn) {
|
||||
// LivingEntity.hurt kills at once (die), whether the level
|
||||
// has loaded around the player or not: its respawn request
|
||||
// is taken as soon as it comes.
|
||||
self.guest_die(host, gi, conn);
|
||||
continue;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
// Player.tick's ItemCooldowns.tick, once a tick for each player
|
||||
// (the shield an axe disabled).
|
||||
// (the shield an axe disabled, a thrown pearl).
|
||||
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
|
||||
play.creative_drops = play.creative_drops.saturating_sub(1);
|
||||
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
|
||||
// the guest (a duration of 0 removes it).
|
||||
for group in play.cooldowns.tick() {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::Cooldown(play::Cooldown {
|
||||
group: group.to_string(),
|
||||
duration: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
let digging = play.digging;
|
||||
let using = play.using.is_some();
|
||||
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
|
||||
@@ -1808,14 +1950,13 @@ impl Game<'_> {
|
||||
g.use_delay -= 1;
|
||||
}
|
||||
});
|
||||
// ServerPlayerGameMode.handleBlockBreakAction: a creative
|
||||
// player's START breaks the block once (destroyAndAck) and
|
||||
// leaves nothing being destroyed; its client sends no STOP.
|
||||
// Kept, the dig broke whatever was put there next, every
|
||||
// destroy delay, for as long as the player broke nothing else.
|
||||
// ServerPlayerGameMode.handleBlockBreakAction: creative and
|
||||
// instant survival STARTs finish without a STOP from the client.
|
||||
// tick_mining clears mining on completion; forget the START too,
|
||||
// so a flower or crop planted here later is not broken again.
|
||||
if digging.is_some()
|
||||
&& host.hub.guests[gi].creative()
|
||||
&& let Some(play) = host.play.get_mut(&conn)
|
||||
&& (host.hub.guests[gi].creative() || play.mining.is_none())
|
||||
{
|
||||
play.digging = None;
|
||||
}
|
||||
@@ -1851,69 +1992,72 @@ impl Game<'_> {
|
||||
self.guest_dig_feedback(host, conn);
|
||||
self.guest_pickups(host, gi);
|
||||
self.guest_orbs(host, gi);
|
||||
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
|
||||
self.guest_forget_digging(host, conn);
|
||||
// FishingHook.shouldStopFishing: its owner is dead.
|
||||
if let Some(play) = host.play.get_mut(&conn) {
|
||||
play.hook = None;
|
||||
}
|
||||
self.guest_die(host, gi, conn);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerPlayer.die` for a guest whose health has run out: the death
|
||||
/// message, the loot and experience, the death screen.
|
||||
fn guest_die(&mut self, host: &mut LanHost, gi: usize, conn: u32) {
|
||||
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
|
||||
self.guest_forget_digging(host, conn);
|
||||
// FishingHook.shouldStopFishing: its owner is dead.
|
||||
if let Some(play) = host.play.get_mut(&conn) {
|
||||
play.hook = None;
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if g.health <= 0.0 && !g.dead {
|
||||
g.dead = true;
|
||||
g.last_death = Some(g.position.map(|v| v.floor() as i32));
|
||||
let (id, at) = (g.entity_id, g.position);
|
||||
let reward = Experience {
|
||||
level: g.xp_level,
|
||||
progress: g.xp_progress,
|
||||
total: g.xp_total,
|
||||
}
|
||||
.death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
|
||||
let (name, uuid) = (g.name.clone(), g.uuid);
|
||||
// ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3.
|
||||
let drops = (g.game_mode != SPECTATOR)
|
||||
.then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
|
||||
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
|
||||
// ServerPlayer.die: with showDeathMessages, the message goes to
|
||||
// the dying player's death screen and to everyone the team lets
|
||||
// see it; without it, the death screen gets an empty one.
|
||||
let message = if self.ui.rules.bool("show_death_messages") {
|
||||
let me = self.player_text(&name, uuid);
|
||||
let text = match cause {
|
||||
Some(key) => horizon_client::text::Text::translate(&key, &[me]),
|
||||
None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => {
|
||||
horizon_client::text::Text::translate("death.attack.outOfWorld", &[me])
|
||||
}
|
||||
None => self.guest_death_message(conn, me),
|
||||
};
|
||||
let message = horizon_client::lan::data::component_nbt(&text.component());
|
||||
let team = self.score.board.players_team(&name);
|
||||
if horizon_client::scoreboard::own_death_message_shown(team) {
|
||||
host.hub.system_message(message.clone());
|
||||
if self.options.dedicated.is_some() {
|
||||
super::dedicated::log(
|
||||
"INFO",
|
||||
&format!("System chat: {}", text.plain()),
|
||||
);
|
||||
} else if self.player.is_some() {
|
||||
self.system_message(text);
|
||||
}
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if g.health <= 0.0 && !g.dead {
|
||||
g.dead = true;
|
||||
g.last_death = Some(g.position.map(|v| v.floor() as i32));
|
||||
let (id, at) = (g.entity_id, g.position);
|
||||
let reward = Experience {
|
||||
level: g.xp_level,
|
||||
progress: g.xp_progress,
|
||||
total: g.xp_total,
|
||||
}
|
||||
.death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
|
||||
let (name, uuid) = (g.name.clone(), g.uuid);
|
||||
// ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3.
|
||||
let drops =
|
||||
(g.game_mode != SPECTATOR).then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
|
||||
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
|
||||
// ServerPlayer.die: with showDeathMessages, the message goes to
|
||||
// the dying player's death screen and to everyone the team lets
|
||||
// see it; without it, the death screen gets an empty one.
|
||||
let message = if self.ui.rules.bool("show_death_messages") {
|
||||
let me = self.player_text(&name, uuid);
|
||||
let text = match cause {
|
||||
Some(key) => horizon_client::text::Text::translate(&key, &[me]),
|
||||
None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => {
|
||||
horizon_client::text::Text::translate("death.attack.outOfWorld", &[me])
|
||||
}
|
||||
message
|
||||
} else {
|
||||
literal("")
|
||||
None => self.guest_death_message(conn, me),
|
||||
};
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::PlayerCombatKill(play::PlayerCombatKill { id, message }),
|
||||
);
|
||||
// LivingEntity.die: the death animation, the dying player included.
|
||||
host.hub.entity_event(id, 3, at);
|
||||
if let Some(eyes) = drops {
|
||||
self.as_guest(host, conn, |game| game.drop_inventory(eyes));
|
||||
let message = horizon_client::lan::data::component_nbt(&text.component());
|
||||
let team = self.score.board.players_team(&name);
|
||||
if horizon_client::scoreboard::own_death_message_shown(team) {
|
||||
host.hub.system_message(message.clone());
|
||||
if self.options.dedicated.is_some() {
|
||||
super::dedicated::log("INFO", &format!("System chat: {}", text.plain()));
|
||||
} else if self.player.is_some() {
|
||||
self.system_message(text);
|
||||
}
|
||||
}
|
||||
self.spawn_orbs(at, reward);
|
||||
message
|
||||
} else {
|
||||
literal("")
|
||||
};
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::PlayerCombatKill(play::PlayerCombatKill { id, message }),
|
||||
);
|
||||
// LivingEntity.die: the death animation, the dying player included.
|
||||
host.hub.entity_event(id, 3, at);
|
||||
if let Some(eyes) = drops {
|
||||
self.as_guest(host, conn, |game| game.drop_inventory(eyes));
|
||||
}
|
||||
self.spawn_orbs(at, reward);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2111,7 +2255,16 @@ impl Game<'_> {
|
||||
let _ = w.tick_environment_damage();
|
||||
let _ = w.apply_fire_contact();
|
||||
let _ = w.server_hazards_tick(in_wall);
|
||||
// Player.tick ends with updatePlayerPose: a swimmer, or a player
|
||||
// crawling under a block, takes the short box its moves are checked with.
|
||||
let _ = w.update_player_pose();
|
||||
let mut events = std::mem::take(&mut w.player.events);
|
||||
// LayeredCauldronBlock.handleEntityOnFireInside along those moves.
|
||||
for event in &events {
|
||||
if let MotionEvent::CauldronExtinguish { pos } = event {
|
||||
game.cauldron_extinguish(*pos);
|
||||
}
|
||||
}
|
||||
// WitherRoseBlock.entityInside along those moves.
|
||||
for event in &events {
|
||||
if matches!(event, MotionEvent::InsideWitherRose) {
|
||||
@@ -2153,6 +2306,7 @@ impl Game<'_> {
|
||||
let Some((events, marked)) = events else {
|
||||
return;
|
||||
};
|
||||
self.as_guest(host, conn, |game| game.tick_player_sculk_events());
|
||||
for event in events {
|
||||
match event {
|
||||
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
|
||||
|
||||
@@ -28,7 +28,11 @@ impl Game<'_> {
|
||||
|
||||
/// SignableCommand.of: the command's message arguments, by name, with
|
||||
/// their text, not past a redirect to the root (`execute ... run`).
|
||||
fn signable_arguments(&mut self, permission: u8, command: &str) -> Vec<(String, String)> {
|
||||
pub(super) fn signable_arguments(
|
||||
&mut self,
|
||||
permission: u8,
|
||||
command: &str,
|
||||
) -> Vec<(String, String)> {
|
||||
let parse = self.parse_as(permission, command);
|
||||
let nodes = &vanilla::game_dispatcher().nodes;
|
||||
let root = parse.context.root;
|
||||
|
||||
@@ -291,6 +291,11 @@ impl Game<'_> {
|
||||
let at = host.hub.guests[gi].position;
|
||||
self.award_furnace_recipes(pos, at);
|
||||
}
|
||||
// Slot.setChanged on a changed container slot: BlockEntity.setChanged,
|
||||
// so the chunk is saved and comparators read the block again.
|
||||
if changed.iter().any(|&s| s < items.len()) {
|
||||
self.container_changed(pos);
|
||||
}
|
||||
host.play.entry(conn).or_default().crafting.machine = Some((p.container, menu));
|
||||
host.hub.inventory_clicked(conn, p, &self.world, &changed);
|
||||
true
|
||||
|
||||
@@ -245,6 +245,16 @@ impl Game<'_> {
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
for c in &self.cushions {
|
||||
let p = c.position;
|
||||
let mut e = state(c.id, "minecraft:cushion", [p.x, p.y, p.z], c.yaw, false);
|
||||
// Cushion.DATA_COLOR, set unless it is the default white.
|
||||
if c.color != 0 {
|
||||
e.meta
|
||||
.push((8, serializer::DYE_COLOR, Value::Id(i32::from(c.color))));
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
self.lan_projectile_states(host_entity, hub, out);
|
||||
for o in &self.others {
|
||||
let p = o.position;
|
||||
|
||||
@@ -119,7 +119,17 @@ impl Game<'_> {
|
||||
|
||||
/// Runs `f` as each guest in play here, for what strikes every player in
|
||||
/// an area (`Level.getEntities`), not only the window's own.
|
||||
pub(super) fn each_guest_hit(&mut self, mut f: impl FnMut(&mut Self)) {
|
||||
pub(super) fn each_guest_hit(&mut self, f: impl FnMut(&mut Self)) {
|
||||
self.each_guest_hit_except(None, f);
|
||||
}
|
||||
|
||||
/// [`Self::each_guest_hit`] leaving out the guest `except` (the one a
|
||||
/// firework rocket is attached to, which it hurts on its own).
|
||||
pub(super) fn each_guest_hit_except(
|
||||
&mut self,
|
||||
except: Option<u32>,
|
||||
mut f: impl FnMut(&mut Self),
|
||||
) {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
@@ -127,7 +137,7 @@ impl Game<'_> {
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && Some(g.conn) != except)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
@@ -171,6 +181,19 @@ impl Game<'_> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The same guests' feet and whether they stand on the ground, for the
|
||||
/// blocks that also ask that (`BigDripleafBlock.canEntityTilt`).
|
||||
pub(super) fn lan_guest_feet(&self) -> Vec<([f64; 3], bool)> {
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
|
||||
.map(|g| (g.position, g.on_ground))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The guests a projectile can hit, under their entity ids.
|
||||
pub(super) fn lan_projectile_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
|
||||
@@ -25,6 +25,33 @@ fn rgb(c: [f32; 4]) -> [f32; 3] {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `SonicBoom.tick` uses `ServerLevel.sendParticles` for each ray point.
|
||||
/// Keep the local event's rendering; connected players receive one at rest.
|
||||
pub(super) fn lan_sonic_boom(&self, options: &ParticleOptions, at: [f64; 3]) {
|
||||
let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, None)) else {
|
||||
return;
|
||||
};
|
||||
let packet = C::LevelParticles(play::LevelParticles {
|
||||
particle,
|
||||
override_limiter: false,
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: [0.0; 3],
|
||||
max_speed: [0.0; 3],
|
||||
count: 0,
|
||||
randomization: 0,
|
||||
});
|
||||
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
|
||||
// ServerLevel compares the player's block centre, strictly within 32.
|
||||
let distance: f64 = (0..3)
|
||||
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
.sum();
|
||||
if distance < 32.0 * 32.0 {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ParticleTypes.STREAM_CODEC`'s value for `options`: the type's
|
||||
/// network id and the data it carries (`state` for the block particles).
|
||||
/// None while not serving, or for a particle whose data isn't sent from
|
||||
@@ -161,12 +188,12 @@ impl Game<'_> {
|
||||
+ (self.particles.random().next_float() - self.particles.random().next_float())
|
||||
* 0.2)
|
||||
* 0.7;
|
||||
self.audio.play(
|
||||
"minecraft:entity.generic.explode",
|
||||
Some(p.center),
|
||||
4.0,
|
||||
pitch,
|
||||
);
|
||||
// The packet's explosionSound: a wind charge's burst, a bed's
|
||||
// or anchor's blast, a modded sound.
|
||||
let sound = self
|
||||
.remote_sound_name(&p.sound)
|
||||
.unwrap_or_else(|| "minecraft:entity.generic.explode".to_owned());
|
||||
self.audio.play(&sound, Some(p.center), 4.0, pitch);
|
||||
}
|
||||
if let Some(options) = self.received_particle(&p.particle, p.center) {
|
||||
self.particles.add(&options, p.center, [1.0, 0.0, 0.0]);
|
||||
|
||||
@@ -32,6 +32,8 @@ pub(super) enum Victim {
|
||||
|
||||
/// The attacker's side of the hit.
|
||||
pub(super) struct Strike {
|
||||
/// The attacking guest, kept after their turn through `as_guest` ends.
|
||||
attacker: Option<u32>,
|
||||
amount: f32,
|
||||
scale: f32,
|
||||
crit: bool,
|
||||
@@ -119,6 +121,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.swing_arm();
|
||||
Some(Strike {
|
||||
attacker: self.lan_acting,
|
||||
amount,
|
||||
scale,
|
||||
crit,
|
||||
@@ -250,6 +253,25 @@ impl Game<'_> {
|
||||
Victim::Host => land(self),
|
||||
Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or(false),
|
||||
};
|
||||
if hurt && s.crit {
|
||||
let target = match victim {
|
||||
Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)),
|
||||
Victim::Guest(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.and_then(|g| g.motion.as_deref().map(|p| (g.entity_id, p))),
|
||||
};
|
||||
if let Some((id, p)) = target {
|
||||
let d = p.dimensions;
|
||||
self.critical_hit(
|
||||
s.attacker,
|
||||
id,
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
f64::from(d.width),
|
||||
f64::from(d.height),
|
||||
);
|
||||
}
|
||||
}
|
||||
let sound = match (hurt, s.crit, s.scale > 0.9) {
|
||||
(false, ..) => "minecraft:entity.player.attack.nodamage",
|
||||
(true, true, _) => "minecraft:entity.player.attack.crit",
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Quiet commands (`horizon_protocol::quiet`): a Horizon client's mod sends a
|
||||
//! batch of command lines in one payload, and the server runs each exactly as
|
||||
//! that player typing it would (`guest_command`'s checks, the player's own
|
||||
//! permission level, at the time it runs) with its output suppressed, as
|
||||
//! vanilla's functions run (`CommandSourceStack.withSuppressedOutput`): no
|
||||
//! feedback and no failure in anyone's chat. A batch waits in a queue and runs
|
||||
//! [`PER_TICK`] lines a player a tick, so a mod can't flood a server; each
|
||||
//! finished batch is one line in the server's log.
|
||||
use super::{Game, lan::LanHost, lan_actions::GuestCommand};
|
||||
use horizon_protocol::quiet;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
/// Lines a player may have waiting at most; a batch past it loses the rest.
|
||||
pub(super) const PENDING: usize = 4096;
|
||||
/// Lines run a player a tick at most.
|
||||
pub(super) const PER_TICK: usize = 64;
|
||||
|
||||
/// A batch waiting to run.
|
||||
pub(super) struct QuietBatch {
|
||||
conn: u32,
|
||||
mod_id: String,
|
||||
lines: VecDeque<String>,
|
||||
ran: usize,
|
||||
failed: usize,
|
||||
/// Lines past [`PENDING`], never run.
|
||||
dropped: usize,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A `horizon:quiet_commands` payload from `conn`: queued (or logged as
|
||||
/// refused when it breaks the payload's caps).
|
||||
pub(super) fn guest_quiet_commands(&mut self, host: &mut LanHost, conn: u32, data: &[u8]) {
|
||||
let Some(name) = host.hub.guest(conn).map(|g| g.name.clone()) else {
|
||||
return;
|
||||
};
|
||||
let batch = match quiet::decode(data) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
super::dedicated::log(
|
||||
"WARN",
|
||||
&format!("{name} sent quiet commands that were refused: {e}"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let waiting: usize = host
|
||||
.quiet
|
||||
.iter()
|
||||
.filter(|b| b.conn == conn)
|
||||
.map(|b| b.lines.len())
|
||||
.sum();
|
||||
let room = PENDING.saturating_sub(waiting);
|
||||
let dropped = batch.lines.len().saturating_sub(room);
|
||||
host.quiet.push_back(QuietBatch {
|
||||
conn,
|
||||
mod_id: batch.mod_id,
|
||||
lines: batch.lines.into_iter().take(room).collect(),
|
||||
ran: 0,
|
||||
failed: 0,
|
||||
dropped,
|
||||
});
|
||||
}
|
||||
|
||||
/// The waiting batches, [`PER_TICK`] lines a player this tick, in order.
|
||||
pub(super) fn run_quiet_commands(&mut self) {
|
||||
let mut left: HashMap<u32, usize> = HashMap::new();
|
||||
let mut i = 0;
|
||||
loop {
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(batch) = host.quiet.get_mut(i) else {
|
||||
return;
|
||||
};
|
||||
let conn = batch.conn;
|
||||
let budget = left.entry(conn).or_insert(PER_TICK);
|
||||
let gone = host.hub.guest(conn).is_none();
|
||||
if gone || batch.lines.is_empty() {
|
||||
let batch = host.quiet.remove(i).expect("got it above");
|
||||
if !gone {
|
||||
let name = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.map_or(String::new(), |g| g.name.clone());
|
||||
super::dedicated::log("INFO", &summary(&name, &batch));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if *budget == 0 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
*budget -= 1;
|
||||
let line = batch.lines.pop_front().expect("not empty");
|
||||
let ok = self.run_quiet_line(conn, &line);
|
||||
if let Some(batch) = self.lan.as_mut().and_then(|h| h.quiet.get_mut(i)) {
|
||||
batch.ran += 1;
|
||||
batch.failed += usize::from(!ok);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One line as `guest_command` would run it for `conn`, quietly. False
|
||||
/// when it is refused or fails. A command that carries a chat message
|
||||
/// (`say`, `msg`...) is refused: it would need the player's signature.
|
||||
fn run_quiet_line(&mut self, conn: u32, line: &str) -> bool {
|
||||
let allowed = self.options.dedicated.is_some() || self.allow_commands();
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let integrated = host.hub.config.integrated;
|
||||
if !integrated && (!host.settings.guest_commands || !allowed) {
|
||||
return false;
|
||||
}
|
||||
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
|
||||
return false;
|
||||
};
|
||||
let command = line.trim_start_matches('/');
|
||||
if !self.signable_arguments(permission, command).is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.run_guest_command(GuestCommand {
|
||||
conn,
|
||||
command: command.to_owned(),
|
||||
permission,
|
||||
integrated,
|
||||
quiet: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The batch's line in the server's log.
|
||||
fn summary(name: &str, b: &QuietBatch) -> String {
|
||||
let mut line = format!(
|
||||
"{name} ran {} quiet command(s) from mod {}: {} failed",
|
||||
b.ran, b.mod_id, b.failed
|
||||
);
|
||||
if b.dropped > 0 {
|
||||
line += &format!(", {} dropped (more than {PENDING} waiting)", b.dropped);
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_log_line_counts_the_batch() {
|
||||
let b = QuietBatch {
|
||||
conn: 1,
|
||||
mod_id: "orbital".into(),
|
||||
lines: VecDeque::new(),
|
||||
ran: 12,
|
||||
failed: 3,
|
||||
dropped: 0,
|
||||
};
|
||||
assert_eq!(
|
||||
summary("Alex", &b),
|
||||
"Alex ran 12 quiet command(s) from mod orbital: 3 failed"
|
||||
);
|
||||
let b = QuietBatch { dropped: 5, ..b };
|
||||
assert!(summary("Alex", &b).ends_with(", 5 dropped (more than 4096 waiting)"));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 634 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user