mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
578
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1f4a7c196 | ||
|
|
9543d2a686 | ||
|
|
07b7ea30d0 | ||
|
|
4d75ff55f9 | ||
|
|
8f2e53a966 | ||
|
|
787a59fd85 | ||
|
|
f4cd2e97a9 | ||
|
|
393e3f0034 | ||
|
|
db42d44415 | ||
|
|
bfa86ce98d | ||
|
|
91c086865a | ||
|
|
719a7d97ed | ||
|
|
6ed842f35b | ||
|
|
5bd8da2db5 | ||
|
|
66bf45924c | ||
|
|
ad3d1ac77d | ||
|
|
411202c6bb | ||
|
|
46716474ef | ||
|
|
4fbe3bf2dd | ||
|
|
294097d298 | ||
|
|
7a6ff6cc32 | ||
|
|
af669895a8 | ||
|
|
863b9034e7 | ||
|
|
9203ebc1b8 | ||
|
|
2f077e899b | ||
|
|
eecf134cec | ||
|
|
606df956bf | ||
|
|
e08bc408a3 | ||
|
|
83966d3fb3 | ||
|
|
5e967a2730 | ||
|
|
13eeb8147b | ||
|
|
76e7861108 | ||
|
|
bb03a6b2e0 | ||
|
|
6d2f22af83 | ||
|
|
ea73dab8ce | ||
|
|
76154d843f | ||
|
|
aa125d76a7 | ||
|
|
2f5862e0e3 | ||
|
|
df761058da | ||
|
|
d7a41d6ff3 | ||
|
|
08e734236d | ||
|
|
3e32651f45 | ||
|
|
85072be4af | ||
|
|
dcbfa2e3ed | ||
|
|
59744ec017 | ||
|
|
4afb288b43 | ||
|
|
02197e6053 | ||
|
|
3892152091 | ||
|
|
51f26d0576 | ||
|
|
0626d6d505 | ||
|
|
d8dc15794c | ||
|
|
de552471f6 | ||
|
|
d10b514182 | ||
|
|
f9187fe58b | ||
|
|
d3fb451a44 | ||
|
|
8560cb7287 | ||
|
|
5b4333059f | ||
|
|
5c0ee1ef23 | ||
|
|
a9c6d86b39 | ||
|
|
4e40cd0eb4 | ||
|
|
079292b669 | ||
|
|
e7fedb9bc3 | ||
|
|
723299c6ab | ||
|
|
2a1b8a321a | ||
|
|
c3af108912 | ||
|
|
4ef0afc99c | ||
|
|
648a65259d | ||
|
|
bbc31dd472 | ||
|
|
e0f5ad643b | ||
|
|
6d744151ac | ||
|
|
50069df898 | ||
|
|
a69a65d099 | ||
|
|
7239b38b1d | ||
|
|
2b7549c556 | ||
|
|
9802a6dccb | ||
|
|
bd8c53bfa4 | ||
|
|
afac2f98eb | ||
|
|
aa85e03ee2 | ||
|
|
42aec3c9f6 | ||
|
|
4ee84d5fa6 | ||
|
|
bef4061052 | ||
|
|
885a33e930 | ||
|
|
a8b2fe2969 | ||
|
|
f75040061d | ||
|
|
f77b1391eb | ||
|
|
4bb3e393d9 | ||
|
|
3477f0d8ce | ||
|
|
b6bf49c1a9 | ||
|
|
b0358127e6 | ||
|
|
64c3a68a71 | ||
|
|
4e25d33e58 | ||
|
|
e7aa7cb796 | ||
|
|
b91ef70301 | ||
|
|
69722dfc5e | ||
|
|
a77659a199 | ||
|
|
ac68376123 | ||
|
|
678eecf01c | ||
|
|
ccc8d4a9ae | ||
|
|
402bb0be5e | ||
|
|
f7dc1bd0d5 | ||
|
|
7096f3fe39 | ||
|
|
a1fa1ac0a6 | ||
|
|
e79b7e80d8 | ||
|
|
7ac08d8634 | ||
|
|
9adc39ae7a | ||
|
|
9d255bbdf0 | ||
|
|
d0135bf354 | ||
|
|
adb8f735ec | ||
|
|
e69ed14ade | ||
|
|
7176677496 | ||
|
|
5445cf8a52 | ||
|
|
b4e123280d | ||
|
|
48af02d0a7 | ||
|
|
c411dcd917 | ||
|
|
b87b52e3f3 | ||
|
|
f4d6ebab0c | ||
|
|
7e5e3d1ada | ||
|
|
f9e03cd32b | ||
|
|
47a342e6c9 | ||
|
|
09a73636d0 | ||
|
|
8b9556cc9d | ||
|
|
54335a438d | ||
|
|
5edb339a76 | ||
|
|
a7bda85c03 | ||
|
|
d16d57b197 | ||
|
|
78043f09a9 | ||
|
|
17410e0123 | ||
|
|
07f5e6164d | ||
|
|
cd12172a8e | ||
|
|
82888ca49d | ||
|
|
6bb88d2149 | ||
|
|
1720b747dd | ||
|
|
d3e42f3761 | ||
|
|
2c1aa246ac | ||
|
|
2b8e602c5b | ||
|
|
7a8a97dd5f | ||
|
|
76ae6b7763 | ||
|
|
669cc4d8fa | ||
|
|
6d035f2fa1 | ||
|
|
04029578c5 | ||
|
|
c958240f31 | ||
|
|
cf281cc812 | ||
|
|
bca8c9a0b8 | ||
|
|
14dcadde70 | ||
|
|
26747867df | ||
|
|
ab376df4e0 | ||
|
|
87e5ca82de | ||
|
|
c624e9c1d8 | ||
|
|
16559f6668 | ||
|
|
8e692458eb | ||
|
|
aa805243b6 | ||
|
|
d81a16f409 | ||
|
|
cb1f775f0c | ||
|
|
3bc251ea5a | ||
|
|
450feba95d | ||
|
|
9cf948a102 | ||
|
|
2c935d8d91 | ||
|
|
e2b0df56bd | ||
|
|
cbfae59314 | ||
|
|
3dba8448f6 | ||
|
|
d1e40035ef | ||
|
|
6ae81c6449 | ||
|
|
46a177c046 | ||
|
|
205d4c32c7 | ||
|
|
8a4ea81699 | ||
|
|
ef2b5d25fa | ||
|
|
eb6d6c3616 | ||
|
|
6fa9bef54f | ||
|
|
f8ee6221ff | ||
|
|
81b0cd084e | ||
|
|
37fd2a4722 | ||
|
|
c539244947 | ||
|
|
7baaa4423a | ||
|
|
acc1fdceb5 | ||
|
|
31fccc920b | ||
|
|
a5937ef682 | ||
|
|
68c5daa3ae | ||
|
|
e554c66ae2 | ||
|
|
3099586bbc | ||
|
|
a1a4fd46be | ||
|
|
8f257f4672 | ||
|
|
b7ce7c00da | ||
|
|
9dc99a5147 | ||
|
|
ae0ed11caf | ||
|
|
05f240102a | ||
|
|
1488ba172e | ||
|
|
88e4e2b847 | ||
|
|
b6134f4841 | ||
|
|
ba8be81ecb | ||
|
|
9267c6870b | ||
|
|
50f47f3acd | ||
|
|
5fc86a7384 | ||
|
|
f5a7175f27 | ||
|
|
e91a4ba7c0 | ||
|
|
6fbec036d7 | ||
|
|
b36c7f9da5 | ||
|
|
aff0fec800 | ||
|
|
8a50d1c414 | ||
|
|
e600371fc9 | ||
|
|
59d8117f64 | ||
|
|
21a12582cf | ||
|
|
aef5704b1e | ||
|
|
8bf92ca0ae | ||
|
|
63bc913369 | ||
|
|
eb46a6d619 | ||
|
|
6018159ff1 | ||
|
|
7cb008681f | ||
|
|
139ef2f801 | ||
|
|
9a47759df3 | ||
|
|
6e96e6f975 | ||
|
|
c51dcda011 | ||
|
|
9228c44123 | ||
|
|
521614fc7f | ||
|
|
a50005adb4 | ||
|
|
fbd0ca7f11 | ||
|
|
bc1d4d1516 | ||
|
|
75a9116efb | ||
|
|
177e6be16c | ||
|
|
af1325e6a5 | ||
|
|
88784b6910 | ||
|
|
64aa0b4473 | ||
|
|
a62c609529 | ||
|
|
c6a5386e9f | ||
|
|
a8b7cdc12e | ||
|
|
dcec52b07e | ||
|
|
b6ac0602d7 | ||
|
|
593e4af363 | ||
|
|
929f53ad3e | ||
|
|
475af9674a | ||
|
|
e8d5b826b0 | ||
|
|
9be37e6877 | ||
|
|
ae4e28774f | ||
|
|
678c9b7073 | ||
|
|
63e9b9207a | ||
|
|
8978c7c35e | ||
|
|
c7095cc9e5 | ||
|
|
78547c3b61 | ||
|
|
1781a7bd3e | ||
|
|
db0ec1edd4 | ||
|
|
f11233f125 | ||
|
|
ddadcb2203 | ||
|
|
1734ca3982 | ||
|
|
8c936ae640 | ||
|
|
c4cbf36967 | ||
|
|
ddeecbad3b | ||
|
|
6ac25bbc02 | ||
|
|
d2ad79e4c1 | ||
|
|
072ad5d00a | ||
|
|
57ae3494b8 | ||
|
|
c30ebd6aaf | ||
|
|
ba8b5b4146 | ||
|
|
22a3c3c55b | ||
|
|
29761e3803 | ||
|
|
cd39ec5fdc | ||
|
|
5a8414ddad | ||
|
|
9c1bd32c77 | ||
|
|
42ffde8cd2 | ||
|
|
753c565883 | ||
|
|
af2edf2e86 | ||
|
|
735f4371c5 | ||
|
|
213a16d88a | ||
|
|
553c11fd2f | ||
|
|
09d3345c9b | ||
|
|
0a8def17fa | ||
|
|
6253329644 | ||
|
|
f28c2b348c | ||
|
|
707340c312 | ||
|
|
98642e8b22 | ||
|
|
f752cb0841 | ||
|
|
9a4faa20d7 | ||
|
|
f90fb824b9 | ||
|
|
0fefb3744c | ||
|
|
3e34340e75 | ||
|
|
84ccfa68de | ||
|
|
000eb647aa | ||
|
|
e26bd0fe58 | ||
|
|
c2e44bda19 | ||
|
|
b126de85e3 | ||
|
|
9fb61eafa6 | ||
|
|
690a1a4512 | ||
|
|
2413dabfae | ||
|
|
6647659a3b | ||
|
|
321af52ea4 | ||
|
|
504f90a09a | ||
|
|
b3f954f038 | ||
|
|
c4fd435788 | ||
|
|
039d514f4f | ||
|
|
8df8d78559 | ||
|
|
231b549303 | ||
|
|
daac34040f | ||
|
|
bd9f2f2683 | ||
|
|
ab49db6a46 | ||
|
|
2b7b1711c9 | ||
|
|
483a51e9f0 | ||
|
|
904e161bf1 | ||
|
|
fbf8c46b8e | ||
|
|
bb3d830ed9 | ||
|
|
e6d8501486 | ||
|
|
885988f9d1 | ||
|
|
df8aa42d9b | ||
|
|
737633786c | ||
|
|
cfa8e57125 | ||
|
|
c652b50920 | ||
|
|
003f47c465 | ||
|
|
2c9b07d6d1 | ||
|
|
08b3aca04c | ||
|
|
175635b82b | ||
|
|
96b7a3332f | ||
|
|
f9839553c7 | ||
|
|
db4acf3105 | ||
|
|
a72a10a288 | ||
|
|
d5aefe39e2 | ||
|
|
96e310baa6 | ||
|
|
6f941a5e92 | ||
|
|
8788e9438e | ||
|
|
622786d903 | ||
|
|
fd5f849c42 | ||
|
|
d0013cfc31 | ||
|
|
c6a450499b | ||
|
|
1c6d6935d4 | ||
|
|
2a3429819e | ||
|
|
0ed5f554b3 | ||
|
|
c1348a3526 | ||
|
|
998049b7dd | ||
|
|
8d3e175d49 | ||
|
|
58f3f290e6 | ||
|
|
d4941b1155 | ||
|
|
064313363c | ||
|
|
9148cb676f | ||
|
|
33efc55c5b | ||
|
|
2c93d81d0c | ||
|
|
934df75a12 | ||
|
|
cb9e4996d3 | ||
|
|
8d98852911 | ||
|
|
ca88f2e2e5 | ||
|
|
fef6f8080a | ||
|
|
db4208923e | ||
|
|
1e3758f6c1 | ||
|
|
124cd2ac33 | ||
|
|
f79e3b7e60 | ||
|
|
a65385ff3e | ||
|
|
06584db009 | ||
|
|
ff6ef169c3 | ||
|
|
ae2088d2ab | ||
|
|
642ed5b09b | ||
|
|
322d45fda5 | ||
|
|
7a19762534 | ||
|
|
4eb27f4216 | ||
|
|
39e9a8e0d1 | ||
|
|
58a141917f | ||
|
|
7ca3d98901 | ||
|
|
3e75d6484b | ||
|
|
be7de6230c | ||
|
|
28bd35e286 | ||
|
|
f8fa3c9996 | ||
|
|
e33808a648 | ||
|
|
9c77c753a5 | ||
|
|
8b9d3235e1 | ||
|
|
7fcd32faa1 | ||
|
|
5b16c46f5d | ||
|
|
5cc2d09d5c | ||
|
|
a484e2b491 | ||
|
|
bbbb18595f | ||
|
|
4497abec98 | ||
|
|
11cd3f220d | ||
|
|
dd72fb8277 | ||
|
|
c90161511b | ||
|
|
068b434a3d | ||
|
|
ccb1474a03 | ||
|
|
7a6468bd8f | ||
|
|
600e7bec77 | ||
|
|
43207a5a3d | ||
|
|
4ad692a07b | ||
|
|
b1d6819737 | ||
|
|
3ab39ea984 | ||
|
|
97c8e9ed15 | ||
|
|
6a47c9eab5 | ||
|
|
4685ab06e2 | ||
|
|
7c42d32723 | ||
|
|
42cfa789b9 | ||
|
|
9cecb86ec2 | ||
|
|
ee34e1982a | ||
|
|
5f87b9e60d | ||
|
|
bc13a0403f | ||
|
|
e39e0088cb | ||
|
|
d253b46872 | ||
|
|
619b5fe709 | ||
|
|
89be6259da | ||
|
|
b10619ee4f | ||
|
|
d5316caaaf | ||
|
|
36b0d2670f | ||
|
|
5bc2501d7f | ||
|
|
c3e5e7866c | ||
|
|
75047c93d9 | ||
|
|
b13a22fdcb | ||
|
|
f074c2d4bd | ||
|
|
a9d6eaf3b3 | ||
|
|
a935fe0bf2 | ||
|
|
78d211e0a4 | ||
|
|
276b77a0f0 | ||
|
|
8226fb0b51 | ||
|
|
dc6b189768 | ||
|
|
3cdf2466ae | ||
|
|
88c6720079 | ||
|
|
0c987c9f63 | ||
|
|
dfcc1eb4dc | ||
|
|
6ebfcd0ded | ||
|
|
0ddaee3da8 | ||
|
|
75c6194615 | ||
|
|
1742791286 | ||
|
|
7f9f86a7ab | ||
|
|
1841925457 | ||
|
|
4a361d034b | ||
|
|
603fda280e | ||
|
|
f749bbeef1 | ||
|
|
8f785872f8 | ||
|
|
3e5b8b3ecc | ||
|
|
de0c665692 | ||
|
|
30350feba8 | ||
|
|
31456cb738 | ||
|
|
0bf4904595 | ||
|
|
b5d2db2e60 | ||
|
|
69c0f83d85 | ||
|
|
e1c1669993 | ||
|
|
86165f5754 | ||
|
|
8b5a93cbd0 | ||
|
|
2aec9b416e | ||
|
|
6c494834f5 | ||
|
|
0be8029be8 | ||
|
|
095b522455 | ||
|
|
02caa53a27 | ||
|
|
2f7dd09e90 | ||
|
|
4e61981299 | ||
|
|
67c4b573ad | ||
|
|
6d2682cf87 | ||
|
|
72e89aca4a | ||
|
|
19c46b3fde | ||
|
|
500c13a0bf | ||
|
|
09a67d8c48 | ||
|
|
6d1237c04a | ||
|
|
746bdf8a96 | ||
|
|
288ba8ca10 | ||
|
|
f7bdb7449e | ||
|
|
eaf3bf704d | ||
|
|
11843ac18b | ||
|
|
e103983b5d | ||
|
|
6f0839a15e | ||
|
|
b1f3afcd9a | ||
|
|
3332137ad2 | ||
|
|
5a2f191b22 | ||
|
|
29f5ee49ee | ||
|
|
b42b510cfb | ||
|
|
68b5f9e525 | ||
|
|
53cfb35095 | ||
|
|
5c3017a630 | ||
|
|
27fb1b40c6 | ||
|
|
82bd9f6f37 | ||
|
|
2034a32866 | ||
|
|
124904afa4 | ||
|
|
f2d940e4c8 | ||
|
|
26a4cdc5c5 | ||
|
|
a844d55815 | ||
|
|
4f1ffce47d | ||
|
|
e05059a562 | ||
|
|
88590156f0 | ||
|
|
f97c2cd499 | ||
|
|
1fedaa1c5a | ||
|
|
8a6eaac999 | ||
|
|
42e5a2fb8e | ||
|
|
bb0e421674 | ||
|
|
d36550e82c | ||
|
|
7bb5aec013 | ||
|
|
3379d00ae0 | ||
|
|
34ff18cdad | ||
|
|
acc8c21f4c | ||
|
|
e2b71fa692 | ||
|
|
ccc34ef964 | ||
|
|
742e4dc206 | ||
|
|
6b5780b686 | ||
|
|
f58fe06685 | ||
|
|
b1e4937c9a | ||
|
|
7170948484 | ||
|
|
242065a84d | ||
|
|
a209fab13e | ||
|
|
1ec53c7a3f | ||
|
|
e8a93aa87e | ||
|
|
c5f11d1eb6 | ||
|
|
617466720a | ||
|
|
ad85e99600 | ||
|
|
d356420e76 | ||
|
|
120a5f41e3 | ||
|
|
fefa788a34 | ||
|
|
03829bb88e | ||
|
|
cb12c27a6e | ||
|
|
e36ed13712 | ||
|
|
46598a2bc5 | ||
|
|
de92159b98 | ||
|
|
39755f90ff | ||
|
|
4983adec8b | ||
|
|
dd722ee7a7 | ||
|
|
71955880b7 | ||
|
|
ee20fbd1a9 | ||
|
|
696c5709e7 | ||
|
|
80bd8b2aa2 | ||
|
|
f0761a306d | ||
|
|
695574db09 | ||
|
|
8bbf67da6f | ||
|
|
91ed167997 | ||
|
|
a8a17b12d0 | ||
|
|
4d9dbdb565 | ||
|
|
abb630c564 | ||
|
|
802a3fa668 | ||
|
|
95122e4ff8 | ||
|
|
da45330894 | ||
|
|
c7b8559015 | ||
|
|
d876fa1212 | ||
|
|
d291364f56 | ||
|
|
9ec5a13f6b | ||
|
|
35f41e670c | ||
|
|
d309aed786 | ||
|
|
59e106cf25 | ||
|
|
9e3aaa3400 | ||
|
|
1e13f55868 | ||
|
|
3779a7c5d0 | ||
|
|
c6661cfe72 | ||
|
|
626de8923b | ||
|
|
cd48c77ff2 | ||
|
|
c7dbf8f290 | ||
|
|
858e8b3ba8 | ||
|
|
a2fcdeef3b | ||
|
|
a12892258f | ||
|
|
dbb23c1eca | ||
|
|
fd4b82f92d | ||
|
|
2d9d1f2264 | ||
|
|
2317bebacf | ||
|
|
aad4fd03f4 | ||
|
|
a7802c004c | ||
|
|
3d94aa69d1 | ||
|
|
7215229f71 | ||
|
|
9945e72c81 | ||
|
|
1af012a921 | ||
|
|
134b6f1a42 | ||
|
|
af5ac52ace | ||
|
|
dc6d372db8 | ||
|
|
0dab1469ac | ||
|
|
3fab32c08a | ||
|
|
501c9abbfe | ||
|
|
f0a06742ea | ||
|
|
6e1ac28831 | ||
|
|
f96acabfdc | ||
|
|
1a7e3da3c4 | ||
|
|
5596fd89f4 | ||
|
|
f6bd4918d7 | ||
|
|
38bda0fc71 | ||
|
|
126d270448 | ||
|
|
92ba57a02e | ||
|
|
c350caa74f | ||
|
|
91290d7d0a | ||
|
|
0286833cb7 | ||
|
|
8a8ee31ea2 | ||
|
|
c9e0a240a4 | ||
|
|
69bc9623a1 | ||
|
|
dc772c4601 | ||
|
|
950e0aeac2 | ||
|
|
ec514fd94b | ||
|
|
664c82127f | ||
|
|
90f2db9114 | ||
|
|
685548932b | ||
|
|
2edc3b57a4 | ||
|
|
bd6ead73c3 | ||
|
|
557a675d5d | ||
|
|
408ca59a65 | ||
|
|
c911e9060d | ||
|
|
9b19ab5b04 | ||
|
|
70a9fd6f6a | ||
|
|
563d85f957 | ||
|
|
ce0983fecc | ||
|
|
b3e5cb2fa6 |
No files matched your search
Generated
+11
@@ -1334,6 +1334,9 @@ dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"naga",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"open",
|
||||
"png",
|
||||
"pollster",
|
||||
@@ -1506,6 +1509,13 @@ dependencies = [
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-port-pack"
|
||||
version = "0.1.0"
|
||||
@@ -1609,6 +1619,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"naga",
|
||||
"png",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
||||
@@ -30,6 +30,7 @@ members = [
|
||||
"crates/cargo-horizon-mod",
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-optifine",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"xtask",
|
||||
|
||||
@@ -96,6 +96,11 @@ pub struct RenderAssets {
|
||||
pub translucent_layers: Vec<bool>,
|
||||
/// Each layer's `MipmapGenerator` settings.
|
||||
pub sprite_mips: Vec<crate::mipmap::Settings>,
|
||||
/// The resource packs' LabPBR maps (normals, specular), layer by layer at `pbr_size`:
|
||||
/// empty when they ship none.
|
||||
pub pbr_normals: Vec<u8>,
|
||||
pub pbr_specular: Vec<u8>,
|
||||
pub pbr_size: u32,
|
||||
}
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
@@ -259,6 +264,9 @@ impl RenderAssets {
|
||||
sprite_layers: sprites.layers,
|
||||
translucent_layers: sprites.translucent,
|
||||
sprite_mips: sprites.mips,
|
||||
pbr_normals: sprites.normals,
|
||||
pbr_specular: sprites.specular,
|
||||
pbr_size: sprites.pbr_size,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1226,6 +1226,17 @@ impl Audio {
|
||||
self.play(event, Some(at), volume, pitch);
|
||||
return;
|
||||
}
|
||||
self.play_after(
|
||||
event,
|
||||
at,
|
||||
volume,
|
||||
pitch,
|
||||
(distance_sqr.sqrt() / 40.0 * 20.0) as i32,
|
||||
);
|
||||
}
|
||||
/// `play` at a world position, starting `ticks` sound-engine ticks (20 a
|
||||
/// second) from now: `SoundEngine.playDelayed`.
|
||||
pub fn play_after(&mut self, event: &str, at: [f64; 3], volume: f32, pitch: f32, ticks: i32) {
|
||||
let seed = self.random.next_long();
|
||||
if let Some(capture) = self.capture.as_mut() {
|
||||
capture.push((event.to_owned(), at, volume, pitch));
|
||||
@@ -1239,8 +1250,7 @@ impl Audio {
|
||||
let instance =
|
||||
SoundInstance::at(&event, source, volume, pitch, LegacyRandom::new(seed), at);
|
||||
let id = self.engine.add(instance, false);
|
||||
self.engine
|
||||
.play_delayed(id, (distance_sqr.sqrt() / 40.0 * 20.0) as i32);
|
||||
self.engine.play_delayed(id, ticks);
|
||||
}
|
||||
/// `play` for this client only: a sound a packet's handler plays
|
||||
/// (`playLocalSound`), which the guests play from that packet themselves.
|
||||
|
||||
@@ -885,25 +885,26 @@ impl BlockEntityRenderer {
|
||||
let mut renderer = Self::new(models, registry);
|
||||
renderer.visibility = Some(Mutex::new(visibility));
|
||||
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
|
||||
if let Ok(bytes) = std::fs::read(root.join("items.json")) {
|
||||
let items: Value = serde_json::from_slice(&bytes)?;
|
||||
for (item, components) in items.as_object().into_iter().flatten() {
|
||||
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
|
||||
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
|
||||
let Ok(def) = std::fs::read(file) else {
|
||||
continue;
|
||||
};
|
||||
let def: Value = serde_json::from_slice(&def)?;
|
||||
if let Some(asset) = def["asset_id"].as_str() {
|
||||
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
|
||||
renderer
|
||||
.pot_patterns
|
||||
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
|
||||
}
|
||||
let items = match horizon_world::item_defaults::read(root) {
|
||||
Ok(items) => items,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
for (item, components) in &items {
|
||||
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str() else {
|
||||
continue;
|
||||
};
|
||||
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
|
||||
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
|
||||
let Ok(def) = std::fs::read(file) else {
|
||||
continue;
|
||||
};
|
||||
let def: Value = serde_json::from_slice(&def)?;
|
||||
if let Some(asset) = def["asset_id"].as_str() {
|
||||
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
|
||||
renderer
|
||||
.pot_patterns
|
||||
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
|
||||
}
|
||||
}
|
||||
Ok(renderer)
|
||||
|
||||
@@ -160,6 +160,18 @@ pub struct Brewing {
|
||||
}
|
||||
impl Brewing {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
|
||||
// NeoForge's load conditions: a file whose don't hold is silently
|
||||
// absent. Vanilla's brewing recipes carry none; a pack's can.
|
||||
let mods: Vec<String> = {
|
||||
let mut mods = horizon_items::content_namespaces::mod_ids(root);
|
||||
if !mods.iter().any(|m| m == "minecraft") {
|
||||
mods.push("minecraft".to_owned());
|
||||
}
|
||||
mods
|
||||
};
|
||||
let conditions_hold = |v: &Value| {
|
||||
horizon_world::conditions::conditions_hold(v, &|id: &str| mods.iter().any(|m| m == id))
|
||||
};
|
||||
let mut sources: Vec<(String, Value)> =
|
||||
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
|
||||
.into_iter()
|
||||
@@ -191,6 +203,9 @@ impl Brewing {
|
||||
None => sources.push((id.to_owned(), v)),
|
||||
}
|
||||
}
|
||||
// The gate answers the merged file, as the crafting loader's does:
|
||||
// a false-conditioned pack file leaves the lower one gone too.
|
||||
sources.retain(|(_, v)| conditions_hold(v));
|
||||
sources.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let mut recipes = Vec::new();
|
||||
for (id, v) in sources {
|
||||
|
||||
@@ -233,6 +233,9 @@ pub struct SkullSkin {
|
||||
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
|
||||
/// an id only): the key `skins::Resolver` resolves it by.
|
||||
pub dynamic: Option<NameOrId>,
|
||||
/// `PlayerSkin.Patch.body`: the profile's `texture`, a resource pack
|
||||
/// texture drawn instead of any skin.
|
||||
pub texture: Option<String>,
|
||||
pub fallback: &'static str,
|
||||
}
|
||||
/// `Either<String, UUID>`: the key a `Dynamic` `ResolvableProfile` resolves
|
||||
|
||||
@@ -9,6 +9,59 @@ pub use crate::chunk::block_entity::{
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// a content mod's menu type (`data/<id>/horizon/menu/`, vanilla's
|
||||
/// `MenuType`): its container slots, its data slots (`ContainerData`) and the
|
||||
/// slots players only take from.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ModMenuType {
|
||||
pub id: &'static str,
|
||||
pub slots: usize,
|
||||
pub data_slots: usize,
|
||||
pub results: Vec<usize>,
|
||||
}
|
||||
|
||||
static MOD_MENU_TYPES: std::sync::OnceLock<Vec<ModMenuType>> = std::sync::OnceLock::new();
|
||||
|
||||
/// registers the content mods' menu types, `(id, slots, data slots,
|
||||
/// results)`, a list set once per process (false when it already was).
|
||||
pub fn register_mod_menu_types(
|
||||
menus: impl IntoIterator<Item = (String, usize, usize, Vec<usize>)>,
|
||||
) -> bool {
|
||||
if MOD_MENU_TYPES.get().is_some() {
|
||||
return false;
|
||||
}
|
||||
let menus = menus
|
||||
.into_iter()
|
||||
.map(|(id, slots, data_slots, results)| ModMenuType {
|
||||
id: Box::leak(id.into_boxed_str()),
|
||||
slots,
|
||||
data_slots,
|
||||
results,
|
||||
});
|
||||
MOD_MENU_TYPES.set(menus.collect()).is_ok()
|
||||
}
|
||||
|
||||
/// the content mods' menu types, in registry order.
|
||||
pub fn mod_menu_types() -> &'static [ModMenuType] {
|
||||
MOD_MENU_TYPES.get().map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// the mod menu type named `id`, kept out of line: a vanilla menu never
|
||||
/// looks one up.
|
||||
#[cold]
|
||||
#[inline(never)]
|
||||
pub fn mod_menu_type(id: &str) -> Option<&'static ModMenuType> {
|
||||
mod_menu_types().iter().find(|m| m.id == id)
|
||||
}
|
||||
|
||||
/// whether a client of `brand` can be sent menu type `menu`: a vanilla
|
||||
/// client reads the type through `ByteBufCodecs.registry(Registries.MENU)`,
|
||||
/// and an id it lacks (`IdMap.byIdOrThrow`) disconnects it, so it is sent
|
||||
/// vanilla's only. A Horizon client has the server's (the content identity).
|
||||
pub fn menu_reaches(brand: &str, menu: &str) -> bool {
|
||||
brand == "horizon" || menu.starts_with("minecraft:")
|
||||
}
|
||||
|
||||
impl FurnaceKind {
|
||||
pub fn recipe_type(self) -> &'static str {
|
||||
match self {
|
||||
@@ -196,6 +249,7 @@ impl SkullSkin {
|
||||
Self {
|
||||
download,
|
||||
dynamic,
|
||||
texture: crate::skins::profile_texture_nbt(profile),
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
@@ -610,6 +664,15 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
|
||||
/// a client that isn't Horizon's is sent vanilla's menu types only.
|
||||
#[test]
|
||||
fn a_mod_menu_type_reaches_horizon_clients_only() {
|
||||
assert!(menu_reaches("horizon", "ruby:press"));
|
||||
assert!(!menu_reaches("vanilla", "ruby:press"));
|
||||
assert!(!menu_reaches("fabric", "ruby:press"));
|
||||
assert!(menu_reaches("vanilla", "minecraft:generic_9x3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campfire_slots_take_food_cook_it_and_cool_down() {
|
||||
let mut c = Campfire::default();
|
||||
|
||||
@@ -1,85 +1,3 @@
|
||||
//! A fast, non-cryptographic hasher (the Fx hash used by rustc) for hot maps
|
||||
//! keyed by small integers, such as chunk coordinates. Iteration order of
|
||||
//! such maps carries no meaning: the default hasher's is random anyway.
|
||||
use std::hash::{BuildHasherDefault, Hasher};
|
||||
|
||||
const SEED: u64 = 0xf135_7aea_2e62_a9c5;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct FxHasher {
|
||||
hash: u64,
|
||||
}
|
||||
impl FxHasher {
|
||||
#[inline]
|
||||
fn add(&mut self, word: u64) {
|
||||
self.hash = self.hash.wrapping_add(word).wrapping_mul(SEED);
|
||||
}
|
||||
}
|
||||
impl Hasher for FxHasher {
|
||||
#[inline]
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
let (words, rest) = bytes.as_chunks::<8>();
|
||||
for word in words {
|
||||
self.add(u64::from_le_bytes(*word));
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
let mut word = [0u8; 8];
|
||||
word[..rest.len()].copy_from_slice(rest);
|
||||
self.add(u64::from_le_bytes(word));
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn write_u8(&mut self, i: u8) {
|
||||
self.add(u64::from(i));
|
||||
}
|
||||
#[inline]
|
||||
fn write_u16(&mut self, i: u16) {
|
||||
self.add(u64::from(i));
|
||||
}
|
||||
#[inline]
|
||||
fn write_u32(&mut self, i: u32) {
|
||||
self.add(u64::from(i));
|
||||
}
|
||||
#[inline]
|
||||
fn write_u64(&mut self, i: u64) {
|
||||
self.add(i);
|
||||
}
|
||||
#[inline]
|
||||
fn write_usize(&mut self, i: usize) {
|
||||
self.add(i as u64);
|
||||
}
|
||||
#[inline]
|
||||
fn write_i32(&mut self, i: i32) {
|
||||
self.add(u64::from(i as u32));
|
||||
}
|
||||
#[inline]
|
||||
fn finish(&self) -> u64 {
|
||||
// The multiply leaves the low bits weakest; hash tables use them.
|
||||
self.hash.rotate_left(26)
|
||||
}
|
||||
}
|
||||
pub type FxBuild = BuildHasherDefault<FxHasher>;
|
||||
pub type FxHashMap<K, V> = std::collections::HashMap<K, V, FxBuild>;
|
||||
pub type FxHashSet<K> = std::collections::HashSet<K, FxBuild>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nearby_chunks_spread_over_buckets() {
|
||||
let mut map: FxHashMap<(i32, i32), i32> = FxHashMap::default();
|
||||
for x in -40..40 {
|
||||
for z in -40..40 {
|
||||
map.insert((x, z), x * z);
|
||||
}
|
||||
}
|
||||
assert_eq!(map.len(), 6400);
|
||||
assert_eq!(map[&(-7, 13)], -91);
|
||||
let mut low = std::collections::HashSet::new();
|
||||
for x in 0..64 {
|
||||
low.insert(std::hash::BuildHasher::hash_one(&FxBuild::default(), (x, 0)) & 63);
|
||||
}
|
||||
assert!(low.len() > 32, "low bits vary: {}", low.len());
|
||||
}
|
||||
}
|
||||
//! The Fx hasher for hot maps keyed by small integers: kept in horizon-core
|
||||
//! (the scheduled ticks use it too), and reachable here under its old path.
|
||||
pub use horizon_core::fast_hash::*;
|
||||
@@ -88,12 +88,14 @@ mod frame_gen;
|
||||
mod hdr;
|
||||
mod hdr_stats;
|
||||
pub use hdr::Calibration as HdrCalibration;
|
||||
pub mod block_light;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
mod native_material;
|
||||
mod native_pack;
|
||||
mod native_post;
|
||||
pub mod native_render;
|
||||
mod native_shadow;
|
||||
mod native_sky;
|
||||
mod occlusion;
|
||||
mod oit;
|
||||
@@ -411,6 +413,9 @@ pub struct Frame<'a> {
|
||||
pub additive: &'a [Vertex],
|
||||
/// Name tags over everything, without depth (`TEXT_SEE_THROUGH`).
|
||||
pub see_through: &'a [Vertex],
|
||||
/// Opaque entity quads only a native pack's shadow pass draws (the local player in first
|
||||
/// person).
|
||||
pub shadow_only: &'a [Vertex],
|
||||
/// Camera-relative sun and moon, or the animated End flash (additive,
|
||||
/// behind everything and after the resident End sky box).
|
||||
pub celestial: &'a [Vertex],
|
||||
@@ -480,6 +485,9 @@ pub struct ScreenshotRequest {
|
||||
/// `GameRenderer.takeAutoScreenshot`: centre-crop the frame to a square
|
||||
/// and average it down to this size. `None` saves the frame as drawn.
|
||||
pub size: Option<[u32; 2]>,
|
||||
/// F2's: drawn at the game's resolution (Resolution Scale above 100%) times
|
||||
/// `Renderer::screenshot_quality`, not the window's.
|
||||
pub scaled: bool,
|
||||
}
|
||||
pub struct Renderer {
|
||||
instance: wgpu::Instance,
|
||||
@@ -494,6 +502,9 @@ pub struct Renderer {
|
||||
offscreen: Option<wgpu::Texture>,
|
||||
/// Offscreen submissions still on the GPU: at most two frames run ahead.
|
||||
in_flight: std::collections::VecDeque<wgpu::SubmissionIndex>,
|
||||
/// Splitscreen: the caller waits for the GPU once every player has submitted
|
||||
/// (`wait_in_flight`), so one player's work overlaps the GPU drawing another's.
|
||||
pub paced_elsewhere: bool,
|
||||
/// Which steps of the first frame on this device still go to the log
|
||||
/// (see `startup_step`).
|
||||
first_frame: FirstFrame,
|
||||
@@ -592,6 +603,8 @@ pub struct Renderer {
|
||||
screen_buffer: wgpu::Buffer,
|
||||
screen_capacity: u64,
|
||||
entity_group: wgpu::BindGroup,
|
||||
/// The layout of `entity_group`, for the native shadow pass.
|
||||
entity_bindings: wgpu::BindGroupLayout,
|
||||
/// The entity resources with the hand's projection (`hand_globals`).
|
||||
hand_group: wgpu::BindGroup,
|
||||
hand_globals: wgpu::Buffer,
|
||||
@@ -600,10 +613,14 @@ pub struct Renderer {
|
||||
entity_batch: EntityBatch,
|
||||
group: wgpu::BindGroup,
|
||||
globals: wgpu::Buffer,
|
||||
/// The resource packs' LabPBR maps (normals, specular), for native shader packs.
|
||||
pbr_maps: Option<[wgpu::Texture; 2]>,
|
||||
lightmap: wgpu::Texture,
|
||||
/// The block sprites, [`SPRITE_SPLIT`] layers per array (the first is
|
||||
/// The block sprites, `sprite_split` layers per array (the first is
|
||||
/// `sprites` in terrain.wgsl, the others `sprites_1`...).
|
||||
sprite_textures: Vec<wgpu::Texture>,
|
||||
/// Block sprite layers per array on this device ([`layer_split`]).
|
||||
sprite_split: u32,
|
||||
/// A 1-layer array bound where a pack needs fewer arrays.
|
||||
sprite_unused: wgpu::TextureView,
|
||||
mip_levels: u32,
|
||||
@@ -690,6 +707,9 @@ pub struct Renderer {
|
||||
section_uploaded: crate::fast_hash::FxHashMap<SectionKey, std::time::Instant>,
|
||||
/// Changes whenever `block_entities` does (unique across renderers).
|
||||
block_entity_revision: u64,
|
||||
/// The light sources of each section and their revision, for `block_light`.
|
||||
light_blocks: crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
light_revision: u64,
|
||||
/// VisGraph face connectivity per meshed section (cave culling).
|
||||
occlusion: occlusion::Occlusion,
|
||||
/// The column `occlusion`, `sections` and `detail_columns` count in: the
|
||||
@@ -751,6 +771,12 @@ pub struct Renderer {
|
||||
pub screenshot: Option<ScreenshotRequest>,
|
||||
/// Outcome of the last screenshot: frame size, or the error.
|
||||
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
|
||||
/// Screenshot Quality (Horizon setting): a `scaled` screenshot's frame is this many
|
||||
/// times the game's resolution (1, 2, 4 or 8).
|
||||
pub screenshot_quality: u32,
|
||||
/// How many times the pending scaled screenshot ran out of video memory: each
|
||||
/// halves its size, down to the window's.
|
||||
screenshot_halvings: u32,
|
||||
/// Brightness option (lightmap gamma), 0..1.
|
||||
pub gamma: f32,
|
||||
/// Optional full-light lightmap (Horizon Extras).
|
||||
@@ -765,18 +791,45 @@ pub struct Renderer {
|
||||
}
|
||||
/// Every layer's mip levels, layer major (`first` is the first layer's
|
||||
/// index into `settings`), made by `MipmapGenerator`.
|
||||
/// Block sprite layers per texture array: the smallest
|
||||
/// `max_texture_array_layers` of the GPUs Horizon runs on. Mods add sprites
|
||||
/// past it into more arrays (terrain.wgsl's `sprite`).
|
||||
/// Block sprite layers per texture array at most: the
|
||||
/// `max_texture_array_layers` of most GPUs. Mods add sprites past it into more
|
||||
/// arrays (terrain.wgsl's `sprite`); a GPU with fewer layers per array gets a
|
||||
/// smaller split ([`layer_split`]).
|
||||
pub(crate) const SPRITE_SPLIT: u32 = 2048;
|
||||
/// [`SPRITE_SPLIT`], or a smaller split from `HORIZON_SPRITE_SPLIT` (dev
|
||||
/// only: vanilla's sprites drawn from several arrays must look the same).
|
||||
fn sprite_split() -> u32 {
|
||||
/// A smaller split from `HORIZON_SPRITE_SPLIT` (dev only: vanilla's sprites
|
||||
/// drawn from several arrays must look the same).
|
||||
fn requested_split() -> Option<u32> {
|
||||
std::env::var("HORIZON_SPRITE_SPLIT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.filter(|&n| (1..=SPRITE_SPLIT).contains(&n))
|
||||
.unwrap_or(SPRITE_SPLIT)
|
||||
}
|
||||
/// The split the CPU mip chains are cut by before the adapter is known.
|
||||
fn sprite_split() -> u32 {
|
||||
requested_split().unwrap_or(SPRITE_SPLIT)
|
||||
}
|
||||
/// The block sprite layers per texture array on an adapter that holds
|
||||
/// `max_layers` layers per array: [`SPRITE_SPLIT`], `requested`
|
||||
/// (`HORIZON_SPRITE_SPLIT`) or `max_layers`, whichever is smallest, with a full
|
||||
/// array still fitting once [`array_layers`] pads it for OpenGL. Errors when
|
||||
/// `layers` sprites need more than the [`SPRITE_ARRAYS`] arrays terrain.wgsl binds.
|
||||
fn layer_split(
|
||||
max_layers: u32,
|
||||
gl: bool,
|
||||
requested: Option<u32>,
|
||||
layers: u32,
|
||||
) -> std::result::Result<u32, String> {
|
||||
let mut split = requested.unwrap_or(SPRITE_SPLIT).min(max_layers).max(1);
|
||||
if split > 1 && array_layers(split, gl) > max_layers {
|
||||
split -= 1;
|
||||
}
|
||||
if layers > split * SPRITE_ARRAYS {
|
||||
return Err(format!(
|
||||
"GPU supports {max_layers} texture layers per array; {layers} block textures need \
|
||||
more than the {SPRITE_ARRAYS} arrays of {split} Horizon draws from"
|
||||
));
|
||||
}
|
||||
Ok(split)
|
||||
}
|
||||
/// Sprite arrays terrain.wgsl binds (`sprites`, `sprites_1`...).
|
||||
const SPRITE_ARRAYS: u32 = 8;
|
||||
@@ -879,7 +932,40 @@ fn terrain_group(
|
||||
/// construction so the temporary mip chains are released after GPU upload.
|
||||
pub struct PreparedTextures {
|
||||
sprites: Vec<(Vec<u8>, u32)>,
|
||||
/// The sprite layers per array `sprites` is cut by.
|
||||
split: u32,
|
||||
forcefield: (Vec<u8>, u32),
|
||||
/// The LabPBR maps' mip chains (normals, specular), their levels, layers and size.
|
||||
pbr: Option<(Vec<u8>, Vec<u8>, u32, u32, u32)>,
|
||||
}
|
||||
/// Every level of `layers` square RGBA layers of `size`, each the mean of four texels of the
|
||||
/// one above, layer major: the LabPBR maps' mips (their channels are data, not colour).
|
||||
fn box_mip_chain(pixels: &[u8], size: u32, layers: u32) -> Vec<u8> {
|
||||
let layer_bytes = (size * size * 4) as usize;
|
||||
let mut out = Vec::new();
|
||||
for layer in pixels.chunks_exact(layer_bytes).take(layers as usize) {
|
||||
let mut level = layer.to_vec();
|
||||
let mut side = size as usize;
|
||||
out.extend_from_slice(&level);
|
||||
while side > 1 {
|
||||
let half = side / 2;
|
||||
let mut next = Vec::with_capacity(half * half * 4);
|
||||
for y in 0..half {
|
||||
for x in 0..half {
|
||||
for c in 0..4 {
|
||||
let at = |dx: usize, dy: usize| {
|
||||
u32::from(level[((2 * y + dy) * side + 2 * x + dx) * 4 + c])
|
||||
};
|
||||
next.push(((at(0, 0) + at(1, 0) + at(0, 1) + at(1, 1) + 2) / 4) as u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&next);
|
||||
level = next;
|
||||
side = half;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
impl PreparedTextures {
|
||||
pub fn new(
|
||||
@@ -910,9 +996,49 @@ impl PreparedTextures {
|
||||
mip_chain(&pixels[start..], size, settings, first, count)
|
||||
})
|
||||
.collect(),
|
||||
split,
|
||||
forcefield: mip_chain(forcefield.0, forcefield.1, &[], 0, 1),
|
||||
pbr: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// With the resource packs' LabPBR `normals` and `specular` maps (`layers` layers of
|
||||
/// `size`, as `RenderAssets` holds them; nothing when empty).
|
||||
pub fn with_pbr(mut self, normals: &[u8], specular: &[u8], size: u32, layers: u32) -> Self {
|
||||
if !normals.is_empty() {
|
||||
self.pbr = Some((
|
||||
box_mip_chain(normals, size, layers),
|
||||
box_mip_chain(specular, size, layers),
|
||||
size.trailing_zeros() + 1,
|
||||
layers,
|
||||
size,
|
||||
));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// The same mip chains cut by `split` layers per array: chains made before
|
||||
/// the adapter was known, for a GPU with fewer layers per array.
|
||||
fn resplit(mut self, layers: u32, split: u32) -> Self {
|
||||
if self.split == split || layers <= split.min(self.split) {
|
||||
self.split = split;
|
||||
return self;
|
||||
}
|
||||
let levels = self.sprites[0].1;
|
||||
let all: Vec<u8> = self
|
||||
.sprites
|
||||
.drain(..)
|
||||
.flat_map(|(bytes, _)| bytes)
|
||||
.collect();
|
||||
// Every layer's mip levels are contiguous (layer major).
|
||||
let layer_bytes = all.len() / layers as usize;
|
||||
self.sprites = all
|
||||
.chunks(layer_bytes * split as usize)
|
||||
.map(|chunk| (chunk.to_vec(), levels))
|
||||
.collect();
|
||||
self.split = split;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn mip_chain(
|
||||
@@ -1081,16 +1207,13 @@ fn describe_adapter(adapter: &wgpu::Adapter) {
|
||||
}
|
||||
/// Whether an adapter's texture arrays hold the block textures Horizon puts
|
||||
/// in one array.
|
||||
fn check_layers(limits: &wgpu::Limits, layers: u32) -> Result<()> {
|
||||
let needed = layers.min(sprite_split());
|
||||
if limits.max_texture_array_layers < needed {
|
||||
return Err(format!(
|
||||
"GPU supports {} texture layers; {needed} required",
|
||||
limits.max_texture_array_layers
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
fn check_layers(adapter: &wgpu::Adapter, layers: u32) -> Result<u32> {
|
||||
Ok(layer_split(
|
||||
adapter.limits().max_texture_array_layers,
|
||||
adapter.get_info().backend == wgpu::Backend::Gl,
|
||||
requested_split(),
|
||||
layers,
|
||||
)?)
|
||||
}
|
||||
/// Whether the swapchain can take a window of `width` x `height`: each side from 1 to the
|
||||
/// device's `max_texture_dimension_2d` (`max`), else `Surface::configure` panics. 0 is a
|
||||
@@ -1261,7 +1384,7 @@ async fn open_backend(
|
||||
describe_adapter(&adapter);
|
||||
let info = adapter.get_info();
|
||||
let limits = adapter.limits();
|
||||
if let Err(e) = check_layers(&limits, layers) {
|
||||
if let Err(e) = check_layers(&adapter, layers) {
|
||||
return Err(NoDevice::Failed(format!("{name}: {e}")));
|
||||
}
|
||||
// Mappable primary buffers: section pages that no longer fit in
|
||||
@@ -1425,14 +1548,6 @@ impl Renderer {
|
||||
previous: Option<&Self>,
|
||||
api: GraphicsApi,
|
||||
) -> Result<Self> {
|
||||
let split = sprite_split();
|
||||
if layers > split * SPRITE_ARRAYS {
|
||||
return Err(format!(
|
||||
"{layers} block textures; Horizon draws at most {}",
|
||||
split * SPRITE_ARRAYS
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let Opened {
|
||||
instance,
|
||||
surface,
|
||||
@@ -1446,7 +1561,7 @@ impl Renderer {
|
||||
// window.
|
||||
Some(renderer) => {
|
||||
describe_adapter(&renderer.adapter);
|
||||
check_layers(&renderer.adapter.limits(), layers)?;
|
||||
check_layers(&renderer.adapter, layers)?;
|
||||
Opened {
|
||||
surface: if offscreen {
|
||||
None
|
||||
@@ -1470,6 +1585,12 @@ impl Renderer {
|
||||
}
|
||||
None => open_device(&window, offscreen, api, layers).await?,
|
||||
};
|
||||
// Computed once: the sprite arrays, the shaders' constants and the
|
||||
// shader pack atlas all cut the sprites by it.
|
||||
let split = check_layers(&adapter, layers)?;
|
||||
if split < sprite_split() {
|
||||
log::info!("Horizon: {split} block textures per array (the GPU's limit)");
|
||||
}
|
||||
let info = adapter.get_info();
|
||||
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
|
||||
let size = window.inner_size();
|
||||
@@ -1847,11 +1968,53 @@ impl Renderer {
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let prepared = prepared.unwrap_or_else(|| {
|
||||
PreparedTextures::new(sprite_pixels, sprite_size, layers, sprite_mips, forcefield)
|
||||
});
|
||||
let prepared = match prepared {
|
||||
Some(prepared) => prepared.resplit(layers, split),
|
||||
None => PreparedTextures::with_split(
|
||||
sprite_pixels,
|
||||
sprite_size,
|
||||
layers,
|
||||
sprite_mips,
|
||||
forcefield,
|
||||
split,
|
||||
),
|
||||
};
|
||||
let mut mip_levels = 1;
|
||||
let gl = adapter.get_info().backend == wgpu::Backend::Gl;
|
||||
// The resource packs' LabPBR maps, for native shader packs: one array each, cut to
|
||||
// the layers the GPU has (`block_normal_map` gives the default past them).
|
||||
let pbr_maps = prepared
|
||||
.pbr
|
||||
.map(|(normals, specular, levels, layers, size)| {
|
||||
let kept = layers.min(device.limits().max_texture_array_layers);
|
||||
let count = array_layers(kept, gl);
|
||||
[("LabPBR normals", normals), ("LabPBR specular", specular)].map(
|
||||
|(label, mut data)| {
|
||||
// The extra layers are empty.
|
||||
let layer_bytes = data.len() / layers as usize;
|
||||
data.resize(layer_bytes * count as usize, 0);
|
||||
device.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: count,
|
||||
},
|
||||
mip_level_count: levels,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&data,
|
||||
)
|
||||
},
|
||||
)
|
||||
});
|
||||
let sprite_textures: Vec<wgpu::Texture> = prepared
|
||||
.sprites
|
||||
.into_iter()
|
||||
@@ -3018,6 +3181,7 @@ impl Renderer {
|
||||
spare_surface: std::sync::Mutex::new(None),
|
||||
offscreen,
|
||||
in_flight: std::collections::VecDeque::new(),
|
||||
paced_elsewhere: false,
|
||||
first_frame: if previous.is_none() {
|
||||
FirstFrame::Acquire
|
||||
} else {
|
||||
@@ -3088,6 +3252,7 @@ impl Renderer {
|
||||
screen_buffer,
|
||||
screen_capacity,
|
||||
entity_group,
|
||||
entity_bindings,
|
||||
hand_group,
|
||||
hand_globals,
|
||||
entity_buffer,
|
||||
@@ -3095,6 +3260,7 @@ impl Renderer {
|
||||
entity_batch: EntityBatch::default(),
|
||||
group,
|
||||
globals,
|
||||
pbr_maps,
|
||||
lightmap,
|
||||
sprite_textures,
|
||||
sprite_unused,
|
||||
@@ -3102,6 +3268,7 @@ impl Renderer {
|
||||
mipmaps: mip_levels - 1,
|
||||
terrain_layout: bindings,
|
||||
terrain_sprites: sprite_constants.map(|(_, v)| v),
|
||||
sprite_split: split,
|
||||
sprite_sampler,
|
||||
texture_filtering: 0,
|
||||
max_anisotropy: 1,
|
||||
@@ -3151,6 +3318,8 @@ impl Renderer {
|
||||
block_entities: Default::default(),
|
||||
section_uploaded: Default::default(),
|
||||
block_entity_revision: next_block_entity_revision(),
|
||||
light_blocks: Default::default(),
|
||||
light_revision: 0,
|
||||
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
|
||||
layout: crate::chunk::ColumnLayout::VANILLA,
|
||||
world_post,
|
||||
@@ -3181,6 +3350,8 @@ impl Renderer {
|
||||
started: Instant::now(),
|
||||
screenshot: None,
|
||||
screenshot_result: None,
|
||||
screenshot_quality: 1,
|
||||
screenshot_halvings: 0,
|
||||
gamma: 0.5,
|
||||
improved_transparency: false,
|
||||
oit: None,
|
||||
@@ -3397,6 +3568,11 @@ impl Renderer {
|
||||
pub fn section_count(&self) -> usize {
|
||||
self.sections.len()
|
||||
}
|
||||
/// Whether one of Horizon's own packs with `[shadow]` draws, whose shadow pass takes the
|
||||
/// frame's `shadow_only` quads.
|
||||
pub fn native_shadow(&self) -> bool {
|
||||
self.native.as_ref().is_some_and(|n| n.shadow.is_some())
|
||||
}
|
||||
/// What a shader pack's shadow pass draws of the entities this frame: (entities or block
|
||||
/// entities other than the player, so those Horizon's culling hides from the camera are
|
||||
/// kept for it; the local player, so its model is built in first person too). (false,
|
||||
@@ -3429,6 +3605,15 @@ impl Renderer {
|
||||
if changed {
|
||||
self.block_entity_revision = next_block_entity_revision();
|
||||
}
|
||||
let lights_changed = if mesh.lights.is_empty() {
|
||||
self.light_blocks.remove(&key).is_some()
|
||||
} else {
|
||||
let old = self.light_blocks.insert(key, mesh.lights.clone());
|
||||
old.as_deref() != Some(&mesh.lights[..])
|
||||
};
|
||||
if lights_changed {
|
||||
self.light_revision += 1;
|
||||
}
|
||||
if !self.lod_retiring.is_empty() {
|
||||
self.lod_retiring.remove(&key);
|
||||
}
|
||||
@@ -3552,7 +3737,8 @@ impl Renderer {
|
||||
/// origins bound: one indirect multi-draw, or a loop without it.
|
||||
/// Consecutive draws in one arena page share a vertex buffer binding,
|
||||
/// keeping the list's order (translucent sections go back to front).
|
||||
/// `indirect` holds the list's draws from `base`.
|
||||
/// `indirect` holds the list's draws from `base`. Leaves the quad
|
||||
/// indices bound, as the draws after it expect.
|
||||
fn draw_sections(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
@@ -3582,6 +3768,12 @@ impl Renderer {
|
||||
}
|
||||
i += run;
|
||||
}
|
||||
if pages[first as usize..end]
|
||||
.iter()
|
||||
.any(|(_, sorted)| sorted.is_some())
|
||||
{
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
}
|
||||
}
|
||||
fn release_section(&mut self, key: SectionKey) {
|
||||
if let Some(s) = self.sections.remove(key) {
|
||||
@@ -3801,6 +3993,9 @@ impl Renderer {
|
||||
for sy in 0..self.layout.sections() as i32 {
|
||||
let key = (x, sy, z);
|
||||
block_entities |= self.block_entities.remove(&key).is_some();
|
||||
if self.light_blocks.remove(&key).is_some() {
|
||||
self.light_revision += 1;
|
||||
}
|
||||
self.section_uploaded.remove(&key);
|
||||
if retiring {
|
||||
// Distant tiles still leave its column out: drawn until they don't.
|
||||
@@ -4100,9 +4295,11 @@ impl Renderer {
|
||||
horizon_shader_api::Report,
|
||||
Vec<(String, f64)>,
|
||||
Vec<(u32, u32)>,
|
||||
Vec<(u32, u32)>,
|
||||
block_light::LightTable,
|
||||
)>,
|
||||
) -> std::result::Result<Vec<String>, String> {
|
||||
let Some((report, constants, sprite_tags)) = pack else {
|
||||
let Some((report, constants, sprite_tags, sprite_materials, lights)) = pack else {
|
||||
self.native = None;
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
@@ -4112,9 +4309,19 @@ impl Renderer {
|
||||
sprites: self.terrain_sprites,
|
||||
format: self.config.format.remove_srgb_suffix(),
|
||||
sprite_tags: &sprite_tags,
|
||||
sprite_materials: &sprite_materials,
|
||||
entities: &self.entity_bindings,
|
||||
weather_groups: [&self.weather.entity_layout, &self.weather.time_layout],
|
||||
pbr: self.pbr_maps.as_ref(),
|
||||
};
|
||||
let (pack, labels) =
|
||||
native_pack::NativePack::new(&self.device, &terrain, report, &constants)?;
|
||||
let (pack, labels) = native_pack::NativePack::new(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&terrain,
|
||||
report,
|
||||
&constants,
|
||||
lights,
|
||||
)?;
|
||||
self.native = pack.draws().then_some(pack);
|
||||
if self.native.is_some() {
|
||||
self.pack_post = None;
|
||||
@@ -4164,7 +4371,7 @@ impl Renderer {
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
&self.sprite_textures,
|
||||
sprite_split(),
|
||||
self.sprite_split,
|
||||
layers,
|
||||
self.sprite_textures[0].width(),
|
||||
);
|
||||
@@ -4387,7 +4594,11 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
pub fn render(&mut self, frame: &Frame) -> Result<bool> {
|
||||
let rendered = self.draw_frame(frame);
|
||||
let scaled = self.screenshot_size();
|
||||
let rendered = match scaled {
|
||||
Some(size) => self.draw_scaled_frame(frame, size),
|
||||
None => self.draw_frame(frame, None),
|
||||
};
|
||||
// A frame that was not drawn still submits the section copies and
|
||||
// atlas patches (glyphs, skins, maps), which the caller already took.
|
||||
if !matches!(rendered, Ok(true)) && !frame.hud.patches.is_empty() {
|
||||
@@ -4410,9 +4621,74 @@ impl Renderer {
|
||||
} else if let Some(copies) = self.take_section_copies() {
|
||||
self.queue.submit([copies]);
|
||||
}
|
||||
rendered
|
||||
// A scaled screenshot's frame never reached the window.
|
||||
rendered.map(|drawn| drawn && scaled.is_none())
|
||||
}
|
||||
fn draw_frame(&mut self, frame: &Frame) -> Result<bool> {
|
||||
/// The frame size of the pending `scaled` screenshot when it is larger than the
|
||||
/// window: the window's times the Resolution Scale (when above 100%) and
|
||||
/// `screenshot_quality`, cut down to what one texture and one readback buffer hold,
|
||||
/// and halved for each time it ran out of video memory.
|
||||
fn screenshot_size(&self) -> Option<[u32; 2]> {
|
||||
let request = self.screenshot.as_ref()?;
|
||||
if !request.scaled || request.size.is_some() || self.suspended {
|
||||
return None;
|
||||
}
|
||||
let window = [self.config.width, self.config.height];
|
||||
let limits = self.device.limits();
|
||||
let factor = scaled_screenshot_factor(
|
||||
window,
|
||||
self.world_post.scale,
|
||||
self.screenshot_quality,
|
||||
limits.max_texture_dimension_2d,
|
||||
limits.max_buffer_size,
|
||||
) / 2f32.powi(self.screenshot_halvings as i32);
|
||||
(factor > 1.0).then(|| window.map(|side| (side as f32 * factor) as u32))
|
||||
}
|
||||
/// Draws one frame at `size` into a texture of its own, for the pending screenshot:
|
||||
/// every pass sizes itself by `config`, which says `size` for this frame, the HUD
|
||||
/// keeps its layout at a larger scale, and the world is drawn at `size` (a Resolution
|
||||
/// Scale below 100% stays as low). The window keeps its last frame.
|
||||
fn draw_scaled_frame(&mut self, frame: &Frame, size: [u32; 2]) -> Result<bool> {
|
||||
let window = [self.config.width, self.config.height];
|
||||
let mut config = self.config.clone();
|
||||
[config.width, config.height] = size;
|
||||
config.usage |= wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC;
|
||||
// Out of video memory (an invalid texture's view is a validation error):
|
||||
// tried again at half the size next frame; the window keeps its last one.
|
||||
let checks = GpuChecks::push(&self.device);
|
||||
let target = Self::offscreen_target(&self.device, &config);
|
||||
let depth = Self::depth(&self.device, &config);
|
||||
if let Some(e) = checks.pop() {
|
||||
log::warn!(
|
||||
"Horizon renderer: no memory for a {}x{} screenshot ({e}); trying half the size",
|
||||
size[0],
|
||||
size[1]
|
||||
);
|
||||
self.screenshot_halvings += 1;
|
||||
return Ok(false);
|
||||
}
|
||||
let scale = self.world_post.scale;
|
||||
self.world_post.scale = scale / scale.max(1.0);
|
||||
self.hud.zoom = size[0] as f32 / window[0] as f32;
|
||||
[self.config.width, self.config.height] = size;
|
||||
let surface = self.surface.take();
|
||||
// HDR presents to the swapchain; this frame goes to a file.
|
||||
let hdr = self.hdr.take();
|
||||
let offscreen = self.offscreen.replace(target);
|
||||
let window_depth = std::mem::replace(&mut self.depth, depth);
|
||||
// The frame's own targets at `size` can run out of memory too.
|
||||
let drawn = self.draw_frame(frame, Some(GpuChecks::push(&self.device)));
|
||||
self.depth = window_depth;
|
||||
self.offscreen = offscreen;
|
||||
self.hdr = hdr;
|
||||
self.surface = surface;
|
||||
[self.config.width, self.config.height] = window;
|
||||
self.hud.zoom = 1.0;
|
||||
self.world_post.scale = scale;
|
||||
drawn
|
||||
}
|
||||
/// `checks`: the error scopes of a scaled screenshot's frame, checked before its PNG is written.
|
||||
fn draw_frame(&mut self, frame: &Frame, checks: Option<GpuChecks>) -> Result<bool> {
|
||||
self.arena.trim();
|
||||
self.compact_arena();
|
||||
self.sorted_indices.trim();
|
||||
@@ -4767,7 +5043,13 @@ impl Renderer {
|
||||
.as_ref()
|
||||
.and_then(|p| p.1.as_ref())
|
||||
.filter(|_| !frame.title)
|
||||
.and_then(|p| p.shadow_view(frame));
|
||||
.and_then(|p| p.shadow_view(frame))
|
||||
.or_else(|| {
|
||||
// One of Horizon's own packs with `[shadow]` uses the same lists.
|
||||
let native = self.native.as_ref().filter(|_| !frame.title)?;
|
||||
native.shadow.as_ref()?;
|
||||
native.frame.shadow_view(frame)
|
||||
});
|
||||
let mut shadow_visible = std::mem::take(&mut self.shadow_visible);
|
||||
shadow_visible.clear();
|
||||
if let Some(v) = &shadow_view {
|
||||
@@ -5308,6 +5590,7 @@ impl Renderer {
|
||||
frame.see_through,
|
||||
oit_on,
|
||||
);
|
||||
self.entity_batch.append_shadow_only(frame.shadow_only);
|
||||
let weather_quads = frame.weather.len() as u32 / 4;
|
||||
let world_quads = entity_quads
|
||||
+ text_quads
|
||||
@@ -5322,7 +5605,8 @@ impl Renderer {
|
||||
// Where the tail of materials the classic and the improved passes
|
||||
// both draw ends: the shadows, particles and items count back from it.
|
||||
let base_quads = world_quads - oit_entity_quads;
|
||||
let cloud_quads = if let Some(input) = &frame.clouds {
|
||||
let native_clouds = self.native.as_ref().is_none_or(|n| n.vanilla_clouds);
|
||||
let cloud_quads = if let Some(input) = frame.clouds.as_ref().filter(|_| native_clouds) {
|
||||
let (rebuilt, uniform) =
|
||||
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
|
||||
let vertices = self.cloud_mesh.vertices.len() as u64;
|
||||
@@ -5364,6 +5648,7 @@ impl Renderer {
|
||||
let hand_quads = (frame.hand.len() / 4) as u32;
|
||||
let additive_quads = (frame.additive.len() / 4) as u32 - self.entity_batch.moved_additive;
|
||||
let see_through_quads = (frame.see_through.len() / 4) as u32;
|
||||
let shadow_only_quads = (frame.shadow_only.len() / 4) as u32;
|
||||
let entity_vertices = all_entities.len() as u64;
|
||||
if entity_vertices > self.entity_capacity {
|
||||
self.entity_capacity = entity_vertices.next_power_of_two();
|
||||
@@ -5409,15 +5694,16 @@ impl Renderer {
|
||||
bytemuck::cast_slice(&frame.celestial[..celestial_vertices]),
|
||||
);
|
||||
}
|
||||
let max_quads = (world_quads + additive_quads + hand_quads + see_through_quads)
|
||||
.max(cloud_quads)
|
||||
.max(if frame.lightning.bolts.is_empty() {
|
||||
0
|
||||
} else {
|
||||
lightning::QUADS
|
||||
})
|
||||
.max(weather_quads)
|
||||
.max(frame.glowing.len() as u32 / 4);
|
||||
let max_quads =
|
||||
(world_quads + additive_quads + hand_quads + see_through_quads + shadow_only_quads)
|
||||
.max(cloud_quads)
|
||||
.max(if frame.lightning.bolts.is_empty() {
|
||||
0
|
||||
} else {
|
||||
lightning::QUADS
|
||||
})
|
||||
.max(weather_quads)
|
||||
.max(frame.glowing.len() as u32 / 4);
|
||||
if max_quads > self.index_quads {
|
||||
self.index_quads = max_quads.next_power_of_two();
|
||||
self.indices = Self::quad_indices(&self.device, self.index_quads);
|
||||
@@ -5702,12 +5988,97 @@ impl Renderer {
|
||||
.filter(|_| scaled.is_some())
|
||||
.unwrap_or([self.config.width, self.config.height]);
|
||||
native.frame.write(&self.queue, frame, size);
|
||||
native.frame.block_light.update(
|
||||
&self.queue,
|
||||
frame.camera,
|
||||
&self.light_blocks,
|
||||
self.light_revision,
|
||||
);
|
||||
if let Some(t) = &mut native.timing {
|
||||
t.begin(&self.device, &self.queue);
|
||||
native_sky_pass =
|
||||
native.sky.is_some() && t.recording() && sky_visible && frame.skybox;
|
||||
}
|
||||
}
|
||||
// One of Horizon's own packs with `[shadow]`: the solid terrain the light sees, from the
|
||||
// shadow camera's lists, into the pack's shadow map (a pack without one gets its 1×1 map
|
||||
// cleared to "far" once).
|
||||
if !frame.title
|
||||
&& let Some(native) = &mut self.native
|
||||
{
|
||||
native.frame.shadow_map.clear_blank(&mut encoder);
|
||||
}
|
||||
if !frame.title
|
||||
&& shadow_view.is_some()
|
||||
&& let Some(native) = &self.native
|
||||
&& let Some(shadow) = &native.shadow
|
||||
{
|
||||
let mut pass = native
|
||||
.frame
|
||||
.shadow_map
|
||||
.begin(&mut encoder, "Native shader pack shadow");
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
|
||||
if flat_frame
|
||||
&& let Some(flat) = &self.flat
|
||||
&& let Some(pipeline) = &shadow.flat
|
||||
{
|
||||
pass.set_pipeline(pipeline);
|
||||
let [first, translucent] = flat.shadow_calls;
|
||||
flat.draw(&mut pass, &flat.calls[first..translucent], |p| {
|
||||
self.arena.buffer(p)
|
||||
});
|
||||
} else if !flat_frame {
|
||||
pass.set_pipeline(&shadow.solid);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(1, self.instances.slice(..));
|
||||
self.draw_sections(&mut pass, &self.indirect, 0, shadow_first, shadow_solid);
|
||||
}
|
||||
drop(pass);
|
||||
let mut pass = native
|
||||
.frame
|
||||
.shadow_map
|
||||
.begin_translucent(&mut encoder, "Native shader pack translucent shadow");
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
|
||||
if flat_frame
|
||||
&& let Some(flat) = &self.flat
|
||||
&& let Some(pipeline) = &shadow.flat_translucent
|
||||
{
|
||||
pass.set_pipeline(pipeline);
|
||||
let [_, translucent] = flat.shadow_calls;
|
||||
flat.draw(&mut pass, &flat.calls[translucent..], |p| {
|
||||
self.arena.buffer(p)
|
||||
});
|
||||
} else if !flat_frame {
|
||||
pass.set_pipeline(&shadow.translucent);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(1, self.instances.slice(..));
|
||||
self.draw_sections(
|
||||
&mut pass,
|
||||
&self.indirect,
|
||||
0,
|
||||
shadow_first + shadow_solid,
|
||||
shadow_translucent,
|
||||
);
|
||||
}
|
||||
drop(pass);
|
||||
if entity_quads > 0 || shadow_only_quads > 0 {
|
||||
let mut pass = native
|
||||
.frame
|
||||
.shadow_map
|
||||
.begin_entities(&mut encoder, "Native shader pack entity shadow");
|
||||
pass.set_pipeline(&shadow.entity);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
// At the end of the buffer (`EntityBatch::append_shadow_only`).
|
||||
let first = entity_vertices as u32 - shadow_only_quads * 4;
|
||||
pass.draw_indexed(0..shadow_only_quads * 6, first as i32, 0..1);
|
||||
}
|
||||
}
|
||||
if native_sky_pass
|
||||
&& let Some(native) = &mut self.native
|
||||
&& let Some(sky) = &native.sky
|
||||
@@ -5940,12 +6311,24 @@ impl Renderer {
|
||||
self.end_sky.draw(&mut pass);
|
||||
}
|
||||
if celestial_vertices > 0 && !pack_draws_celestial {
|
||||
// The Overworld's bodies start with the sun's quad
|
||||
// (`Game::celestial_geometry`): a pack sky drawing its own skips it.
|
||||
let own_sun = !frame.end_sky
|
||||
&& self
|
||||
.native
|
||||
.as_ref()
|
||||
.is_some_and(|n| n.sky.is_some() && !n.vanilla_sun);
|
||||
let first = if own_sun { 6 } else { 0 };
|
||||
pass.set_vertex_buffer(0, self.celestial_buffer.slice(..));
|
||||
pass.draw_indexed(0..(celestial_vertices as u32 / 4) * 6, 0, 0..1);
|
||||
pass.draw_indexed(first..(celestial_vertices as u32 / 4) * 6, 0, 0..1);
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if !frame.title && frame.stars.is_some() && !pack_draws_sky {
|
||||
let own_stars = self
|
||||
.native
|
||||
.as_ref()
|
||||
.is_some_and(|n| n.sky.is_some() && !n.vanilla_stars);
|
||||
if !frame.title && frame.stars.is_some() && !pack_draws_sky && !own_stars {
|
||||
self.stars.draw(&mut pass);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
@@ -6429,7 +6812,13 @@ impl Renderer {
|
||||
// terrain and clouds, so the water behind the rain is drawn first.
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
if !pack_draws_weather && !oit_on {
|
||||
self.weather.draw(&mut pass, &self.entity_group);
|
||||
let native_weather = self
|
||||
.native
|
||||
.as_ref()
|
||||
.filter(|_| !frame.title)
|
||||
.and_then(|n| Some((n.weather.as_ref()?, &n.frame.group)));
|
||||
self.weather
|
||||
.draw(&mut pass, &self.entity_group, native_weather);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
// LevelRenderer: the world border after translucent terrain and weather.
|
||||
@@ -6832,21 +7221,34 @@ impl Renderer {
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
let failed = checks.and_then(GpuChecks::pop);
|
||||
if let Some((request, buffer)) = readback {
|
||||
if let Some(hdr_buffer) = hdr_readback {
|
||||
let hdr_path = hdr_screenshot_path(&request.path);
|
||||
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
|
||||
Ok(()) => log::info!("Saved HDR screenshot to {}", hdr_path.display()),
|
||||
Err(e) => log::warn!(
|
||||
"Horizon renderer: HDR screenshot {}: {e}",
|
||||
hdr_path.display()
|
||||
),
|
||||
// Out of memory on the way: the PNG would be black; taken again at half the size.
|
||||
if let Some(e) = failed {
|
||||
log::warn!(
|
||||
"Horizon renderer: a {}x{} screenshot failed ({e}); trying half the size",
|
||||
self.config.width,
|
||||
self.config.height
|
||||
);
|
||||
self.screenshot_halvings += 1;
|
||||
self.screenshot = Some(request);
|
||||
} else {
|
||||
self.screenshot_halvings = 0;
|
||||
if let Some(hdr_buffer) = hdr_readback {
|
||||
let hdr_path = hdr_screenshot_path(&request.path);
|
||||
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
|
||||
Ok(()) => log::info!("Saved HDR screenshot to {}", hdr_path.display()),
|
||||
Err(e) => log::warn!(
|
||||
"Horizon renderer: HDR screenshot {}: {e}",
|
||||
hdr_path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&request, &buffer)
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
}
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&request, &buffer)
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
}
|
||||
let present = Instant::now();
|
||||
if let Some(surface_frame) = surface_frame {
|
||||
@@ -6870,14 +7272,9 @@ impl Renderer {
|
||||
self.configure_surface();
|
||||
}
|
||||
} else {
|
||||
// No swapchain paces offscreen frames: keep two on the GPU at most.
|
||||
self.in_flight.push_back(submission);
|
||||
while self.in_flight.len() > 2 {
|
||||
let index = self.in_flight.pop_front();
|
||||
let _ = self.device.poll(wgpu::PollType::Wait {
|
||||
submission_index: index,
|
||||
timeout: Some(std::time::Duration::from_secs(5)),
|
||||
});
|
||||
if !self.paced_elsewhere {
|
||||
self.wait_in_flight();
|
||||
}
|
||||
}
|
||||
self.last_wait += present.elapsed();
|
||||
@@ -6889,6 +7286,41 @@ impl Renderer {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
/// Out-of-memory and validation error scopes, popped together: the first error either caught.
|
||||
struct GpuChecks {
|
||||
memory: wgpu::ErrorScopeGuard,
|
||||
validation: wgpu::ErrorScopeGuard,
|
||||
}
|
||||
impl GpuChecks {
|
||||
fn push(device: &wgpu::Device) -> Self {
|
||||
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
Self { memory, validation }
|
||||
}
|
||||
fn pop(self) -> Option<wgpu::Error> {
|
||||
let validation = pollster::block_on(self.validation.pop());
|
||||
let memory = pollster::block_on(self.memory.pop());
|
||||
memory.or(validation)
|
||||
}
|
||||
}
|
||||
/// How many times the window's size a scaled screenshot is: the Resolution Scale
|
||||
/// `world_scale` when above 1, times `quality`, as far as a side fits in
|
||||
/// `max_side` pixels and the readback in `max_buffer` bytes.
|
||||
fn scaled_screenshot_factor(
|
||||
[width, height]: [u32; 2],
|
||||
world_scale: f32,
|
||||
quality: u32,
|
||||
max_side: u32,
|
||||
max_buffer: u64,
|
||||
) -> f32 {
|
||||
let wanted = world_scale.max(1.0) * quality.max(1) as f32;
|
||||
let (w, h) = (f64::from(width.max(1)), f64::from(height.max(1)));
|
||||
let side = f64::from(max_side) / w.max(h);
|
||||
// padded_row(w * f) * h * f <= (4 * w * f + 256) * h * f <= max_buffer.
|
||||
let (a, b) = (4.0 * w * h, 256.0 * h);
|
||||
let buffer = ((b * b + 4.0 * a * max_buffer as f64).sqrt() - b) / (2.0 * a);
|
||||
wanted.min(side.min(buffer) as f32)
|
||||
}
|
||||
/// Bytes per row of a `width`-pixel frame read back at 4 bytes a pixel.
|
||||
fn padded_row(width: u32) -> u32 {
|
||||
(width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
|
||||
@@ -6925,6 +7357,16 @@ pub(crate) fn copy_frame(
|
||||
buffer
|
||||
}
|
||||
impl Renderer {
|
||||
/// No swapchain paces offscreen frames: keep two on the GPU at most.
|
||||
pub fn wait_in_flight(&mut self) {
|
||||
while self.in_flight.len() > 2 {
|
||||
let index = self.in_flight.pop_front();
|
||||
let _ = self.device.poll(wgpu::PollType::Wait {
|
||||
submission_index: index,
|
||||
timeout: Some(std::time::Duration::from_secs(5)),
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Take a screenshot request: queue the frame's copy, or report why the
|
||||
/// frame cannot be read back.
|
||||
fn take_readback(
|
||||
@@ -7297,6 +7739,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn scaled_screenshots_follow_resolution_scale_and_quality_within_limits() {
|
||||
use super::scaled_screenshot_factor as factor;
|
||||
let big = u64::MAX / 2;
|
||||
// The window's size at 100% and 1x; Resolution Scale below 100% stays the window's.
|
||||
assert_eq!(factor([1920, 1080], 1.0, 1, 16384, big), 1.0);
|
||||
assert_eq!(factor([1920, 1080], 0.5, 1, 16384, big), 1.0);
|
||||
// The game's resolution, times the quality.
|
||||
assert_eq!(factor([1920, 1080], 2.0, 1, 16384, big), 2.0);
|
||||
assert_eq!(factor([1920, 1080], 1.5, 4, 16384, big), 6.0);
|
||||
assert_eq!(factor([1920, 1080], 0.5, 8, 16384, big), 8.0);
|
||||
// Cut to the largest texture side: 3840 * 4.266 = 16384.
|
||||
let f = factor([3840, 2160], 1.0, 8, 16384, big);
|
||||
assert!(
|
||||
(3840.0 * f) as u32 <= 16384 && (3840.0 * f) as u32 > 16300,
|
||||
"{f}"
|
||||
);
|
||||
// Cut to the largest buffer: 256 MiB holds about 3.8x of 1080p's readback.
|
||||
let max = 256 << 20;
|
||||
let f = factor([1920, 1080], 1.0, 8, 16384, max);
|
||||
let [w, h] = [1920, 1080].map(|s| (s as f32 * f) as u32);
|
||||
assert!(u64::from(super::padded_row(w)) * u64::from(h) <= max, "{f}");
|
||||
assert!(f > 3.7, "{f}");
|
||||
}
|
||||
#[test]
|
||||
fn drawable_size_skips_minimized_and_oversized_windows() {
|
||||
use super::drawable_size;
|
||||
assert!(drawable_size(1280, 720, 8192));
|
||||
@@ -7905,9 +8371,52 @@ mod sprite_upload_tests {
|
||||
.collect();
|
||||
assert_eq!(actual, expected.0);
|
||||
assert_eq!(prepared.forcefield, mip_chain(&border, size, &[], 0, 1));
|
||||
// Chains cut before the adapter was known, cut again for it.
|
||||
for to in [1, 2, 3, 5, 8] {
|
||||
let direct = PreparedTextures::with_split(
|
||||
&pixels,
|
||||
size,
|
||||
layers,
|
||||
&settings,
|
||||
(&border, size),
|
||||
to,
|
||||
);
|
||||
let resplit = PreparedTextures::with_split(
|
||||
&pixels,
|
||||
size,
|
||||
layers,
|
||||
&settings,
|
||||
(&border, size),
|
||||
split,
|
||||
)
|
||||
.resplit(layers, to);
|
||||
assert_eq!(resplit.split, to);
|
||||
assert_eq!(resplit.sprites, direct.sprites, "{split} -> {to}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_split_follows_the_adapters_layer_limit() {
|
||||
// The Discord report: 512 layers per array, 1291 block textures (3 arrays).
|
||||
assert_eq!(layer_split(512, false, None, 1291), Ok(512));
|
||||
assert_eq!(layer_split(512, true, None, 1291), Ok(512));
|
||||
assert_eq!(layer_split(2048, false, None, 1291), Ok(SPRITE_SPLIT));
|
||||
assert_eq!(layer_split(8192, false, None, 1291), Ok(SPRITE_SPLIT));
|
||||
// HORIZON_SPRITE_SPLIT only lowers it.
|
||||
assert_eq!(layer_split(2048, false, Some(512), 1291), Ok(512));
|
||||
assert_eq!(layer_split(256, false, Some(512), 1291), Ok(256));
|
||||
// OpenGL pads a multiple of 6 by a layer: a full array must still fit.
|
||||
assert_eq!(layer_split(600, true, None, 1291), Ok(599));
|
||||
assert_eq!(layer_split(600, false, None, 1291), Ok(600));
|
||||
assert_eq!(array_layers(599, true), 599);
|
||||
// Past the arrays terrain.wgsl binds.
|
||||
assert_eq!(layer_split(256, false, None, 256 * SPRITE_ARRAYS), Ok(256));
|
||||
let err = layer_split(128, false, None, 1291).unwrap_err();
|
||||
assert!(err.contains("128 texture layers per array"), "{err}");
|
||||
assert!(layer_split(512, false, None, 512 * SPRITE_ARRAYS + 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mip_chain_allocates_exactly_its_size() {
|
||||
for size in [16, 64] {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! The colour of block light for Horizon's shader packs (`[[lights]]`, docs/api/shaders.md):
|
||||
//! a grid of cells around the camera, each holding the colour of the light sources that
|
||||
//! reach it, averaged by how near each is, which the material stage reads through the
|
||||
//! prelude's `block_light_color`. Block light keeps its own brightness (the light engine's,
|
||||
//! which walls stop): the grid only tints it, so it needs no occlusion of its own.
|
||||
//!
|
||||
//! The grid is rebuilt on the CPU when the camera moves on to another 16-block step or the
|
||||
//! light sources change, from the sources the section meshes list (`SectionMesh::lights`).
|
||||
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use glam::{DVec3, Vec3};
|
||||
|
||||
use super::SectionKey;
|
||||
|
||||
/// Cells along each side of the grid.
|
||||
pub const SIZE: u32 = 64;
|
||||
/// The side of a cell, in blocks: the grid spans 128 blocks.
|
||||
pub const CELL: f32 = 2.0;
|
||||
/// The grid moves in steps of this many blocks.
|
||||
const STEP: i32 = 16;
|
||||
/// The most sources one rebuild splats, the nearest first (a lava lake lists thousands).
|
||||
const MAX_SOURCES: usize = 2048;
|
||||
/// `[[lights]]` colours go up to 4; the cells keep them over 4 in 8 bits.
|
||||
const COLOR_RANGE: f32 = 4.0;
|
||||
|
||||
/// The light a block state gives off: its `[[lights]]` colour and its level, 1 to 15.
|
||||
pub type LightTable = FxHashMap<u32, ([f32; 3], u8)>;
|
||||
|
||||
pub struct BlockLight {
|
||||
texture: wgpu::Texture,
|
||||
pub view: wgpu::TextureView,
|
||||
/// `block_light_grid`: the grid's corner, camera-relative, and the side of a cell.
|
||||
pub grid: wgpu::Buffer,
|
||||
table: LightTable,
|
||||
/// The world corner and the sources' revision the texture was last built for.
|
||||
built: Option<([i32; 3], u64)>,
|
||||
}
|
||||
|
||||
impl BlockLight {
|
||||
pub fn new(device: &wgpu::Device, table: LightTable) -> Self {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Native shader pack block light colours"),
|
||||
size: wgpu::Extent3d {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
depth_or_array_layers: SIZE,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D3,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
let grid = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Native shader pack block light grid"),
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
Self {
|
||||
view: texture.create_view(&Default::default()),
|
||||
texture,
|
||||
grid,
|
||||
table,
|
||||
built: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Places the grid around `camera`, rebuilding it when it moved or `revision` (of
|
||||
/// `sections`' sources) changed.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
queue: &wgpu::Queue,
|
||||
camera: DVec3,
|
||||
sections: &FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
revision: u64,
|
||||
) {
|
||||
let half = (SIZE as f32 * CELL / 2.0) as f64;
|
||||
let origin = ((camera - half) / f64::from(STEP))
|
||||
.floor()
|
||||
.as_ivec3()
|
||||
.to_array()
|
||||
.map(|v| v * STEP);
|
||||
let corner = (glam::IVec3::from(origin).as_dvec3() - camera).as_vec3();
|
||||
queue.write_buffer(
|
||||
&self.grid,
|
||||
0,
|
||||
bytemuck::cast_slice(&[corner.x, corner.y, corner.z, CELL]),
|
||||
);
|
||||
if self.built == Some((origin, revision)) {
|
||||
return;
|
||||
}
|
||||
self.built = Some((origin, revision));
|
||||
let cells = build(&self.table, origin, camera, sections);
|
||||
queue.write_texture(
|
||||
self.texture.as_image_copy(),
|
||||
&cells,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(SIZE * 4),
|
||||
rows_per_image: Some(SIZE),
|
||||
},
|
||||
self.texture.size(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The cells of the grid with its corner at `origin`: RGBA8, x fastest, then y, then z.
|
||||
fn build(
|
||||
table: &LightTable,
|
||||
origin: [i32; 3],
|
||||
camera: DVec3,
|
||||
sections: &FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
) -> Vec<u8> {
|
||||
let n = SIZE as usize;
|
||||
let mut cells = vec![0u8; n * n * n * 4];
|
||||
if table.is_empty() {
|
||||
return cells;
|
||||
}
|
||||
let span = SIZE as i32 * CELL as i32;
|
||||
// Sections the grid or the light reaching into it (15 blocks) touches.
|
||||
let lo = origin.map(|v| (v - 15).div_euclid(16));
|
||||
let hi = origin.map(|v| (v + span + 15).div_euclid(16));
|
||||
let mut sources: Vec<(Vec3, [f32; 3], f32, f64)> = Vec::new();
|
||||
for (key, lights) in sections {
|
||||
let key = [key.0, key.1 + crate::chunk::MIN_Y.div_euclid(16), key.2];
|
||||
if (0..3).any(|i| key[i] < lo[i] || key[i] > hi[i]) {
|
||||
continue;
|
||||
}
|
||||
for &(pos, state) in lights {
|
||||
let Some(&(color, level)) = table.get(&state) else {
|
||||
continue;
|
||||
};
|
||||
let center = glam::IVec3::from(pos).as_dvec3() + 0.5;
|
||||
let local = (center - glam::IVec3::from(origin).as_dvec3()).as_vec3();
|
||||
sources.push((
|
||||
local,
|
||||
color,
|
||||
f32::from(level),
|
||||
center.distance_squared(camera),
|
||||
));
|
||||
}
|
||||
}
|
||||
if sources.len() > MAX_SOURCES {
|
||||
sources.select_nth_unstable_by(MAX_SOURCES, |a, b| a.3.total_cmp(&b.3));
|
||||
sources.truncate(MAX_SOURCES);
|
||||
}
|
||||
let mut sum = vec![[0.0f32; 4]; n * n * n];
|
||||
for (at, color, reach, _) in sources {
|
||||
let first = ((at - reach) / CELL).floor().max(Vec3::ZERO).as_ivec3();
|
||||
let last = ((at + reach) / CELL)
|
||||
.ceil()
|
||||
.min(Vec3::splat(SIZE as f32 - 1.0))
|
||||
.as_ivec3();
|
||||
for z in first.z..=last.z {
|
||||
for y in first.y..=last.y {
|
||||
for x in first.x..=last.x {
|
||||
let cell = (Vec3::new(x as f32, y as f32, z as f32) + 0.5) * CELL;
|
||||
let d = cell.distance(at);
|
||||
if d >= reach {
|
||||
continue;
|
||||
}
|
||||
// As block light falls off: strongest at the source, gone at its level.
|
||||
let w = (1.0 - d / reach).powi(2);
|
||||
let s = &mut sum[x as usize + n * (y as usize + n * z as usize)];
|
||||
s[0] += color[0] * w;
|
||||
s[1] += color[1] * w;
|
||||
s[2] += color[2] * w;
|
||||
s[3] += w;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (cell, s) in cells.as_chunks_mut::<4>().0.iter_mut().zip(&sum) {
|
||||
if s[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
for i in 0..3 {
|
||||
cell[i] = byte(s[i] / s[3] / COLOR_RANGE);
|
||||
}
|
||||
cell[3] = byte(s[3]);
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_source_tints_the_cells_it_reaches_with_its_colour() {
|
||||
let mut table = LightTable::default();
|
||||
table.insert(7, ([2.0, 1.0, 0.0], 14));
|
||||
let mut sections = FxHashMap::default();
|
||||
// A torch at world (8, 64, 8): section y = (64 - MIN_Y) / 16.
|
||||
let sy = (64 - crate::chunk::MIN_Y) / 16;
|
||||
sections.insert((0, sy, 0), vec![([8, 64, 8], 7)]);
|
||||
let camera = DVec3::new(8.0, 64.0, 8.0);
|
||||
let origin = [-64, 0, -64];
|
||||
let cells = build(&table, origin, camera, §ions);
|
||||
let n = SIZE as usize;
|
||||
let at = |p: [i32; 3]| {
|
||||
let c = p
|
||||
.iter()
|
||||
.zip(origin)
|
||||
.map(|(v, o)| ((v - o) as f32 / CELL) as usize)
|
||||
.collect::<Vec<_>>();
|
||||
let i = (c[0] + n * (c[1] + n * c[2])) * 4;
|
||||
[cells[i], cells[i + 1], cells[i + 2], cells[i + 3]]
|
||||
};
|
||||
let near = at([8, 64, 8]);
|
||||
// The colour over 4, whatever the weight: (2, 1, 0) / 4.
|
||||
assert_eq!(&near[..3], &[128, 64, 0]);
|
||||
assert!(near[3] > 150);
|
||||
assert_eq!(at([8, 64, 40]), [0; 4], "past its level, nothing");
|
||||
assert!(
|
||||
build(&LightTable::default(), origin, camera, §ions)
|
||||
.iter()
|
||||
.all(|&b| b == 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,11 @@ pub(super) struct EntityBatch {
|
||||
}
|
||||
|
||||
impl EntityBatch {
|
||||
/// After `update`: quads only a native pack's shadow pass draws, at the buffer's end.
|
||||
pub(super) fn append_shadow_only(&mut self, shadow_only: &[Vertex]) {
|
||||
self.combined.extend_from_slice(shadow_only);
|
||||
}
|
||||
|
||||
pub(super) fn update(
|
||||
&mut self,
|
||||
entities: &[Vertex],
|
||||
|
||||
@@ -18,6 +18,14 @@ const PREFIX: &str = "horizon_terrain_";
|
||||
/// The entries around the pack's `material`: vanilla's vertex work without the lightmap
|
||||
/// product, then the material, the alpha test and fog as `fs_solid` and `fs_translucent` do.
|
||||
const ENTRIES: &str = "
|
||||
// The manifest's `[material] fog`: false when the pack fogs its surfaces itself.
|
||||
override horizon_material_fog: bool = true;
|
||||
fn horizon_material_fogged(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
|
||||
if horizon_material_fog {
|
||||
return horizon_terrain_apply_fog(color, spherical, cylindrical);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
struct HorizonMaterialOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@@ -79,26 +87,52 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
|
||||
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
|
||||
color, light, origin.xyz);
|
||||
}
|
||||
// The prelude's `block_normal_map` and `block_specular_map`: the resource packs' LabPBR maps
|
||||
// in the pack's frame group (3), one array each, a blank 1x1 without them.
|
||||
@group(3) @binding(12) var horizon_pbr_normals: texture_2d_array<f32>;
|
||||
@group(3) @binding(13) var horizon_pbr_specular: texture_2d_array<f32>;
|
||||
fn horizon_block_pbr_present() -> bool {
|
||||
return textureDimensions(horizon_pbr_normals).x > 1u;
|
||||
}
|
||||
fn horizon_block_pbr_size() -> vec2<f32> {
|
||||
return vec2<f32>(textureDimensions(horizon_pbr_normals).xy);
|
||||
}
|
||||
fn horizon_block_pbr(uv: vec2<f32>, layer: u32, lod: f32, map: u32) -> vec4<f32> {
|
||||
if !horizon_block_pbr_present() || layer >= textureNumLayers(horizon_pbr_normals) {
|
||||
return select(vec4<f32>(0.5, 0.5, 1.0, 1.0), vec4<f32>(0.0), map == 1u);
|
||||
}
|
||||
let s = horizon_terrain_sprite_sampler;
|
||||
if map == 0u {
|
||||
return textureSampleLevel(horizon_pbr_normals, s, uv, i32(layer), lod);
|
||||
}
|
||||
return textureSampleLevel(horizon_pbr_specular, s, uv, i32(layer), lod);
|
||||
}
|
||||
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
|
||||
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
|
||||
horizon_block_layer = in.layer & 0x7fffu;
|
||||
// The mip level of this point in the maps: their own texels, as the sampler would pick.
|
||||
let texels = in.uv * horizon_block_pbr_size();
|
||||
let footprint = max(dot(dpdx(texels), dpdx(texels)), dot(dpdy(texels), dpdy(texels)));
|
||||
horizon_block_lod = max(0.5 * log2(max(footprint, 1e-8)), 0.0);
|
||||
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
|
||||
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
|
||||
let normal = horizon_material_normal(in.position);
|
||||
let tangent = horizon_material_tangent(in.position, in.uv, normal);
|
||||
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
|
||||
translucent, horizon_sprite_tags(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
|
||||
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
|
||||
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
|
||||
in.face, fluid, in.emission, normal, tangent)).color;
|
||||
}
|
||||
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_material_color(in, false);
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
return horizon_terrain_apply_fog(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
|
||||
return horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
|
||||
}
|
||||
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_material_color(in, true);
|
||||
if color.a < 0.01 { discard; }
|
||||
return horizon_terrain_apply_fog(color, in.spherical, in.cylindrical);
|
||||
return horizon_material_fogged(color, in.spherical, in.cylindrical);
|
||||
}
|
||||
";
|
||||
|
||||
@@ -115,6 +149,16 @@ pub struct TerrainShape<'a> {
|
||||
/// The pack's tags of each tagged sprite layer, as `(layer, bits)`
|
||||
/// (`Manifest::sprite_tags`).
|
||||
pub sprite_tags: &'a [(u32, u32)],
|
||||
/// The pack's material id of each sprite layer with one, as `(layer, id)`
|
||||
/// (`Manifest::sprite_materials`).
|
||||
pub sprite_materials: &'a [(u32, u32)],
|
||||
/// The entities' group 0 (their atlas), for the shadow pass's entity draw.
|
||||
pub entities: &'a wgpu::BindGroupLayout,
|
||||
/// The weather's groups 0 and 1 (the entities' atlas, the fall clock), for the weather
|
||||
/// stage.
|
||||
pub weather_groups: [&'a wgpu::BindGroupLayout; 2],
|
||||
/// The resource packs' LabPBR maps (normals, specular), when they ship any.
|
||||
pub pbr: Option<&'a [wgpu::Texture; 2]>,
|
||||
}
|
||||
|
||||
/// The pack's terrain pipelines: solid (with the cutout test) and translucent, for the
|
||||
@@ -134,12 +178,14 @@ fn vanilla_terrain() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// `horizon_sprite_tags(layer)`: the bits of `MaterialInput.tags` of each sprite layer.
|
||||
fn sprite_tags(tags: &[(u32, u32)]) -> String {
|
||||
let mut out = String::from("fn horizon_sprite_tags(layer: u32) -> u32 {\n switch layer {\n");
|
||||
let mut tags = tags.to_vec();
|
||||
tags.sort_unstable_by_key(|&(layer, bits)| (bits, layer));
|
||||
for group in tags.chunk_by(|a, b| a.1 == b.1) {
|
||||
/// The function `name(layer)`, the value of each sprite layer in `table` and 0 for the rest:
|
||||
/// `horizon_sprite_tags` (`MaterialInput.tags`) and `horizon_sprite_material`
|
||||
/// (`MaterialInput.material`).
|
||||
fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
|
||||
let mut out = format!("fn {name}(layer: u32) -> u32 {{\n switch layer {{\n");
|
||||
let mut table = table.to_vec();
|
||||
table.sort_unstable_by_key(|&(layer, value)| (value, layer));
|
||||
for group in table.chunk_by(|a, b| a.1 == b.1) {
|
||||
let layers: Vec<String> = group.iter().map(|(layer, _)| format!("{layer}u")).collect();
|
||||
out += &format!(
|
||||
" case {}: {{ return {}u; }}\n",
|
||||
@@ -150,18 +196,24 @@ fn sprite_tags(tags: &[(u32, u32)]) -> String {
|
||||
out + " default: { return 0u; }\n }\n}\n"
|
||||
}
|
||||
|
||||
/// The terrain shader for the pack's material stage `source`, with its sprite `tags`.
|
||||
fn shader_source(source: &str, tags: &[(u32, u32)]) -> String {
|
||||
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
|
||||
/// `materials`.
|
||||
fn shader_source(source: &str, tags: &[(u32, u32)], materials: &[(u32, u32)]) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{ENTRIES}",
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{ENTRIES}",
|
||||
vanilla_terrain(),
|
||||
sprite_tags(tags)
|
||||
layer_table("horizon_sprite_tags", tags),
|
||||
layer_table("horizon_sprite_material", materials)
|
||||
)
|
||||
}
|
||||
|
||||
/// The material's naga module, its errors in the stage's file.
|
||||
fn module(stage: &CheckedStage, tags: &[(u32, u32)]) -> Result<naga::Module, String> {
|
||||
let text = shader_source(&stage.source, tags);
|
||||
fn module(
|
||||
stage: &CheckedStage,
|
||||
tags: &[(u32, u32)],
|
||||
materials: &[(u32, u32)],
|
||||
) -> Result<naga::Module, String> {
|
||||
let text = shader_source(&stage.source, tags, materials);
|
||||
naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
|
||||
}
|
||||
@@ -174,14 +226,16 @@ impl NativeMaterial {
|
||||
frame: &NativeFrame,
|
||||
stage: &CheckedStage,
|
||||
constants: &[(String, f64)],
|
||||
fog: bool,
|
||||
) -> Result<Self, String> {
|
||||
let module = module(stage, shape.sprite_tags)?;
|
||||
let module = module(stage, shape.sprite_tags, shape.sprite_materials)?;
|
||||
let mut constants = present_constants(&module, constants);
|
||||
let sprite_names = [
|
||||
"horizon_terrain_SPRITE_ARRAYS",
|
||||
"horizon_terrain_SPRITE_SPLIT",
|
||||
];
|
||||
constants.extend(sprite_names.into_iter().zip(shape.sprites));
|
||||
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&stage.label),
|
||||
@@ -346,14 +400,20 @@ mod tests {
|
||||
return Surface(apply_fog(sprite(lit)) + (behind + frame_of) * 0.0);\n}\n";
|
||||
let stage = stage(own);
|
||||
let tags = [(7, 1), (3, 1), (9, 2)];
|
||||
assert!(sprite_tags(&tags).contains("case 3u, 7u: { return 1u; }"));
|
||||
let module = module(&stage, &tags).unwrap();
|
||||
let materials = [(5, 1000)];
|
||||
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
|
||||
let module = module(&stage, &tags, &materials).unwrap();
|
||||
let info = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source, &tags))));
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"{}",
|
||||
e.emit_to_string(&shader_source(&stage.source, &tags, &materials))
|
||||
)
|
||||
});
|
||||
let entries: Vec<&str> = module
|
||||
.entry_points
|
||||
.iter()
|
||||
|
||||
@@ -5,6 +5,7 @@ use horizon_shader_api::{Report, StageKind};
|
||||
|
||||
use super::native_material::{NativeMaterial, TerrainShape};
|
||||
use super::native_post::{NativeFrame, NativePost};
|
||||
use super::native_shadow::NativeShadow;
|
||||
use super::native_sky::NativeSky;
|
||||
use super::pack_timing::PassTiming;
|
||||
|
||||
@@ -13,6 +14,13 @@ pub struct NativePack {
|
||||
pub sky: Option<NativeSky>,
|
||||
pub material: Option<NativeMaterial>,
|
||||
pub post: Option<NativePost>,
|
||||
/// With a sky stage, what of Minecraft's sky still draws over it (`[sky]`).
|
||||
pub vanilla_clouds: bool,
|
||||
pub vanilla_sun: bool,
|
||||
pub vanilla_stars: bool,
|
||||
pub weather: Option<wgpu::RenderPipeline>,
|
||||
/// The draw of the shadow map the stages read, if the pack asks for one.
|
||||
pub shadow: Option<NativeShadow>,
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
pub timing: Option<PassTiming>,
|
||||
}
|
||||
@@ -24,12 +32,24 @@ impl NativePack {
|
||||
/// refused.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
terrain: &TerrainShape<'_>,
|
||||
report: Report,
|
||||
constants: &[(String, f64)],
|
||||
lights: super::block_light::LightTable,
|
||||
) -> Result<(Self, Vec<String>), String> {
|
||||
let (globals, format) = (terrain.globals, terrain.format);
|
||||
let mut frame = NativeFrame::new(device);
|
||||
let textures = super::native_post::pack_textures(device, queue, &report);
|
||||
let settings = report
|
||||
.shadow
|
||||
.as_ref()
|
||||
.map(|settings| super::native_shadow::fitted(device, settings));
|
||||
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
|
||||
let shadow = report
|
||||
.shadow
|
||||
.as_ref()
|
||||
.map(|s| NativeShadow::new(device, terrain, &frame, &s.solid_materials))
|
||||
.transpose()?;
|
||||
// The mirror is drawn for a material that reads it, while `[reflection] option` is on.
|
||||
let option = report.reflection.as_ref().and_then(|r| r.option.as_ref());
|
||||
frame.reflects = report
|
||||
@@ -43,6 +63,34 @@ impl NativePack {
|
||||
.find(|(k, _)| k == id)
|
||||
.is_none_or(|(_, v)| *v != 0.0)
|
||||
});
|
||||
let fog = report.material_fog;
|
||||
let keeps = |key: fn(&horizon_shader_api::Stage) -> Option<bool>| {
|
||||
report.sky.as_ref().and_then(key).unwrap_or(true)
|
||||
};
|
||||
let vanilla_clouds = keeps(|s| s.clouds);
|
||||
let vanilla_sun = keeps(|s| s.sun);
|
||||
let vanilla_stars = keeps(|s| s.stars);
|
||||
let weather = report
|
||||
.stages
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Weather)
|
||||
.map(|stage| {
|
||||
super::weather::native_pipeline(
|
||||
device,
|
||||
format,
|
||||
terrain.weather_groups[0],
|
||||
terrain.weather_groups[1],
|
||||
&frame,
|
||||
stage,
|
||||
constants,
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
let weather_label = report
|
||||
.stages
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Weather)
|
||||
.map(|s| s.label.clone());
|
||||
let (post, others): (Vec<_>, Vec<_>) = report
|
||||
.stages
|
||||
.into_iter()
|
||||
@@ -55,10 +103,10 @@ impl NativePack {
|
||||
let material = others
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Material)
|
||||
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants))
|
||||
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants, fog))
|
||||
.transpose()?;
|
||||
let post = (!post.is_empty())
|
||||
.then(|| NativePost::new(device, post, constants, format, &frame))
|
||||
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
|
||||
.transpose()?;
|
||||
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
|
||||
labels.extend(
|
||||
@@ -67,6 +115,7 @@ impl NativePack {
|
||||
.filter(|s| material.is_some() && s.kind == StageKind::Material)
|
||||
.map(|s| s.label.clone()),
|
||||
);
|
||||
labels.extend(weather_label);
|
||||
if let Some(post) = &post {
|
||||
labels.extend(post.labels().into_iter().map(str::to_owned));
|
||||
}
|
||||
@@ -75,6 +124,11 @@ impl NativePack {
|
||||
sky,
|
||||
material,
|
||||
post,
|
||||
vanilla_clouds,
|
||||
vanilla_sun,
|
||||
vanilla_stars,
|
||||
weather,
|
||||
shadow,
|
||||
timing: None,
|
||||
};
|
||||
Ok((pack, labels))
|
||||
@@ -82,6 +136,9 @@ impl NativePack {
|
||||
|
||||
/// Whether the pack draws anything the game runs today.
|
||||
pub fn draws(&self) -> bool {
|
||||
self.sky.is_some() || self.material.is_some() || self.post.is_some()
|
||||
self.sky.is_some()
|
||||
|| self.material.is_some()
|
||||
|| self.weather.is_some()
|
||||
|| self.post.is_some()
|
||||
}
|
||||
}
|
||||
@@ -16,14 +16,72 @@ const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
/// The world's colour and depth views a set of bind groups was made for.
|
||||
type WorldViews = (wgpu::TextureView, wgpu::TextureView);
|
||||
|
||||
/// Where a post pass draws.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Writes {
|
||||
/// One of the chain's two targets.
|
||||
Chain(usize),
|
||||
/// A pack target (`[[post]] output`).
|
||||
Target(usize),
|
||||
/// The screen: the last pass.
|
||||
Screen,
|
||||
}
|
||||
|
||||
/// A post pass's target, and the chain's picture it reads as `previous` (the world's for
|
||||
/// none).
|
||||
#[derive(Clone, Copy)]
|
||||
struct PassPlan {
|
||||
writes: Writes,
|
||||
reads: Option<usize>,
|
||||
}
|
||||
|
||||
/// The passes' plan for `outputs` (each pass's `[[post]] output`, by target index): the chain's
|
||||
/// passes write its targets in turn, each reading what the one before wrote; a pass with an
|
||||
/// output writes that and reads the chain's picture, which goes on past it.
|
||||
fn plan(outputs: &[Option<usize>]) -> Vec<PassPlan> {
|
||||
let mut current = None;
|
||||
outputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, output)| {
|
||||
let reads = current;
|
||||
let writes = match output {
|
||||
_ if i + 1 == outputs.len() => Writes::Screen,
|
||||
Some(t) => Writes::Target(*t),
|
||||
None => {
|
||||
let slot = usize::from(current == Some(0));
|
||||
current = Some(slot);
|
||||
Writes::Chain(slot)
|
||||
}
|
||||
};
|
||||
PassPlan { writes, reads }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A pack target's size: `scale` of the world's `size`, at least a texel.
|
||||
fn scaled(size: [u32; 2], scale: f32) -> [u32; 2] {
|
||||
size.map(|s| ((s as f32 * scale).ceil() as u32).max(1))
|
||||
}
|
||||
|
||||
pub struct NativePost {
|
||||
passes: Vec<(String, wgpu::RenderPipeline)>,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
/// The pack's textures.
|
||||
textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
|
||||
frame: wgpu::Buffer,
|
||||
linear: wgpu::Sampler,
|
||||
nearest: wgpu::Sampler,
|
||||
/// Two targets of the world's size, written in turn (none with a single pass).
|
||||
targets: Option<([u32; 2], [wgpu::TextureView; 2])>,
|
||||
/// The frame's shadow map, its comparison sampler (bindings 6 and 7), the translucent map
|
||||
/// and its colour (bindings 9 and 10).
|
||||
shadow: (
|
||||
wgpu::TextureView,
|
||||
wgpu::Sampler,
|
||||
wgpu::TextureView,
|
||||
wgpu::TextureView,
|
||||
),
|
||||
/// Last frame's input to the last pass (`history`, binding 8), made with the targets,
|
||||
/// and a black 1×1 in its place before then.
|
||||
history: Option<wgpu::Texture>,
|
||||
@@ -32,6 +90,12 @@ pub struct NativePost {
|
||||
groups: Option<(WorldViews, bool, Vec<wgpu::BindGroup>)>,
|
||||
/// Whether `history` contains the last frame's input to the last pass.
|
||||
history_valid: bool,
|
||||
/// What each pass writes and reads (`[[post]] output`).
|
||||
plan: Vec<PassPlan>,
|
||||
/// The pack's targets' scales (`Manifest::post_targets`), and their views for the world's
|
||||
/// size.
|
||||
scales: Vec<f32>,
|
||||
named: Option<([u32; 2], Vec<wgpu::TextureView>)>,
|
||||
/// The screen's colour format, the world target's too.
|
||||
format: wgpu::TextureFormat,
|
||||
}
|
||||
@@ -42,6 +106,10 @@ pub struct NativeFrame {
|
||||
pub(super) buffer: wgpu::Buffer,
|
||||
/// Group 3 of the sky and material stages.
|
||||
pub layout: wgpu::BindGroupLayout,
|
||||
/// The pack's `[[textures]]` and their samplers, bound for every stage.
|
||||
pub textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
|
||||
/// The colour of block light around the camera (`[[lights]]`), bindings 9 and 10.
|
||||
pub block_light: super::block_light::BlockLight,
|
||||
pub group: wgpu::BindGroup,
|
||||
/// The world's depth before translucent terrain (the material stage's `opaque_depth`,
|
||||
/// binding 6), copied each frame.
|
||||
@@ -49,11 +117,17 @@ pub struct NativeFrame {
|
||||
/// The world's colour before translucent terrain (`opaque_color`, binding 11), copied
|
||||
/// with it; 1×1 while the world draws straight to the window.
|
||||
pub opaque_color: wgpu::Texture,
|
||||
/// The resource packs' LabPBR maps (bindings 12 and 13: normals, specular), a blank 1x1
|
||||
/// without them.
|
||||
pbr: [wgpu::TextureView; 2],
|
||||
/// The material stage's `reflection` (binding 1): this frame's water mirror, else
|
||||
/// `blank`; and its sampler (binding 2).
|
||||
reflection: wgpu::TextureView,
|
||||
blank: wgpu::TextureView,
|
||||
reflection_sampler: wgpu::Sampler,
|
||||
/// The shadow map the stages read, and the camera that draws it when the pack asks for one.
|
||||
pub shadow_map: super::native_shadow::ShadowMap,
|
||||
shadow_camera: Option<horizon_shaders::Shadow>,
|
||||
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
|
||||
/// is drawn and `has_reflection` stays 0.
|
||||
pub reflects: bool,
|
||||
@@ -140,22 +214,61 @@ fn eased(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
|
||||
}
|
||||
|
||||
impl NativeFrame {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
/// The frame of a pack with the `[shadow]` settings `shadow`, if any, and the
|
||||
/// block light colours `lights`.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
shadow: Option<&horizon_shader_api::manifest::Shadow>,
|
||||
textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
|
||||
lights: super::block_light::LightTable,
|
||||
pbr: Option<&[wgpu::Texture; 2]>,
|
||||
) -> Self {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Native shader pack frame"),
|
||||
size: size_of::<FrameInputs>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut entries = vec![
|
||||
frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT),
|
||||
texture_entry(1, wgpu::TextureSampleType::Float { filterable: true }),
|
||||
sampler_entry(2),
|
||||
texture_entry(3, wgpu::TextureSampleType::Depth),
|
||||
comparison_entry(4),
|
||||
texture_entry(6, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(7, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(8, wgpu::TextureSampleType::Float { filterable: true }),
|
||||
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
|
||||
pbr_entry(12),
|
||||
pbr_entry(13),
|
||||
];
|
||||
let block_light_entries = [
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 9,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D3,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 10,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
];
|
||||
entries.extend(block_light_entries);
|
||||
entries.extend(pack_texture_entries(textures.len()));
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Native shader pack frame"),
|
||||
entries: &[
|
||||
frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT),
|
||||
texture_entry(1, wgpu::TextureSampleType::Float { filterable: true }),
|
||||
sampler_entry(2),
|
||||
texture_entry(6, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
|
||||
],
|
||||
entries: &entries,
|
||||
});
|
||||
let opaque_depth = depth_copy(device, [1, 1]);
|
||||
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
|
||||
@@ -172,15 +285,50 @@ impl NativeFrame {
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
let shadow_map = super::native_shadow::ShadowMap::new(device, shadow);
|
||||
let block_light = super::block_light::BlockLight::new(device, lights);
|
||||
let array_view = |texture: &wgpu::Texture| {
|
||||
texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let pbr = match pbr {
|
||||
Some(maps) => maps.each_ref().map(array_view),
|
||||
None => {
|
||||
// Two layers: one would make a 2D texture on the OpenGL backend.
|
||||
let blank = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Native shader pack LabPBR (none)"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 2,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
[array_view(&blank), array_view(&blank)]
|
||||
}
|
||||
};
|
||||
let group = Self::group(
|
||||
device,
|
||||
&layout,
|
||||
&buffer,
|
||||
(&blank, &reflection_sampler),
|
||||
&opaque_depth,
|
||||
&opaque_color,
|
||||
&shadow_map,
|
||||
(&opaque_depth, &opaque_color),
|
||||
&textures,
|
||||
&block_light,
|
||||
&pbr,
|
||||
);
|
||||
Self {
|
||||
textures,
|
||||
block_light,
|
||||
pbr,
|
||||
buffer,
|
||||
layout,
|
||||
group,
|
||||
@@ -189,6 +337,8 @@ impl NativeFrame {
|
||||
reflection: blank.clone(),
|
||||
blank,
|
||||
reflection_sampler,
|
||||
shadow_map,
|
||||
shadow_camera: shadow.map(super::native_shadow::camera),
|
||||
reflects: false,
|
||||
last: None,
|
||||
eye: [f32::NAN; 4],
|
||||
@@ -199,43 +349,93 @@ impl NativeFrame {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the shadow camera sees this frame, for the renderer's shadow lists.
|
||||
pub fn shadow_view(&self, frame: &Frame) -> Option<super::pack_post::ShadowView> {
|
||||
let camera = self.shadow_camera.as_ref()?;
|
||||
Some(super::pack_post::ShadowView {
|
||||
cull: super::pack_uniforms::shadow_culling(frame, camera),
|
||||
light: super::pack_uniforms::shadow_light(frame, camera),
|
||||
// The light's box culls: on the ground it reaches further than `distance` along
|
||||
// the sun's azimuth while the sun is low.
|
||||
distance: None,
|
||||
unculled: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn group(
|
||||
device: &wgpu::Device,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
buffer: &wgpu::Buffer,
|
||||
(reflection, sampler): (&wgpu::TextureView, &wgpu::Sampler),
|
||||
opaque_depth: &wgpu::Texture,
|
||||
opaque_color: &wgpu::Texture,
|
||||
shadow: &super::native_shadow::ShadowMap,
|
||||
(opaque_depth, opaque_color): (&wgpu::Texture, &wgpu::Texture),
|
||||
textures: &[(wgpu::TextureView, wgpu::Sampler)],
|
||||
block_light: &super::block_light::BlockLight,
|
||||
pbr: &[wgpu::TextureView; 2],
|
||||
) -> wgpu::BindGroup {
|
||||
let opaque_depth = opaque_depth.create_view(&Default::default());
|
||||
let opaque_color = opaque_color.create_view(&Default::default());
|
||||
let mut entries = vec![
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(reflection),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&shadow.view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&shadow.sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(&shadow.translucent_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: wgpu::BindingResource::TextureView(&shadow.color_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::TextureView(&opaque_depth),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 11,
|
||||
resource: wgpu::BindingResource::TextureView(&opaque_color),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 12,
|
||||
resource: wgpu::BindingResource::TextureView(&pbr[0]),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 13,
|
||||
resource: wgpu::BindingResource::TextureView(&pbr[1]),
|
||||
},
|
||||
];
|
||||
entries.extend([
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 9,
|
||||
resource: wgpu::BindingResource::TextureView(&block_light.view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 10,
|
||||
resource: block_light.grid.as_entire_binding(),
|
||||
},
|
||||
]);
|
||||
entries.extend(pack_texture_group_entries(textures));
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Native shader pack frame"),
|
||||
layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(reflection),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::TextureView(
|
||||
&opaque_depth.create_view(&Default::default()),
|
||||
),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 11,
|
||||
resource: wgpu::BindingResource::TextureView(
|
||||
&opaque_color.create_view(&Default::default()),
|
||||
),
|
||||
},
|
||||
],
|
||||
entries: &entries,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -284,8 +484,11 @@ impl NativeFrame {
|
||||
&self.layout,
|
||||
&self.buffer,
|
||||
(&self.reflection, &self.reflection_sampler),
|
||||
&self.opaque_depth,
|
||||
&self.opaque_color,
|
||||
&self.shadow_map,
|
||||
(&self.opaque_depth, &self.opaque_color),
|
||||
&self.textures,
|
||||
&self.block_light,
|
||||
&self.pbr,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -295,7 +498,13 @@ impl NativeFrame {
|
||||
let now = std::time::Instant::now();
|
||||
let since = self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32());
|
||||
self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now));
|
||||
let mut inputs = frame_inputs(frame, size, seconds, self.frames);
|
||||
let mut inputs = frame_inputs(
|
||||
frame,
|
||||
size,
|
||||
seconds,
|
||||
self.frames,
|
||||
self.shadow_camera.as_ref(),
|
||||
);
|
||||
inputs.wetness = self.wetness.0;
|
||||
if !self.reflects {
|
||||
inputs.has_reflection = 0;
|
||||
@@ -377,6 +586,29 @@ fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::Bi
|
||||
}
|
||||
}
|
||||
|
||||
/// A LabPBR map's array, read by the material entries (`native_material.rs`).
|
||||
fn pbr_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2Array,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn comparison_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sampler_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
@@ -392,22 +624,43 @@ impl NativePost {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
stages: Vec<CheckedStage>,
|
||||
manifest: &horizon_shader_api::Manifest,
|
||||
constants: &[(String, f64)],
|
||||
format: wgpu::TextureFormat,
|
||||
frame: &NativeFrame,
|
||||
) -> Result<Self, String> {
|
||||
let targets = manifest.post_targets();
|
||||
let outputs: Vec<Option<usize>> = manifest
|
||||
.post
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let name = p.output.as_deref()?;
|
||||
targets.iter().position(|t| t.0 == name)
|
||||
})
|
||||
.collect();
|
||||
let plan = plan(&outputs);
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let mut entries = vec![
|
||||
frame_entry(wgpu::ShaderStages::FRAGMENT),
|
||||
texture_entry(1, float),
|
||||
texture_entry(2, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(3, float),
|
||||
sampler_entry(4),
|
||||
sampler_entry(5),
|
||||
texture_entry(6, wgpu::TextureSampleType::Depth),
|
||||
comparison_entry(7),
|
||||
texture_entry(8, float),
|
||||
texture_entry(9, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(10, float),
|
||||
];
|
||||
entries.extend(pack_texture_entries(frame.textures.len()));
|
||||
entries
|
||||
.extend((0..targets.len() as u32).map(|j| {
|
||||
texture_entry(horizon_shader_api::stages::POST_TARGET_BINDING + j, float)
|
||||
}));
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Native shader pack post"),
|
||||
entries: &[
|
||||
frame_entry(wgpu::ShaderStages::FRAGMENT),
|
||||
texture_entry(1, float),
|
||||
texture_entry(2, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(3, float),
|
||||
sampler_entry(4),
|
||||
sampler_entry(5),
|
||||
texture_entry(8, float),
|
||||
],
|
||||
entries: &entries,
|
||||
});
|
||||
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
|
||||
groups[GROUP as usize] = Some(&layout);
|
||||
@@ -446,7 +699,11 @@ impl NativePost {
|
||||
entry_point: Some("horizon_fs_post"),
|
||||
compilation_options: options,
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: if i + 1 == count { format } else { INTERMEDIATE },
|
||||
format: if plan[i].writes == Writes::Screen {
|
||||
format
|
||||
} else {
|
||||
INTERMEDIATE
|
||||
},
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
@@ -470,7 +727,14 @@ impl NativePost {
|
||||
Ok(Self {
|
||||
passes,
|
||||
layout,
|
||||
textures: frame.textures.clone(),
|
||||
frame: frame.buffer.clone(),
|
||||
shadow: (
|
||||
frame.shadow_map.view.clone(),
|
||||
frame.shadow_map.sampler.clone(),
|
||||
frame.shadow_map.translucent_view.clone(),
|
||||
frame.shadow_map.color_view.clone(),
|
||||
),
|
||||
linear: sampler("Native shader pack linear", wgpu::FilterMode::Linear),
|
||||
nearest: sampler("Native shader pack nearest", wgpu::FilterMode::Nearest),
|
||||
targets: None,
|
||||
@@ -478,6 +742,9 @@ impl NativePost {
|
||||
history_blank: history_target(device, "Native shader pack history blank", [1, 1]),
|
||||
groups: None,
|
||||
history_valid: false,
|
||||
scales: targets.iter().map(|t| t.1).collect(),
|
||||
named: None,
|
||||
plan,
|
||||
format,
|
||||
})
|
||||
}
|
||||
@@ -494,7 +761,39 @@ impl NativePost {
|
||||
size: [u32; 2],
|
||||
history_reset: bool,
|
||||
) {
|
||||
if self.passes.len() > 1 && self.targets.as_ref().is_none_or(|t| t.0 != size) {
|
||||
if !self.scales.is_empty() && self.named.as_ref().is_none_or(|n| n.0 != size) {
|
||||
let views = self
|
||||
.scales
|
||||
.iter()
|
||||
.map(|&scale| {
|
||||
let size = scaled(size, scale);
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Native shader pack target"),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: INTERMEDIATE,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
})
|
||||
.collect();
|
||||
self.named = Some((size, views));
|
||||
self.groups = None;
|
||||
}
|
||||
let chain = self
|
||||
.plan
|
||||
.iter()
|
||||
.any(|p| matches!(p.writes, Writes::Chain(_)));
|
||||
if chain && self.targets.as_ref().is_none_or(|t| t.0 != size) {
|
||||
let target = |label| {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
@@ -545,46 +844,79 @@ impl NativePost {
|
||||
.expect("history exists after a post stage has more than one pass")
|
||||
}
|
||||
.create_view(&Default::default());
|
||||
let blank = self.history_blank.create_view(&Default::default());
|
||||
let groups = (0..self.passes.len())
|
||||
.map(|i| {
|
||||
// Pass i reads what pass i - 1 wrote: target (i - 1) % 2, the world for the first.
|
||||
let previous = match (&self.targets, i) {
|
||||
(Some((_, t)), 1..) => &t[(i - 1) % 2],
|
||||
// The chain's picture so far: the world's before its first pass.
|
||||
let previous = match (&self.targets, self.plan[i].reads) {
|
||||
(Some((_, t)), Some(slot)) => &t[slot],
|
||||
_ => world.0,
|
||||
};
|
||||
let mut entries = vec![
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.frame.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(world.0),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(world.1),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(previous),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&self.linear),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::Sampler(&self.nearest),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::TextureView(&self.shadow.0),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::Sampler(&self.shadow.1),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: wgpu::BindingResource::TextureView(&history),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 9,
|
||||
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 10,
|
||||
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
|
||||
},
|
||||
];
|
||||
entries.extend(pack_texture_group_entries(&self.textures));
|
||||
// The pack's targets, a blank one in place of the one this pass writes.
|
||||
if let Some((_, views)) = &self.named {
|
||||
entries.extend(views.iter().enumerate().map(|(j, view)| {
|
||||
let view = if self.plan[i].writes == Writes::Target(j) {
|
||||
&blank
|
||||
} else {
|
||||
view
|
||||
};
|
||||
wgpu::BindGroupEntry {
|
||||
binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
}
|
||||
}));
|
||||
}
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Native shader pack post"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.frame.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(world.0),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(world.1),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(previous),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&self.linear),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::Sampler(&self.nearest),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: wgpu::BindingResource::TextureView(&history),
|
||||
},
|
||||
],
|
||||
entries: &entries,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -611,8 +943,9 @@ impl NativePost {
|
||||
return;
|
||||
};
|
||||
for (i, ((label, pipeline), group)) in self.passes.iter().zip(groups).enumerate() {
|
||||
let view = match &self.targets {
|
||||
Some((_, t)) if i + 1 < self.passes.len() => &t[i % 2],
|
||||
let view = match (self.plan[i].writes, &self.targets, &self.named) {
|
||||
(Writes::Chain(slot), Some((_, t)), _) => &t[slot],
|
||||
(Writes::Target(j), _, Some((_, n))) => &n[j],
|
||||
_ => screen,
|
||||
};
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
@@ -634,14 +967,17 @@ impl NativePost {
|
||||
pass.set_bind_group(GROUP, group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
// The last pass's input becomes next frame's `history`, once that pass has read this
|
||||
// frame's.
|
||||
let count = self.passes.len();
|
||||
if count > 1
|
||||
// The chain's last picture becomes next frame's `history`, once every pass has read
|
||||
// this frame's.
|
||||
let before_last = &self.plan[..self.plan.len() - 1];
|
||||
if let Some(Writes::Chain(slot)) = before_last
|
||||
.iter()
|
||||
.rfind(|p| matches!(p.writes, Writes::Chain(_)))
|
||||
.map(|p| p.writes)
|
||||
&& let (Some(history), Some((_, targets))) = (&self.history, &self.targets)
|
||||
{
|
||||
encoder.copy_texture_to_texture(
|
||||
targets[(count - 2) % 2].texture().as_image_copy(),
|
||||
targets[slot].texture().as_image_copy(),
|
||||
history.as_image_copy(),
|
||||
history.size(),
|
||||
);
|
||||
@@ -676,7 +1012,13 @@ fn wetness(wetness: f32, rain: f32, dt: f32) -> f32 {
|
||||
}
|
||||
|
||||
/// The frame as packs read it (docs/api/shaders-reference.md), from the renderer's `Frame`.
|
||||
pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs {
|
||||
pub fn frame_inputs(
|
||||
frame: &Frame,
|
||||
size: [u32; 2],
|
||||
seconds: f32,
|
||||
index: u32,
|
||||
shadow: Option<&horizon_shaders::Shadow>,
|
||||
) -> FrameInputs {
|
||||
let aspect = size[0] as f32 / size[1].max(1) as f32;
|
||||
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
|
||||
let projection = super::world_projection(frame.fov_degrees, aspect, frame.depth_far)
|
||||
@@ -748,11 +1090,147 @@ pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) ->
|
||||
water_plane: frame.water_plane.unwrap_or(0.0),
|
||||
has_reflection: u32::from(frame.water_plane.is_some()),
|
||||
reflection_resolution: reflection_size(size).map(|s| s as f32),
|
||||
mods: pack.mod_params,
|
||||
light_direction: match shadow {
|
||||
Some(s) => super::pack_uniforms::shadow_light(frame, s),
|
||||
None => Vec3::Y,
|
||||
}
|
||||
.to_array(),
|
||||
has_shadow: u32::from(shadow.is_some()),
|
||||
shadow_view_proj: m(shadow.map_or(Mat4::IDENTITY, |s| {
|
||||
super::pack_uniforms::shadow_culling(frame, s)
|
||||
})),
|
||||
shadow_distance: shadow.map_or(0.0, |s| s.distance),
|
||||
shadow_distortion: shadow.map_or(0.0, |s| {
|
||||
super::native_shadow::distortion(s.distance, s.resolution)
|
||||
}),
|
||||
shadow_resolution: shadow.map_or(0.0, |s| s.resolution as f32),
|
||||
shadow_center_texel: shadow.map_or(0.0, |s| {
|
||||
let k = super::native_shadow::distortion(s.distance, s.resolution);
|
||||
2.0 * s.distance / s.resolution.max(1) as f32 * (1.0 - k)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pack's `[[textures]]` (`check::Image`s), uploaded with samplers as the manifest says.
|
||||
pub fn pack_textures(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
report: &horizon_shader_api::Report,
|
||||
) -> Vec<(wgpu::TextureView, wgpu::Sampler)> {
|
||||
report
|
||||
.textures
|
||||
.iter()
|
||||
.map(|(t, image)| {
|
||||
let texture = wgpu::util::DeviceExt::create_texture_with_data(
|
||||
device,
|
||||
queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some(&t.id),
|
||||
size: wgpu::Extent3d {
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&image.rgba,
|
||||
);
|
||||
let filter = if t.linear {
|
||||
wgpu::FilterMode::Linear
|
||||
} else {
|
||||
wgpu::FilterMode::Nearest
|
||||
};
|
||||
let address = if t.repeat {
|
||||
wgpu::AddressMode::Repeat
|
||||
} else {
|
||||
wgpu::AddressMode::ClampToEdge
|
||||
};
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some(&t.id),
|
||||
address_mode_u: address,
|
||||
address_mode_v: address,
|
||||
mag_filter: filter,
|
||||
min_filter: filter,
|
||||
..Default::default()
|
||||
});
|
||||
(texture.create_view(&Default::default()), sampler)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Layout entries of `count` pack textures and their samplers.
|
||||
pub(super) fn pack_texture_entries(count: usize) -> Vec<wgpu::BindGroupLayoutEntry> {
|
||||
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
|
||||
(0..count as u32)
|
||||
.flat_map(|i| {
|
||||
[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: TEXTURE_BINDING + i,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: TEXTURE_SAMPLER_BINDING + i,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Group entries of the pack's textures, as `pack_texture_entries` lays them out.
|
||||
pub(super) fn pack_texture_group_entries(
|
||||
textures: &[(wgpu::TextureView, wgpu::Sampler)],
|
||||
) -> Vec<wgpu::BindGroupEntry<'_>> {
|
||||
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
|
||||
textures
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, (view, sampler))| {
|
||||
[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: TEXTURE_BINDING + i as u32,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: TEXTURE_SAMPLER_BINDING + i as u32,
|
||||
resource: wgpu::BindingResource::Sampler(sampler),
|
||||
},
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_pass_with_an_output_leaves_the_chain_as_it_was() {
|
||||
use super::{Writes, plan};
|
||||
// Bloom into a target, a pass of the chain, a second target, the picture.
|
||||
let p = plan(&[Some(0), None, Some(1), None, None]);
|
||||
let writes: Vec<_> = p.iter().map(|p| p.writes).collect();
|
||||
assert!(writes[0] == Writes::Target(0));
|
||||
assert!(writes[1] == Writes::Chain(0));
|
||||
assert!(writes[2] == Writes::Target(1));
|
||||
assert!(writes[3] == Writes::Chain(1));
|
||||
assert!(writes[4] == Writes::Screen);
|
||||
let reads: Vec<_> = p.iter().map(|p| p.reads).collect();
|
||||
assert_eq!(reads, [None, None, Some(0), Some(0), Some(1)]);
|
||||
}
|
||||
#[test]
|
||||
fn eased_values_start_at_the_target_and_halve_the_distance() {
|
||||
use super::eased;
|
||||
@@ -814,7 +1292,7 @@ mod tests {
|
||||
.into();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let frame = NativeFrame::new(&device);
|
||||
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
|
||||
queue.write_buffer(
|
||||
&frame.buffer,
|
||||
0,
|
||||
@@ -823,6 +1301,7 @@ mod tests {
|
||||
let mut post = NativePost::new(
|
||||
&device,
|
||||
report.stages,
|
||||
&report.manifest,
|
||||
&[("gain".into(), 1.0)],
|
||||
format,
|
||||
&frame,
|
||||
@@ -933,8 +1412,16 @@ file = \"b.wgsl\"
|
||||
.into();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let frame = NativeFrame::new(&device);
|
||||
let mut post = NativePost::new(&device, report.stages, &[], format, &frame).unwrap();
|
||||
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
|
||||
let mut post = NativePost::new(
|
||||
&device,
|
||||
report.stages,
|
||||
&report.manifest,
|
||||
&[],
|
||||
format,
|
||||
&frame,
|
||||
)
|
||||
.unwrap();
|
||||
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
let color = texture(&device, format, usage).create_view(&Default::default());
|
||||
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
//! The shadow map of Horizon's own shader packs (`[shadow]`, docs/shaders.md): each frame the
|
||||
//! solid terrain and entities the light (the sun by day, the moon by night) sees are drawn into a square depth
|
||||
//! map, which the material and post stages read as `shadow_map`; translucent terrain goes over a
|
||||
//! copy of it, `shadow_map_translucent`, with its colour in `shadow_color`. The shadow camera is the one
|
||||
//! `shaders/` packs use (`pack_uniforms::shadow_camera`), so the renderer's shadow visibility and
|
||||
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
|
||||
//! prelude's `shadow_position` undoes.
|
||||
|
||||
use horizon_shader_api::{Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
|
||||
use horizon_shaders::Shadow;
|
||||
|
||||
use super::native_material::TerrainShape;
|
||||
use super::wgsl_prefix::{TERRAIN, prefixed, section};
|
||||
|
||||
const PREFIX: &str = "horizon_terrain_";
|
||||
|
||||
/// The shadow pass's entries: terrain's vertices through the shadow camera, distorted, and the
|
||||
/// cutout test (leaves' and plants' holes let the light through).
|
||||
const ENTRIES: &str = "
|
||||
struct HorizonShadowOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) @interpolate(flat) layer: u32,
|
||||
@location(2) tint: vec3<f32>,
|
||||
@location(3) @interpolate(flat) fluid: u32,
|
||||
}
|
||||
// `color` is the vertex colour: the tint, and the fluid flag in its alpha's high nibble
|
||||
// (mesher.rs's `fluid_face`).
|
||||
fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>)
|
||||
-> HorizonShadowOut {
|
||||
let clip = frame.shadow_view_proj * vec4(pos, 1.0);
|
||||
let xy = shadow_distort(clip.xy);
|
||||
var out: HorizonShadowOut;
|
||||
out.clip = vec4(xy, clamp(clip.z, 0.0, 1.0), 1.0);
|
||||
out.uv = uv;
|
||||
out.layer = layer;
|
||||
out.tint = color.rgb;
|
||||
let high = u32(round(color.a * 255.0)) >> 4u;
|
||||
out.fluid = u32(high >= 8u && (high & 7u) < 6u);
|
||||
return out;
|
||||
}
|
||||
@vertex fn horizon_shadow_vs(
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>,
|
||||
@location(5) origin: vec4<f32>) -> HorizonShadowOut {
|
||||
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
|
||||
}
|
||||
@vertex fn horizon_shadow_vs_flat(
|
||||
@builtin(vertex_index) index: u32,
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>, @location(2) layer: u32,
|
||||
@location(3) color: vec4<f32>
|
||||
) -> HorizonShadowOut {
|
||||
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[index >> 8u]];
|
||||
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
|
||||
}
|
||||
@fragment fn horizon_shadow_fs(in: HorizonShadowOut) {
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
let alpha = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu).a;
|
||||
if alpha < 0.5 && (in.layer & 0x8000u) == 0u && !horizon_shadow_solid(in.layer & 0x7fffu) { discard; }
|
||||
}
|
||||
// Translucent terrain: its colour where it is the nearest thing the light passes through;
|
||||
// alpha 1 marks a fluid, other surfaces keep their alpha below it.
|
||||
@fragment fn horizon_shadow_fs_translucent(in: HorizonShadowOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
|
||||
if color.a < 0.01 { discard; }
|
||||
return vec4(color.rgb * in.tint, select(min(color.a, 0.99), 1.0, in.fluid != 0u));
|
||||
}
|
||||
";
|
||||
|
||||
/// The entity draw of the shadow pass: the renderer's opaque entity quads (`EntityBatch`'s first
|
||||
/// material) through the shadow camera, with their texture's cutout. The sky's bodies, clouds,
|
||||
/// beacon beams and breaking cracks share that buffer but are unlit (entity.wgsl's overlays) and
|
||||
/// cast nothing. A quad's layer holds the overlay in its low 16 bits.
|
||||
const ENTITY_ENTRIES: &str = "
|
||||
@group(0) @binding(1) var horizon_atlas: texture_2d<f32>;
|
||||
@group(0) @binding(2) var horizon_atlas_sampler: sampler;
|
||||
struct HorizonEntityShadowOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) alpha: f32,
|
||||
}
|
||||
@vertex fn horizon_entity_shadow_vs(@location(0) position: vec3<f32>,
|
||||
@location(1) uv: vec2<f32>, @location(2) layer: u32, @location(3) color: vec4<f32>)
|
||||
-> HorizonEntityShadowOut {
|
||||
let clip = frame.shadow_view_proj * vec4(position, 1.0);
|
||||
let xy = shadow_distort(clip.xy);
|
||||
let overlay = layer & 0xffffu;
|
||||
// As entity.wgsl's `unlit`.
|
||||
let unlit = overlay == 2u || overlay == 4u || overlay == 6u
|
||||
|| (overlay >= 9u && overlay <= 12u);
|
||||
var out: HorizonEntityShadowOut;
|
||||
// Unlit geometry lands outside the clip volume.
|
||||
out.clip = select(vec4(xy, clamp(clip.z, 0.0, 1.0), 1.0), vec4(2.0, 2.0, 2.0, 1.0), unlit);
|
||||
out.uv = uv;
|
||||
out.alpha = color.a;
|
||||
return out;
|
||||
}
|
||||
@fragment fn horizon_entity_shadow_fs(in: HorizonEntityShadowOut) {
|
||||
let alpha = textureSampleLevel(horizon_atlas, horizon_atlas_sampler, in.uv, 0.0).a;
|
||||
if alpha * in.alpha < 0.5 { discard; }
|
||||
}
|
||||
";
|
||||
|
||||
/// `shadow_color`'s format.
|
||||
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
|
||||
/// The shader pack's shadow camera for `settings`: orthographic over `distance` each side,
|
||||
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
|
||||
/// centre, so a centre that moves with the camera, even by a texel, resamples every shadow but
|
||||
/// the nearest each frame and they shimmer; a still one maps the world to the same texels until
|
||||
/// the next cell. Its depth starts 256 blocks further toward the light than `shaders/` packs'
|
||||
/// (the projection's near plane is at `-near`), so the ground over a deep cave still casts its
|
||||
/// shadow, and ends past the build height and the slant across the map below the camera.
|
||||
pub fn camera(settings: &Settings) -> Shadow {
|
||||
Shadow {
|
||||
resolution: settings.resolution,
|
||||
distance: settings.distance,
|
||||
render_mul: 1.0,
|
||||
interval: SNAP,
|
||||
euclidean_snap: true,
|
||||
near: 256.0,
|
||||
far: super::pack_uniforms::SHADOW_EYE + 2.0 * settings.distance + 384.0,
|
||||
..Shadow::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `settings` with the resolution `device` can make.
|
||||
pub fn fitted(device: &wgpu::Device, settings: &Settings) -> Settings {
|
||||
Settings {
|
||||
resolution: settings
|
||||
.resolution
|
||||
.min(device.limits().max_texture_dimension_2d),
|
||||
..settings.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The cell the shadow camera keeps to, in blocks along the world's axes: the player stays
|
||||
/// within 7 blocks of the centre (half the cell on each axis), where the map is still over
|
||||
/// three times as fine as at its edge.
|
||||
const SNAP: f32 = 8.0;
|
||||
|
||||
/// The ground one shadow texel covers right under the camera, in blocks: a 32nd, half a texture
|
||||
/// pixel, so shadow edges near the player stay as sharp as the blocks casting them.
|
||||
const CENTRE_TEXEL: f32 = 1.0 / 32.0;
|
||||
|
||||
/// How strongly the map is distorted toward its centre (`Frame::shadow_distortion`).
|
||||
/// `xy / (|xy| k + 1 - k)` magnifies the centre by 1 / (1 - k); an undistorted texel covers
|
||||
/// 2 distance / resolution blocks, so k is what brings that down to `CENTRE_TEXEL`. None when
|
||||
/// the map is already that fine; at most 0.95, past which the edge of the map gets too few texels.
|
||||
pub fn distortion(distance: f32, resolution: u32) -> f32 {
|
||||
let texel = 2.0 * distance / resolution.max(1) as f32;
|
||||
(1.0 - CENTRE_TEXEL / texel).clamp(0.0, 0.95)
|
||||
}
|
||||
|
||||
/// The shadow map: a square depth map of the pack's resolution, or a 1×1 one (cleared to "far",
|
||||
/// so everything is lit) for a pack without `[shadow]`; the same with translucent terrain drawn
|
||||
/// in, and that terrain's colour.
|
||||
pub struct ShadowMap {
|
||||
depth: wgpu::Texture,
|
||||
pub view: wgpu::TextureView,
|
||||
translucent: wgpu::Texture,
|
||||
pub translucent_view: wgpu::TextureView,
|
||||
pub color_view: wgpu::TextureView,
|
||||
pub sampler: wgpu::Sampler,
|
||||
/// The 1×1 map still holds the zeroes it was made with.
|
||||
blank_uncleared: bool,
|
||||
}
|
||||
|
||||
impl ShadowMap {
|
||||
pub fn new(device: &wgpu::Device, settings: Option<&Settings>) -> Self {
|
||||
let size = settings.map_or(1, |s| s.resolution);
|
||||
let texture = |label, format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let depth = texture(
|
||||
"Native shader pack shadow map",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let translucent = texture(
|
||||
"Native shader pack translucent shadow map",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::COPY_DST,
|
||||
);
|
||||
let color = texture(
|
||||
"Native shader pack shadow colour",
|
||||
SHADOW_COLOR,
|
||||
wgpu::TextureUsages::empty(),
|
||||
);
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Native shader pack shadow comparison"),
|
||||
compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
Self {
|
||||
view: depth.create_view(&Default::default()),
|
||||
depth,
|
||||
translucent_view: translucent.create_view(&Default::default()),
|
||||
translucent,
|
||||
color_view: color.create_view(&Default::default()),
|
||||
sampler,
|
||||
blank_uncleared: settings.is_none(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A depth pass into the map that clears it to "far".
|
||||
pub fn begin<'e>(
|
||||
&self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
self.depth_pass(encoder, label, wgpu::LoadOp::Clear(1.0))
|
||||
}
|
||||
|
||||
/// A depth pass that adds to the map. Entities draw here, after the translucent map was
|
||||
/// copied: where one casts, the solid map is the darker and decides.
|
||||
pub fn begin_entities<'e>(
|
||||
&self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
self.depth_pass(encoder, label, wgpu::LoadOp::Load)
|
||||
}
|
||||
|
||||
fn depth_pass<'e>(
|
||||
&self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
load: wgpu::LoadOp<f32>,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// The translucent pass: the solid terrain's map copied into the translucent one, which it
|
||||
/// goes on drawing into, and the colour cleared to white (nothing to pass through).
|
||||
pub fn begin_translucent<'e>(
|
||||
&self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
encoder.copy_texture_to_texture(
|
||||
self.depth.as_image_copy(),
|
||||
self.translucent.as_image_copy(),
|
||||
self.depth.size(),
|
||||
);
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &self.color_view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.translucent_view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Clears the 1×1 map of a pack without `[shadow]` once.
|
||||
pub fn clear_blank(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
if std::mem::take(&mut self.blank_uncleared) {
|
||||
drop(self.begin(encoder, "Native shader pack shadow map clear"));
|
||||
drop(self.begin_translucent(encoder, "Native shader pack shadow colour clear"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pipelines that draw terrain into the shadow map, for the renderer's instanced draws and,
|
||||
/// where they run, its flat ones, and the frame they read (group 3, the uniform alone: the
|
||||
/// stages' own group 3 also holds the map being drawn).
|
||||
pub struct NativeShadow {
|
||||
pub solid: wgpu::RenderPipeline,
|
||||
pub flat: Option<wgpu::RenderPipeline>,
|
||||
/// The translucent terrain's draws into the translucent map and the colour, instanced and
|
||||
/// flat.
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
pub flat_translucent: Option<wgpu::RenderPipeline>,
|
||||
/// The entities' draw, with the entities' group 0 (`ENTITY_ENTRIES`).
|
||||
pub entity: wgpu::RenderPipeline,
|
||||
pub group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
/// The entity draw's shader.
|
||||
fn entity_source() -> String {
|
||||
format!(
|
||||
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{ENTITY_ENTRIES}",
|
||||
FrameInputs::WGSL.wgsl(),
|
||||
horizon_shader_api::stages::SHADOW_HELPERS,
|
||||
)
|
||||
}
|
||||
|
||||
/// The shadow pass's entity pipeline: the entities' vertices and group 0, the frame at group 3.
|
||||
fn entity_pipeline(
|
||||
device: &wgpu::Device,
|
||||
entities: &wgpu::BindGroupLayout,
|
||||
frame_layout: &wgpu::BindGroupLayout,
|
||||
) -> wgpu::RenderPipeline {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Native shader pack entity shadow"),
|
||||
source: wgpu::ShaderSource::Wgsl(entity_source().into()),
|
||||
});
|
||||
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
|
||||
groups[0] = Some(entities);
|
||||
groups[GROUP as usize] = Some(frame_layout);
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Native shader pack entity shadow"),
|
||||
bind_group_layouts: &groups,
|
||||
..Default::default()
|
||||
});
|
||||
let attributes =
|
||||
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Uint32, 3 => Unorm8x4];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Native shader pack entity shadow"),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("horizon_entity_shadow_vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
// Both sides, as the terrain: thin parts (ears, item quads) cast from either.
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 0,
|
||||
slope_scale: 1.5,
|
||||
clamp: 0.0,
|
||||
},
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("horizon_entity_shadow_fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `horizon_shadow_solid(layer)`: whether the sprite layer's id, out of `materials`
|
||||
/// (`(layer, id)`), is one of the pack's `solid` ones.
|
||||
fn shadow_solid(materials: &[(u32, u32)], solid: &[u32]) -> String {
|
||||
let layers: Vec<String> = materials
|
||||
.iter()
|
||||
.filter(|(_, id)| solid.contains(id))
|
||||
.map(|(layer, _)| format!("{layer}u"))
|
||||
.collect();
|
||||
let cases = if layers.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" case {}: {{ return true; }}\n", layers.join(", "))
|
||||
};
|
||||
format!(
|
||||
"fn horizon_shadow_solid(layer: u32) -> bool {{\n switch layer {{\n{cases} \
|
||||
default: {{ return false; }}\n }}\n}}\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// The shadow pass's shader: the frame, Horizon's terrain, and the entries, with the layers
|
||||
/// `solid` of `shadow_solid`.
|
||||
fn shader_source(solid: &str) -> String {
|
||||
format!(
|
||||
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}\
|
||||
// Horizon's terrain (terrain.wgsl)\n{}{solid}{ENTRIES}",
|
||||
FrameInputs::WGSL.wgsl(),
|
||||
horizon_shader_api::stages::SHADOW_HELPERS,
|
||||
prefixed(
|
||||
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
|
||||
PREFIX
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
impl NativeShadow {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
shape: &TerrainShape<'_>,
|
||||
frame: &super::native_post::NativeFrame,
|
||||
solid_materials: &[u32],
|
||||
) -> Result<Self, String> {
|
||||
let text = shader_source(&shadow_solid(shape.sprite_materials, solid_materials));
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
|
||||
let constants = [
|
||||
("horizon_terrain_SPRITE_ARRAYS", shape.sprites[0]),
|
||||
("horizon_terrain_SPRITE_SPLIT", shape.sprites[1]),
|
||||
];
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Native shader pack shadow"),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
});
|
||||
let frame_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Native shader pack shadow frame"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: wgpu::BufferSize::new(size_of::<FrameInputs>() as u64),
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Native shader pack shadow frame"),
|
||||
layout: &frame_layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: frame.buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let layout = |flat: Option<&wgpu::BindGroupLayout>| {
|
||||
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] =
|
||||
Default::default();
|
||||
groups[0] = Some(shape.globals);
|
||||
groups[1] = flat;
|
||||
groups[GROUP as usize] = Some(&frame_layout);
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Native shader pack shadow"),
|
||||
bind_group_layouts: &groups,
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
|
||||
let vertices = wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::TerrainVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &super::TERRAIN_ATTRIBUTES,
|
||||
};
|
||||
let instances = wgpu::VertexBufferLayout {
|
||||
array_stride: 16,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &instance_attributes,
|
||||
};
|
||||
let color_target = [Some(wgpu::ColorTargetState {
|
||||
format: SHADOW_COLOR,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})];
|
||||
let pipeline = |layout: &wgpu::PipelineLayout,
|
||||
vertex: &str,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
translucent: bool| {
|
||||
let options = wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
};
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Native shader pack shadow"),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some(vertex),
|
||||
compilation_options: options.clone(),
|
||||
buffers,
|
||||
},
|
||||
// Both sides: the quads that face the light are drawn (`facing_runs`), and
|
||||
// plants' crossed quads cast from either side.
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 0,
|
||||
slope_scale: 1.5,
|
||||
clamp: 0.0,
|
||||
},
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some(if translucent {
|
||||
"horizon_shadow_fs_translucent"
|
||||
} else {
|
||||
"horizon_shadow_fs"
|
||||
}),
|
||||
compilation_options: options,
|
||||
targets: if translucent { &color_target } else { &[] },
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let instanced = layout(None);
|
||||
let solid = pipeline(
|
||||
&instanced,
|
||||
"horizon_shadow_vs",
|
||||
&[Some(vertices.clone()), Some(instances.clone())],
|
||||
false,
|
||||
);
|
||||
let translucent = pipeline(
|
||||
&instanced,
|
||||
"horizon_shadow_vs",
|
||||
&[Some(vertices.clone()), Some(instances)],
|
||||
true,
|
||||
);
|
||||
let flat_layout = shape.flat.map(|group| layout(Some(group)));
|
||||
let flat_pipeline = |translucent| {
|
||||
flat_layout.as_ref().map(|layout| {
|
||||
pipeline(
|
||||
layout,
|
||||
"horizon_shadow_vs_flat",
|
||||
&[Some(vertices.clone())],
|
||||
translucent,
|
||||
)
|
||||
})
|
||||
};
|
||||
let flat = flat_pipeline(false);
|
||||
let flat_translucent = flat_pipeline(true);
|
||||
let entity = entity_pipeline(device, shape.entities, &frame_layout);
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
return Err(format!("shadow pass: {e}"));
|
||||
}
|
||||
Ok(Self {
|
||||
solid,
|
||||
flat,
|
||||
translucent,
|
||||
flat_translucent,
|
||||
entity,
|
||||
group,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_shadow_shader_validates() {
|
||||
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
|
||||
let text = shader_source(&shadow_solid(materials, &[7]));
|
||||
let module = naga::front::wgsl::parse_str(&text).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_layers_of_solid_ids_lose_their_cutouts() {
|
||||
let materials = [(3, 7), (4, 8), (9, 7)];
|
||||
assert!(shadow_solid(&materials, &[7]).contains("case 3u, 9u: { return true; }"));
|
||||
assert!(!shadow_solid(&materials, &[]).contains("case"));
|
||||
assert!(!shadow_solid(&materials, &[5]).contains("case"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_entity_shader_validates() {
|
||||
let text = entity_source();
|
||||
let module = naga::front::wgsl::parse_str(&text).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_camera_stands_on_is_inside_the_shadow_cameras_depth() {
|
||||
let settings = Settings {
|
||||
resolution: 2048,
|
||||
distance: 128.0,
|
||||
solid_materials: Vec::new(),
|
||||
};
|
||||
let shadow = camera(&settings);
|
||||
let (view, projection) = super::super::pack_uniforms::shadow_camera(
|
||||
glam::Vec3::new(0.3, 0.9, 0.0).normalize(),
|
||||
glam::DVec3::new(-2064.5, 246.0, -4304.5),
|
||||
&shadow,
|
||||
);
|
||||
// Up to the build height's span below a player flying high, as well as above them.
|
||||
for y in [64.0, 0.0, -64.0, -200.0, -384.0] {
|
||||
let clip = projection * view * glam::Vec4::new(0.0, y, 0.0, 1.0);
|
||||
assert!(clip.is_finite(), "{clip}");
|
||||
assert!((-1.0..=1.0).contains(&clip.z), "{clip}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_centre_stays_near_the_player_on_both_sides_of_zero() {
|
||||
let shadow = camera(&Settings {
|
||||
resolution: 2048,
|
||||
distance: 128.0,
|
||||
solid_materials: Vec::new(),
|
||||
});
|
||||
let light = glam::Vec3::Y;
|
||||
for x in [5.5, -5.5, -2064.5, 2064.5] {
|
||||
let camera = glam::DVec3::new(x, 64.0, x);
|
||||
let (view, _) = super::super::pack_uniforms::shadow_camera(light, camera, &shadow);
|
||||
let centre = view.inverse().w_axis.truncate();
|
||||
assert!(centre.x.abs() <= SNAP / 2.0, "{x}: {centre}");
|
||||
assert!(centre.z.abs() <= SNAP / 2.0, "{x}: {centre}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_distortion_brings_the_centre_texel_to_a_32nd_of_a_block() {
|
||||
// 2048 texels over 256 blocks: an 8th of a block each, magnified 4 times.
|
||||
assert!((distortion(128.0, 2048) - 0.75).abs() < 1e-6);
|
||||
// Already that fine: no distortion.
|
||||
assert_eq!(distortion(32.0, 2048), 0.0);
|
||||
assert_eq!(distortion(10_000.0, 1024), 0.95);
|
||||
}
|
||||
}
|
||||
@@ -252,7 +252,7 @@ mod tests {
|
||||
resource: globals_buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let frame = NativeFrame::new(&device);
|
||||
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
|
||||
queue.write_buffer(
|
||||
&frame.buffer,
|
||||
0,
|
||||
|
||||
@@ -35,6 +35,8 @@ pub struct PackInputs {
|
||||
/// `heldItemId`, `heldItemId2` (item.properties, 5.3) and `heldBlockLightValue(2)`.
|
||||
pub held_items: [i32; 2],
|
||||
pub held_light: [i32; 2],
|
||||
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
|
||||
pub mod_params: [[f32; 4]; 8],
|
||||
}
|
||||
|
||||
/// One uniform's value: up to 16 floats or 3 integers.
|
||||
@@ -245,7 +247,7 @@ pub fn shadow_culling(frame: &Frame, shadow: &Shadow) -> Mat4 {
|
||||
|
||||
/// How far toward the light the shadow projection starts, in blocks: part of the projection,
|
||||
/// not of `shadowModelView` (Iris's matrices, measured).
|
||||
const SHADOW_EYE: f32 = 100.0;
|
||||
pub const SHADOW_EYE: f32 = 100.0;
|
||||
|
||||
/// The shadow camera as Iris places it (7.1; measured from the outside): its view (player
|
||||
/// space to shadow view space) and GL projection. The view undoes `sunPathRotation`, turns the
|
||||
@@ -264,7 +266,12 @@ pub fn shadow_camera(light: Vec3, camera: DVec3, shadow: &Shadow) -> (Mat4, Mat4
|
||||
* DMat4::from_rotation_x(tilt);
|
||||
let step = f64::from(shadow.interval);
|
||||
let origin = if step > 0.0 {
|
||||
DVec3::splat(step / 2.0) - camera % step
|
||||
let remainder = if shadow.euclidean_snap {
|
||||
camera.rem_euclid(DVec3::splat(step))
|
||||
} else {
|
||||
camera % step
|
||||
};
|
||||
DVec3::splat(step / 2.0) - remainder
|
||||
} else {
|
||||
DVec3::ZERO
|
||||
};
|
||||
|
||||
@@ -3,6 +3,10 @@ use crate::weather::{Time, Vertex};
|
||||
|
||||
pub(super) struct Weather {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// The layouts of groups 0 (the entities') and 1 (the fall clock), for a native pack's
|
||||
/// weather pipeline.
|
||||
pub entity_layout: wgpu::BindGroupLayout,
|
||||
pub time_layout: wgpu::BindGroupLayout,
|
||||
group: wgpu::BindGroup,
|
||||
uniform: wgpu::Buffer,
|
||||
vertices: wgpu::Buffer,
|
||||
@@ -84,6 +88,8 @@ impl Weather {
|
||||
});
|
||||
Self {
|
||||
pipeline,
|
||||
entity_layout: entities.clone(),
|
||||
time_layout: bindings,
|
||||
group,
|
||||
uniform,
|
||||
vertices: Self::buffer(device, 4),
|
||||
@@ -138,11 +144,24 @@ impl Weather {
|
||||
pass.set_vertex_buffer(0, self.vertices.slice(..));
|
||||
pass.draw_indexed(0..self.count / 4 * 6, 0, 0..1);
|
||||
}
|
||||
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, entities: &wgpu::BindGroup) {
|
||||
/// Draws the streaks, through a native pack's weather stage when `native` is given (its
|
||||
/// pipeline and the pack's frame group).
|
||||
pub fn draw(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
entities: &wgpu::BindGroup,
|
||||
native: Option<(&wgpu::RenderPipeline, &wgpu::BindGroup)>,
|
||||
) {
|
||||
if self.count == 0 {
|
||||
return;
|
||||
}
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
match native {
|
||||
Some((pipeline, frame)) => {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(horizon_shader_api::stages::GROUP, frame, &[]);
|
||||
}
|
||||
None => pass.set_pipeline(&self.pipeline),
|
||||
}
|
||||
pass.set_bind_group(0, entities, &[]);
|
||||
pass.set_bind_group(1, &self.group, &[]);
|
||||
pass.set_vertex_buffer(0, self.vertices.slice(..));
|
||||
@@ -163,8 +182,156 @@ fn depth_stencil() -> wgpu::DepthStencilState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The entries around a native pack's `weather`: Horizon's weather vertex, then the texture
|
||||
/// as `fs_weather` reads it, handed to the pack with the light and the position.
|
||||
const NATIVE_ENTRIES: &str = "
|
||||
struct HorizonWeatherOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
@location(2) @interpolate(flat) rect: vec4<f32>,
|
||||
@location(3) light: vec2<f32>,
|
||||
@location(4) position: vec3<f32>,
|
||||
@location(5) @interpolate(flat) snow: u32,
|
||||
}
|
||||
@vertex fn horizon_weather_vs(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) rect: vec4<f32>, @location(3) animation: vec4<f32>,
|
||||
@location(4) bases: vec4<u32>, @location(5) color: vec4<f32>,
|
||||
@location(6) light: vec4<u32>, @location(7) snow: u32) -> HorizonWeatherOut {
|
||||
let phase = horizon_entity_weather_phase(animation, bases, snow,
|
||||
horizon_entity_weather_time.values);
|
||||
let v = horizon_entity_entity_vertex(position, uv + phase, 0u, color, light, vec4(0.0));
|
||||
var out: HorizonWeatherOut;
|
||||
out.clip = v.clip;
|
||||
out.uv = v.uv;
|
||||
out.color = v.color;
|
||||
out.rect = rect;
|
||||
// entity_vertex's light: block in y, sky in x, 0..240.
|
||||
out.light = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
|
||||
out.position = position;
|
||||
out.snow = snow;
|
||||
return out;
|
||||
}
|
||||
@fragment fn horizon_weather_fs(in: HorizonWeatherOut) -> @location(0) vec4<f32> {
|
||||
let texel = horizon_entity_weather_texel(in.uv, in.rect,
|
||||
textureDimensions(horizon_entity_atlas));
|
||||
let color = textureLoad(horizon_entity_atlas, texel, 0) * in.color;
|
||||
if color.a < 0.1 { discard; }
|
||||
return weather(WeatherInput(color, in.light, in.position, in.snow != 0u));
|
||||
}
|
||||
";
|
||||
|
||||
/// The shader of a native pack's weather stage `source`.
|
||||
fn native_source(source: &str) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's weather (entity.wgsl)\n{}{NATIVE_ENTRIES}",
|
||||
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_")
|
||||
)
|
||||
}
|
||||
|
||||
/// A native pack's weather pipeline: its `stage` (with the option values `constants`), then
|
||||
/// entity.wgsl with every name prefixed, then the entries above. Group 0 is the entities',
|
||||
/// 1 the fall clock (`time_layout`), 3 the pack's frame.
|
||||
pub(super) fn native_pipeline(
|
||||
device: &wgpu::Device,
|
||||
format: wgpu::TextureFormat,
|
||||
entities: &wgpu::BindGroupLayout,
|
||||
time_layout: &wgpu::BindGroupLayout,
|
||||
frame: &super::native_post::NativeFrame,
|
||||
stage: &horizon_shader_api::check::CheckedStage,
|
||||
constants: &[(String, f64)],
|
||||
) -> Result<wgpu::RenderPipeline, String> {
|
||||
use horizon_shader_api::stages::GROUP;
|
||||
let text = native_source(&stage.source);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
|
||||
let present = super::native_post::present_constants(&module, constants);
|
||||
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
|
||||
groups[0] = Some(entities);
|
||||
groups[1] = Some(time_layout);
|
||||
groups[GROUP as usize] = Some(&frame.layout);
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Native shader pack weather"),
|
||||
bind_group_layouts: &groups,
|
||||
..Default::default()
|
||||
});
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&stage.label),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
});
|
||||
let options = wgpu::PipelineCompilationOptions {
|
||||
constants: &present,
|
||||
..Default::default()
|
||||
};
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Float32x4,
|
||||
3=>Float32x4,4=>Uint32x4,5=>Unorm8x4,6=>Uint8x4,7=>Uint32];
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Native shader pack weather"),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("horizon_weather_vs"),
|
||||
compilation_options: options.clone(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(depth_stencil()),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("horizon_weather_fs"),
|
||||
compilation_options: options,
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: wgpu::BlendState::ALPHA_BLENDING.color,
|
||||
// The frame's alpha stays as the surfaces left it (entities mark it 0).
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
return Err(format!("{}: {e}", stage.file));
|
||||
}
|
||||
Ok(pipeline)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::native_source;
|
||||
|
||||
#[test]
|
||||
fn a_native_weather_stage_validates() {
|
||||
use horizon_shader_api::stages::StageKind;
|
||||
let source = StageKind::Weather.prelude()
|
||||
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
|
||||
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
|
||||
let text = native_source(&source);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
|
||||
}
|
||||
use crate::weather::{Parameters, Time};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
|
||||
@@ -313,6 +313,8 @@ pub fn reduced_far_detail_suggested() -> bool {
|
||||
|
||||
/// The name the options screen waits on to bind `Graphics::hdr_key`.
|
||||
pub const HDR_KEY: &str = "horizon.hdrSettings";
|
||||
/// The Screenshot Quality choices, times the game's resolution.
|
||||
pub const SCREENSHOT_QUALITIES: [u32; 4] = [1, 2, 4, 8];
|
||||
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
|
||||
/// opens its surface; the settings open HDR's screen only then.
|
||||
@@ -362,10 +364,16 @@ pub struct Graphics {
|
||||
pub chunk_updates: u32,
|
||||
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
|
||||
pub upload_budget_kib: u32,
|
||||
/// The block textures' video memory (their top mip level), in MiB; 0 leaves it to
|
||||
/// Horizon (`sprites::DEFAULT_TEXTURE_BUDGET`). Applies when resources next load.
|
||||
pub texture_budget_mib: u32,
|
||||
pub sky: bool,
|
||||
pub fog: bool,
|
||||
pub weather: bool,
|
||||
pub animated_textures: bool,
|
||||
/// Dithers the small mip levels of cutouts from 64 texels up, so a high-resolution pack's
|
||||
/// fine plants keep their coverage in the distance; off is vanilla's look.
|
||||
pub dither_cutout_mips: bool,
|
||||
pub present_mode: PresentMode,
|
||||
pub pacing: Pacing,
|
||||
/// Chunk worker threads; 0 uses every core but one. Applies at the next start.
|
||||
@@ -379,6 +387,8 @@ pub struct Graphics {
|
||||
pub taa: bool,
|
||||
/// TAA upscaling; anything but `Native` comes with `taa` on.
|
||||
pub upscaling: Upscaling,
|
||||
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
|
||||
pub screenshot_quality: u32,
|
||||
/// HDR output (HDR10, or scRGB), where the display path offers it.
|
||||
pub hdr: bool,
|
||||
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
|
||||
@@ -420,10 +430,12 @@ impl Default for Graphics {
|
||||
entity_culling: false,
|
||||
chunk_updates: 0,
|
||||
upload_budget_kib: 0,
|
||||
texture_budget_mib: 0,
|
||||
sky: true,
|
||||
fog: true,
|
||||
weather: true,
|
||||
animated_textures: true,
|
||||
dither_cutout_mips: false,
|
||||
present_mode: PresentMode::Auto,
|
||||
pacing: Pacing::Efficient,
|
||||
workers: 0,
|
||||
@@ -432,6 +444,7 @@ impl Default for Graphics {
|
||||
fxaa: false,
|
||||
taa: false,
|
||||
upscaling: Upscaling::Native,
|
||||
screenshot_quality: 1,
|
||||
hdr: false,
|
||||
hdr_paper_white: 0,
|
||||
hdr_peak: 0,
|
||||
@@ -485,10 +498,14 @@ impl Graphics {
|
||||
"entityCulling" => g.entity_culling = flag(g.entity_culling),
|
||||
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
|
||||
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
|
||||
"textureBudgetMiB" => {
|
||||
g.texture_budget_mib = number(g.texture_budget_mib).min(16384);
|
||||
}
|
||||
"sky" => g.sky = flag(g.sky),
|
||||
"fog" => g.fog = flag(g.fog),
|
||||
"weather" => g.weather = flag(g.weather),
|
||||
"animatedTextures" => g.animated_textures = flag(g.animated_textures),
|
||||
"ditherCutoutMips" => g.dither_cutout_mips = flag(g.dither_cutout_mips),
|
||||
"presentMode" => g.present_mode = PresentMode::parse(value),
|
||||
"framePacing" => {
|
||||
g.pacing = if value == "precise" {
|
||||
@@ -502,6 +519,11 @@ impl Graphics {
|
||||
"fxaa" => g.fxaa = flag(g.fxaa),
|
||||
"taa" => g.taa = flag(g.taa),
|
||||
"upscaling" => g.upscaling = Upscaling::parse(value),
|
||||
"screenshotQuality" => {
|
||||
g.screenshot_quality = Some(number(0))
|
||||
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
|
||||
.unwrap_or(1)
|
||||
}
|
||||
"hdr" => g.hdr = flag(g.hdr),
|
||||
"hdrPaperWhite" => {
|
||||
g.hdr_paper_white = match number(g.hdr_paper_white) {
|
||||
@@ -538,7 +560,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -546,10 +568,12 @@ impl Graphics {
|
||||
bool_text(self.entity_culling),
|
||||
self.chunk_updates,
|
||||
self.upload_budget_kib,
|
||||
self.texture_budget_mib,
|
||||
bool_text(self.sky),
|
||||
bool_text(self.fog),
|
||||
bool_text(self.weather),
|
||||
bool_text(self.animated_textures),
|
||||
bool_text(self.dither_cutout_mips),
|
||||
self.present_mode.name(),
|
||||
match self.pacing {
|
||||
Pacing::Efficient => "efficient",
|
||||
@@ -565,6 +589,7 @@ impl Graphics {
|
||||
bool_text(self.fxaa),
|
||||
bool_text(self.taa),
|
||||
self.upscaling.name(),
|
||||
self.screenshot_quality,
|
||||
bool_text(self.hdr),
|
||||
self.hdr_paper_white,
|
||||
self.hdr_peak,
|
||||
@@ -698,10 +723,12 @@ mod tests {
|
||||
entity_culling: true,
|
||||
chunk_updates: 12,
|
||||
upload_budget_kib: 4096,
|
||||
texture_budget_mib: 1536,
|
||||
sky: false,
|
||||
fog: false,
|
||||
weather: false,
|
||||
animated_textures: false,
|
||||
dither_cutout_mips: true,
|
||||
present_mode: PresentMode::Mailbox,
|
||||
pacing: Pacing::Precise,
|
||||
workers: 6,
|
||||
@@ -710,6 +737,7 @@ mod tests {
|
||||
fxaa: true,
|
||||
taa: true,
|
||||
upscaling: Upscaling::Balanced,
|
||||
screenshot_quality: 4,
|
||||
hdr: true,
|
||||
hdr_paper_white: 240,
|
||||
hdr_peak: 1400,
|
||||
@@ -728,6 +756,19 @@ mod tests {
|
||||
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 10);
|
||||
// Only the button's choices.
|
||||
assert_eq!(
|
||||
Graphics::parse("screenshotQuality:8\n").screenshot_quality,
|
||||
8
|
||||
);
|
||||
assert_eq!(
|
||||
Graphics::parse("screenshotQuality:3\n").screenshot_quality,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
Graphics::parse("screenshotQuality:16\n").screenshot_quality,
|
||||
1
|
||||
);
|
||||
// The old menus' Simple / Advanced choice is ignored, and so is the
|
||||
// shader pack older versions saved.
|
||||
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
|
||||
|
||||
@@ -33,6 +33,7 @@ pub enum H {
|
||||
Fog,
|
||||
Weather,
|
||||
AnimatedTextures,
|
||||
DitherCutoutMips,
|
||||
PresentMode,
|
||||
Pacing,
|
||||
Workers,
|
||||
@@ -40,6 +41,7 @@ pub enum H {
|
||||
ResolutionScale,
|
||||
Antialiasing,
|
||||
Upscaling,
|
||||
ScreenshotQuality,
|
||||
Hdr,
|
||||
/// Opens `Level::Hdr`.
|
||||
HdrSettings,
|
||||
@@ -260,6 +262,11 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
Low,
|
||||
"Animates water, lava, fire and other moving textures.",
|
||||
),
|
||||
h(
|
||||
H::DitherCutoutMips,
|
||||
Low,
|
||||
"Keeps fine plants in high-res packs from fading far away.",
|
||||
),
|
||||
Row::Header("Display"),
|
||||
fov,
|
||||
fps,
|
||||
@@ -279,6 +286,11 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
High,
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
|
||||
),
|
||||
h(
|
||||
H::ScreenshotQuality,
|
||||
None,
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.",
|
||||
),
|
||||
h(
|
||||
H::HdrSettings,
|
||||
None,
|
||||
@@ -519,6 +531,7 @@ impl H {
|
||||
H::Fog => labelled("Fog", on_off(g.fog)),
|
||||
H::Weather => labelled("Weather", on_off(g.weather)),
|
||||
H::AnimatedTextures => labelled("Animated Textures", on_off(g.animated_textures)),
|
||||
H::DitherCutoutMips => labelled("Distant Plant Dither", on_off(g.dither_cutout_mips)),
|
||||
H::PresentMode => labelled("Present Mode", g.present_mode.label()),
|
||||
H::Pacing => labelled(
|
||||
"Frame Pacing",
|
||||
@@ -544,6 +557,9 @@ impl H {
|
||||
},
|
||||
),
|
||||
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
|
||||
H::ScreenshotQuality => {
|
||||
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
|
||||
}
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
|
||||
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
|
||||
@@ -637,6 +653,7 @@ impl H {
|
||||
H::Fog => g.fog = !g.fog,
|
||||
H::Weather => g.weather = !g.weather,
|
||||
H::AnimatedTextures => g.animated_textures = !g.animated_textures,
|
||||
H::DitherCutoutMips => g.dither_cutout_mips = !g.dither_cutout_mips,
|
||||
H::PresentMode => g.present_mode = step(&PresentMode::ALL, g.present_mode, back),
|
||||
// Off, FXAA, TAA.
|
||||
H::Antialiasing => {
|
||||
@@ -654,6 +671,13 @@ impl H {
|
||||
(g.fxaa, g.taa) = (false, true);
|
||||
}
|
||||
}
|
||||
H::ScreenshotQuality => {
|
||||
g.screenshot_quality = step(
|
||||
&crate::graphics::SCREENSHOT_QUALITIES,
|
||||
g.screenshot_quality,
|
||||
back,
|
||||
)
|
||||
}
|
||||
H::Hdr => g.hdr = !g.hdr,
|
||||
H::BatteryMode => g.battery = !g.battery,
|
||||
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
|
||||
@@ -717,6 +741,7 @@ impl Item {
|
||||
Item::Horizon(H::Fog) => g.fog,
|
||||
Item::Horizon(H::Weather) => g.weather,
|
||||
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
|
||||
Item::Horizon(H::DitherCutoutMips) => g.dither_cutout_mips,
|
||||
Item::Horizon(H::Hdr) => g.hdr,
|
||||
Item::Horizon(H::BatteryMode) => g.battery,
|
||||
Item::Horizon(H::ShadowCache) => g.shadow_cache,
|
||||
@@ -775,6 +800,7 @@ impl Item {
|
||||
Item::Horizon(
|
||||
H::Preset
|
||||
| H::Overlay
|
||||
| H::ScreenshotQuality
|
||||
| H::Pacing
|
||||
| H::PresentMode
|
||||
| H::Workers
|
||||
@@ -1743,6 +1769,21 @@ mod tests {
|
||||
assert_eq!(s.horizon.preset, Preset::Ultra);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_quality_cycles_1_2_4_8_and_keeps_the_preset() {
|
||||
let mut s = Settings::default();
|
||||
Item::Horizon(H::Preset).cycle(&mut s, false, [854, 480]);
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..4 {
|
||||
Item::Horizon(H::ScreenshotQuality).cycle(&mut s, false, [854, 480]);
|
||||
seen.push(s.horizon.screenshot_quality);
|
||||
}
|
||||
assert_eq!(seen, [2, 4, 8, 1]);
|
||||
Item::Horizon(H::ScreenshotQuality).cycle(&mut s, true, [854, 480]);
|
||||
assert_eq!(s.horizon.screenshot_quality, 8);
|
||||
assert_eq!(s.horizon.preset, Preset::Performance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_budget_slider_steps() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -528,20 +528,33 @@ impl HudAtlas {
|
||||
for folder in folders {
|
||||
walk(&textures.join(folder), &mut files);
|
||||
}
|
||||
// Content mods' equipment textures, which their equipment assets name as
|
||||
// `<ns>:<path>`, and their mob effects' icons, `<ns>:mob_effect/<path>`.
|
||||
// Content mods' entity textures: their equipment assets name them as
|
||||
// `<ns>:<path>`, and native mods draw their block entities with them
|
||||
// (`native_draw_quad`: `<ns>:entity/...`); and their mob effects'
|
||||
// icons, `<ns>:mob_effect/<path>`.
|
||||
let namespaces = horizon_items::content_namespaces::namespaces(root);
|
||||
for (folder, textures) in [("entity", "entity/equipment"), ("mob_effect", "mob_effect")] {
|
||||
for folder in ["entity", "mob_effect"] {
|
||||
if folders.contains(&folder) {
|
||||
for namespace in &namespaces {
|
||||
walk(
|
||||
&root.join(format!("assets/{namespace}/textures/{textures}")),
|
||||
&root.join(format!("assets/{namespace}/textures/{folder}")),
|
||||
&mut files,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let assets = root.join("assets");
|
||||
// A native mod's particle type names its textures `<ns>:particle/<path>`
|
||||
// and needn't ship content, so every namespace's folder is packed.
|
||||
if folders.contains(&"particle")
|
||||
&& let Ok(namespaces) = fs::read_dir(&assets)
|
||||
{
|
||||
for ns in namespaces.flatten() {
|
||||
if ns.file_name() != "minecraft" {
|
||||
walk(&ns.path().join("textures/particle"), &mut files);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Content mods' GUI textures, for their screens: `<namespace>:gui/...`.
|
||||
if full && let Ok(namespaces) = fs::read_dir(&assets) {
|
||||
for ns in namespaces.flatten() {
|
||||
@@ -565,6 +578,12 @@ impl HudAtlas {
|
||||
format!("{namespace}:{rest}")
|
||||
}
|
||||
};
|
||||
// LabPBR's maps beside a texture are the world's (sprites.rs), not images to draw.
|
||||
if let Some(base) = name.strip_suffix("_n").or_else(|| name.strip_suffix("_s"))
|
||||
&& textures.join(format!("{base}.png")).is_file()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let image = decode(path)
|
||||
.map_err(|e| log::warn!("skipped GUI texture {}: {e}", path.display()))
|
||||
.ok()?;
|
||||
@@ -1604,6 +1623,9 @@ pub struct HudRenderer {
|
||||
atlas_view: wgpu::TextureView,
|
||||
/// The resource pack's text shader, when it has one that works.
|
||||
pack_text: Option<pack_text::PackText>,
|
||||
/// Target pixels per window pixel: above 1 for a screenshot drawn larger than the
|
||||
/// window (`Renderer::screenshot_quality`).
|
||||
pub zoom: f32,
|
||||
}
|
||||
impl HudRenderer {
|
||||
pub fn new(
|
||||
@@ -1833,6 +1855,7 @@ impl HudRenderer {
|
||||
format,
|
||||
atlas_view: view.clone(),
|
||||
pack_text: None,
|
||||
zoom: 1.0,
|
||||
})
|
||||
}
|
||||
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
|
||||
@@ -1968,14 +1991,14 @@ impl HudRenderer {
|
||||
[
|
||||
size[0] as f32,
|
||||
size[1] as f32,
|
||||
batch.scale,
|
||||
batch.scale * self.zoom,
|
||||
batch.glint_alpha,
|
||||
],
|
||||
batch.end_portal,
|
||||
)),
|
||||
);
|
||||
if let Some(pack_text) = &self.pack_text {
|
||||
pack_text.prepare(uploads, encoder, batch, size);
|
||||
pack_text.prepare(uploads, encoder, batch, size, batch.scale * self.zoom);
|
||||
}
|
||||
}
|
||||
/// Draw the batch `prepare` staged.
|
||||
@@ -2048,7 +2071,8 @@ impl HudRenderer {
|
||||
});
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_vertex_buffer(0, self.buffer.slice(..));
|
||||
if let Some([x, y, w, h]) = clip {
|
||||
if let Some(clip) = clip {
|
||||
let [x, y, w, h] = clip.map(|v| (v as f32 * self.zoom) as u32);
|
||||
let (x, y) = (x.min(size[0]), y.min(size[1]));
|
||||
let (w, h) = (w.min(size[0] - x), h.min(size[1] - y));
|
||||
if w == 0 || h == 0 {
|
||||
@@ -2186,7 +2210,7 @@ mod tests {
|
||||
atlas.pixels[at..at + 4].try_into().unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn content_mod_equipment_textures_pack_under_their_namespace() {
|
||||
fn content_mod_entity_textures_pack_under_their_namespace() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-atlas-equip-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let equipment = root.join("assets/gems/textures/entity/equipment/humanoid");
|
||||
@@ -2206,10 +2230,17 @@ mod tests {
|
||||
br#"["gems"]"#,
|
||||
)
|
||||
.unwrap();
|
||||
// A block entity's texture, for a native mod's renderer.
|
||||
let machine = root.join("assets/gems/textures/entity/machine");
|
||||
fs::create_dir_all(&machine).unwrap();
|
||||
write_strip(&machine.join("cog.png"), 32, 32, &[[0, 0, 200, 255]], None);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let rect = atlas.sprites["gems:entity/equipment/humanoid/ruby"];
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [200, 0, 0, 255]);
|
||||
// Only equipment: the mods' other textures reach the atlas through atlas definitions.
|
||||
let rect = atlas.sprites["gems:entity/machine/cog"];
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (32, 32));
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 0, 200, 255]);
|
||||
// Only entity textures: the mods' others reach the atlas through atlas definitions.
|
||||
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
@@ -216,8 +216,9 @@ impl PackText {
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
batch: &HudBatch,
|
||||
size: [u32; 2],
|
||||
scale: f32,
|
||||
) {
|
||||
uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size));
|
||||
uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size, scale));
|
||||
}
|
||||
|
||||
/// Draw `range` (whole glyph quads) with the pack's shader; the caller sets its own
|
||||
@@ -233,7 +234,8 @@ impl PackText {
|
||||
/// `GlintAlpha`, `GameTime`, `ScreenSize`), `Projection` (`GuiRenderer`'s orthographic GUI
|
||||
/// projection, 1000 to 11000 deep, in GL's depth range), `DynamicTransforms` (the GUI's model view
|
||||
/// moving 11000 back, colour modulator white) and an empty `Fog`.
|
||||
fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec<u8> {
|
||||
/// `scale`: target pixels per GUI pixel (`batch.scale`, times `HudRenderer::zoom`).
|
||||
fn uniforms(batch: &HudBatch, size: [u32; 2], scale: f32) -> Vec<u8> {
|
||||
let mut data = vec![0f32; 256];
|
||||
let globals = 0;
|
||||
data[globals + 3] = batch.glint_alpha;
|
||||
@@ -241,7 +243,7 @@ fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec<u8> {
|
||||
data[globals + 8] = size[0] as f32;
|
||||
data[globals + 9] = size[1] as f32;
|
||||
// Matrix4f.setOrtho(0, width, height, 0, near, far) with the GUI's scale, columns.
|
||||
let (w, h) = (size[0] as f32 / batch.scale, size[1] as f32 / batch.scale);
|
||||
let (w, h) = (size[0] as f32 / scale, size[1] as f32 / scale);
|
||||
let (near, far) = (1000.0f32, 11000.0f32);
|
||||
let projection = 64;
|
||||
data[projection] = 2.0 / w;
|
||||
@@ -287,7 +289,8 @@ mod tests {
|
||||
fn the_gui_projection_maps_gui_pixels_to_the_screen() {
|
||||
let mut batch = HudBatch::new([1920, 1080]);
|
||||
batch.scale = 4.0;
|
||||
let data: Vec<f32> = bytemuck::cast_slice(&uniforms(&batch, [1920, 1080])).to_vec();
|
||||
let data: Vec<f32> =
|
||||
bytemuck::cast_slice(&uniforms(&batch, [1920, 1080], batch.scale)).to_vec();
|
||||
let column = |matrix: usize, c: usize| &data[matrix + 4 * c..matrix + 4 * c + 4];
|
||||
let apply = |matrix: usize, v: [f32; 4]| -> [f32; 4] {
|
||||
std::array::from_fn(|r| (0..4).map(|c| column(matrix, c)[r] * v[c]).sum())
|
||||
|
||||
@@ -231,6 +231,9 @@ pub struct SectionMesh {
|
||||
/// each frame by `block_entity_render` like vanilla's per-section
|
||||
/// `SectionCompiler.Results.blockEntities`.
|
||||
pub block_entities: Vec<([i32; 3], u32)>,
|
||||
/// Blocks that give off light (world position, state id), for the colour of block light
|
||||
/// Horizon's shader packs read (`gfx::block_light`).
|
||||
pub lights: Vec<([i32; 3], u32)>,
|
||||
/// Sprite layers emitted while building this mesh. Kept distinct before
|
||||
/// upload so the renderer does not rescan every terrain vertex.
|
||||
pub sprite_layers: Vec<u16>,
|
||||
@@ -248,6 +251,7 @@ impl Default for SectionMesh {
|
||||
translucent: Vec::new(),
|
||||
visibility: ALL_VISIBLE,
|
||||
block_entities: Vec::new(),
|
||||
lights: Vec::new(),
|
||||
sprite_layers: Vec::new(),
|
||||
bounds: None,
|
||||
keep: false,
|
||||
@@ -1124,6 +1128,10 @@ impl Mesher<'_> {
|
||||
out.block_entities
|
||||
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
|
||||
}
|
||||
if state.emission > 0 {
|
||||
out.lights
|
||||
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
|
||||
}
|
||||
if state.solid
|
||||
&& Direction::ALL.iter().all(|d| {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
//!
|
||||
//! The buckets lie one after another in the mesh, in an order chosen so that
|
||||
//! the buckets an eye can see are usually one or two runs of quads: one draw
|
||||
//! each. Inside a bucket quads keep their model order, so coplanar quads
|
||||
//! facing one way (an overlay on its base) still draw in that order.
|
||||
use super::TerrainVertex;
|
||||
//! each. Coplanar quads facing one way keep their model order, so an overlay
|
||||
//! on its base still draws in that order. Solid cutout quads are otherwise
|
||||
//! ordered from the outside toward the section interior to reduce overdraw.
|
||||
use super::{OPAQUE_LAYER, TerrainVertex};
|
||||
|
||||
/// The buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y. Seen from
|
||||
/// above (+Y, the commonest case), each pair of horizontal sides with a
|
||||
@@ -138,11 +139,14 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
|
||||
OTHER
|
||||
}
|
||||
|
||||
/// Reorders `solid`'s quads by bucket, keeping their order inside each.
|
||||
/// Reorders `solid`'s quads by bucket. Solid cutout quads are also ordered
|
||||
/// front to back within each axis-facing bucket; stable sorting preserves the
|
||||
/// mesher's order for coplanar overlays.
|
||||
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
let (quads, rest) = solid.as_chunks::<4>();
|
||||
debug_assert!(rest.is_empty(), "solid vertices come in quads");
|
||||
let buckets: Vec<u8> = quads.iter().map(|q| bucket(q) as u8).collect();
|
||||
let has_cutout = quads.iter().any(|q| q[0].layer & OPAQUE_LAYER == 0);
|
||||
let mut counts = [0u32; 7];
|
||||
let mut planes = [0f32; 7];
|
||||
for (quad, &b) in quads.iter().zip(&buckets) {
|
||||
@@ -163,7 +167,7 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
total += count;
|
||||
*end = total;
|
||||
}
|
||||
if counts.iter().filter(|&&c| c > 0).count() > 1 {
|
||||
if has_cutout || counts.iter().filter(|&&c| c > 0).count() > 1 {
|
||||
let mut next: [u32; 7] = std::array::from_fn(|b| ends[b] - counts[b]);
|
||||
let mut sorted = vec![quads[0]; quads.len()];
|
||||
for (quad, &b) in quads.iter().zip(&buckets) {
|
||||
@@ -171,6 +175,25 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
sorted[*slot as usize] = *quad;
|
||||
*slot += 1;
|
||||
}
|
||||
if has_cutout {
|
||||
let mut start = 0;
|
||||
for (i, bucket) in BUCKETS.iter().enumerate() {
|
||||
let end = ends[i] as usize;
|
||||
let slice = &mut sorted[start..end];
|
||||
if let Bucket::Side(axis, positive) = *bucket {
|
||||
slice.sort_by(|a, b| {
|
||||
let a = a[0].pos[axis];
|
||||
let b = b[0].pos[axis];
|
||||
if positive {
|
||||
b.total_cmp(&a)
|
||||
} else {
|
||||
a.total_cmp(&b)
|
||||
}
|
||||
});
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
*solid = sorted.into_flattened();
|
||||
}
|
||||
Sides { ends, planes }
|
||||
@@ -368,4 +391,46 @@ mod tests {
|
||||
before.iter().map(|v| v.layer).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn front_to_back_sort_orders_positive_faces_and_keeps_ties_stable() {
|
||||
let mut solid: Vec<_> = [(1.0, 10), (3.0, 30), (3.0, 31)]
|
||||
.into_iter()
|
||||
.flat_map(|(x, layer)| {
|
||||
quad(
|
||||
[[x, 0.0, 0.0], [x, 1.0, 0.0], [x, 1.0, 1.0], [x, 0.0, 1.0]],
|
||||
layer,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
sort(&mut solid);
|
||||
let layers: Vec<u16> = solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| q[0].layer)
|
||||
.collect();
|
||||
assert_eq!(layers, [30, 31, 10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn front_to_back_sort_orders_negative_faces_toward_the_eye() {
|
||||
let mut solid: Vec<_> = [(3.0, 30), (1.0, 10), (2.0, 20)]
|
||||
.into_iter()
|
||||
.flat_map(|(x, layer)| {
|
||||
quad(
|
||||
[[x, 0.0, 0.0], [x, 0.0, 1.0], [x, 1.0, 1.0], [x, 1.0, 0.0]],
|
||||
layer,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
sort(&mut solid);
|
||||
let layers: Vec<u16> = solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| q[0].layer)
|
||||
.collect();
|
||||
assert_eq!(layers, [10, 20, 30]);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,21 @@ pub struct Settings {
|
||||
pub alpha_cutoff_bias: f32,
|
||||
pub has_transparent: bool,
|
||||
pub item: bool,
|
||||
/// Whether a high-resolution cutout's small levels are dithered ([`dither_cutout_levels`]).
|
||||
pub dither_cutouts: bool,
|
||||
}
|
||||
|
||||
/// The `ditherCutoutMips` setting, for textures loaded from now on.
|
||||
static DITHER_CUTOUTS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// Sets `ditherCutoutMips`; true if that changes it, when the textures must load again.
|
||||
pub fn set_dither_cutouts(on: bool) -> bool {
|
||||
DITHER_CUTOUTS.swap(on, std::sync::atomic::Ordering::Relaxed) != on
|
||||
}
|
||||
|
||||
/// The `ditherCutoutMips` setting.
|
||||
pub fn dither_cutouts() -> bool {
|
||||
DITHER_CUTOUTS.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// A square or rectangular RGBA8 image, rows top to bottom.
|
||||
@@ -362,5 +377,113 @@ fn generate_native(mut base: Image, levels: u32, settings: Settings) -> Vec<Imag
|
||||
}
|
||||
out.push(data);
|
||||
}
|
||||
if settings.dither_cutouts && cutout && out[0].width >= DITHERED_CUTOUT_SIZE {
|
||||
dither_cutout_levels(&mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// From this size a cutout texture's mips can be dithered ([`dither_cutout_levels`]).
|
||||
const DITHERED_CUTOUT_SIZE: u32 = 64;
|
||||
/// A high-resolution cutout's detail is finer than its small mips: grass blades a few
|
||||
/// texels wide at 2048 average to about the same alpha everywhere at 64, and no scale of
|
||||
/// that alpha can keep the coverage, so the alpha test passes the whole quad or none of it.
|
||||
/// Each level below the base takes the base's mean alpha through an ordered dither instead:
|
||||
/// its texels are in or out, about as many in as the base has.
|
||||
fn dither_cutout_levels(levels: &mut [Image]) {
|
||||
const BAYER: [[f32; 4]; 4] = [
|
||||
[0.0, 8.0, 2.0, 10.0],
|
||||
[12.0, 4.0, 14.0, 6.0],
|
||||
[3.0, 11.0, 1.0, 9.0],
|
||||
[15.0, 7.0, 13.0, 5.0],
|
||||
];
|
||||
let (mut w, mut h) = (levels[0].width as usize, levels[0].height as usize);
|
||||
let mut alpha: Vec<f32> = levels[0]
|
||||
.pixels
|
||||
.iter()
|
||||
.map(|p| f32::from(p[3]) / 255.0)
|
||||
.collect();
|
||||
for level in &mut levels[1..] {
|
||||
let (nw, nh) = (level.width as usize, level.height as usize);
|
||||
let mut next = vec![0.0; nw * nh];
|
||||
for y in 0..nh {
|
||||
for x in 0..nw {
|
||||
let at = |dx: usize, dy: usize| {
|
||||
alpha[(y * 2 + dy).min(h - 1) * w + (x * 2 + dx).min(w - 1)]
|
||||
};
|
||||
let mean = (at(0, 0) + at(1, 0) + at(0, 1) + at(1, 1)) / 4.0;
|
||||
next[y * nw + x] = mean;
|
||||
let threshold = (BAYER[y % 4][x % 4] + 0.5) / 16.0;
|
||||
level.pixels[y * nw + x][3] = if mean > threshold { 255 } else { 0 };
|
||||
}
|
||||
}
|
||||
alpha = next;
|
||||
(w, h) = (nw, nh);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dither_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn the_dither_only_runs_when_asked_for_and_from_its_size() {
|
||||
let cutout = |side: u32| Image {
|
||||
width: side,
|
||||
height: side,
|
||||
pixels: (0..side * side)
|
||||
.map(|i| {
|
||||
[
|
||||
90,
|
||||
140,
|
||||
40,
|
||||
if (i % side).is_multiple_of(4) { 255 } else { 0 },
|
||||
]
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let settings = |dither_cutouts| Settings {
|
||||
has_transparent: true,
|
||||
dither_cutouts,
|
||||
..Settings::default()
|
||||
};
|
||||
assert_ne!(
|
||||
generate(cutout(128), 4, settings(false)),
|
||||
generate(cutout(128), 4, settings(true))
|
||||
);
|
||||
assert_eq!(
|
||||
generate(cutout(32), 4, settings(false)),
|
||||
generate(cutout(32), 4, settings(true))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn a_high_resolution_cutouts_fine_detail_keeps_its_coverage_in_small_mips() {
|
||||
// Blades one texel wide, one column in four: too fine for the mips to hold.
|
||||
let side = 128u32;
|
||||
let pixels = (0..side * side)
|
||||
.map(|i| {
|
||||
[
|
||||
90,
|
||||
140,
|
||||
40,
|
||||
if (i % side).is_multiple_of(4) { 255 } else { 0 },
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let base = Image {
|
||||
width: side,
|
||||
height: side,
|
||||
pixels,
|
||||
};
|
||||
let settings = Settings {
|
||||
has_transparent: true,
|
||||
dither_cutouts: true,
|
||||
..Settings::default()
|
||||
};
|
||||
let mips = generate(base, 4, settings);
|
||||
for level in &mips[3..] {
|
||||
let passing = level.pixels.iter().filter(|p| p[3] >= 128).count() as f32;
|
||||
let coverage = passing / level.pixels.len() as f32;
|
||||
assert!((0.15..0.35).contains(&coverage), "{coverage}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1236,6 +1236,9 @@ pub enum Page {
|
||||
NativeMod(usize),
|
||||
/// Horizon's shader packs, either kind (`shader_pack_list`).
|
||||
ShaderPacks,
|
||||
/// The options of `s.shader_packs[.0]`, a Horizon pack: its `[[pages]]` entry `.1`, the
|
||||
/// first the one the button opens.
|
||||
NativePackOptions(usize, u8),
|
||||
/// The picked `shaders/` pack's options (`shader_options`), one of its screens at a time.
|
||||
ShaderOptions,
|
||||
/// Horizon's graphics settings (`graphics_ui`).
|
||||
@@ -1282,6 +1285,12 @@ enum Act {
|
||||
OpenShaderPackFolder,
|
||||
/// Open the picked pack's Shader Options page.
|
||||
ShaderOptions,
|
||||
/// Open the options page of the Horizon pack `s.shader_packs[.0]`.
|
||||
NativePackOptions(usize),
|
||||
/// Option `.1` of that pack: its next value.
|
||||
NativePackOption(usize, usize),
|
||||
/// Every option of that pack back to its default.
|
||||
NativePackReset(usize),
|
||||
/// Shader Options: the next value of an option, a screen of the pack, the next profile,
|
||||
/// every option back to its default.
|
||||
ShaderOption(String),
|
||||
@@ -1411,6 +1420,9 @@ pub enum Event {
|
||||
OpenShaderOptions,
|
||||
/// The Shader Options page closed with changes: save them and load the pack again.
|
||||
ShaderOptions,
|
||||
/// The options of `shader_packs[.0]`, a Horizon pack, changed: save them (the pack reloads
|
||||
/// with them).
|
||||
NativePackOptions(usize),
|
||||
/// Native mod `.0`'s page opened: the game asks the mod for its labels
|
||||
/// again (they were made with the list, maybe in another language).
|
||||
NativeSettings(usize),
|
||||
@@ -1627,18 +1639,12 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
}
|
||||
/// The open screen of the picked pack: its title, then its items two to a row (one when the
|
||||
/// pack asks for a single column).
|
||||
fn shader_option_rows(s: &Settings) -> Vec<Row> {
|
||||
let Some(options) = &s.shader_options else {
|
||||
return vec![Row::Header("horizon.shader_options.loading")];
|
||||
};
|
||||
let columns = options
|
||||
.screens
|
||||
.screen(options.screen())
|
||||
.map_or(2, |(_, c)| c);
|
||||
let blank = || Widget {
|
||||
/// A list's slot with nothing in it: text takes no clicks.
|
||||
fn blank_slot(w: f32) -> Widget {
|
||||
Widget {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
w: 150.0,
|
||||
w,
|
||||
h: 20.0,
|
||||
kind: WKind::Text {
|
||||
text: Text::literal(""),
|
||||
@@ -1647,7 +1653,55 @@ fn shader_option_rows(s: &Settings) -> Vec<Row> {
|
||||
},
|
||||
list: false,
|
||||
row: 0,
|
||||
}
|
||||
}
|
||||
/// Page `at` of the options of the Horizon pack `s.shader_packs[i]`: the pack's own layout,
|
||||
/// else every option two a row.
|
||||
fn native_pack_option_rows(s: &Settings, i: usize, at: u8) -> Vec<Row> {
|
||||
use horizon_shader_api::PageItem;
|
||||
let Some(pack) = s.shader_packs.get(i) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let count = pack.options.len().min(pack.values.len());
|
||||
let (columns, items) = match pack.pages.get(usize::from(at)) {
|
||||
Some(page) => (usize::from(page.columns), page.items.clone()),
|
||||
None => (2, (0..count).map(PageItem::Option).collect()),
|
||||
};
|
||||
let width = if columns == 1 { 310.0 } else { 150.0 };
|
||||
let widget = |item: &PageItem| match *item {
|
||||
PageItem::Option(o) if o < count => {
|
||||
let option = &pack.options[o];
|
||||
let value = crate::shader_pack_list::show_value(&option.kind, pack.values[o]);
|
||||
let label = Text::literal(format!("{}: {value}", option.name));
|
||||
button(0.0, 0.0, width, label, Act::NativePackOption(i, o))
|
||||
}
|
||||
PageItem::Page(p) if p < pack.pages.len() => {
|
||||
let label = Text::literal(format!("{}...", pack.pages[p].name));
|
||||
let page = Page::NativePackOptions(i, p as u8);
|
||||
button(0.0, 0.0, width, label, Act::Push(page))
|
||||
}
|
||||
_ => blank_slot(width),
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
if at == 0 {
|
||||
rows.push(Row::Note(pack.title.clone()));
|
||||
}
|
||||
rows.extend(
|
||||
items
|
||||
.chunks(columns)
|
||||
.map(|row| Row::Buttons(widget(&row[0]), row.get(1).map(widget))),
|
||||
);
|
||||
rows
|
||||
}
|
||||
fn shader_option_rows(s: &Settings) -> Vec<Row> {
|
||||
let Some(options) = &s.shader_options else {
|
||||
return vec![Row::Header("horizon.shader_options.loading")];
|
||||
};
|
||||
let columns = options
|
||||
.screens
|
||||
.screen(options.screen())
|
||||
.map_or(2, |(_, c)| c);
|
||||
let blank = || blank_slot(150.0);
|
||||
let width = if columns == 1 { 310.0 } else { 150.0 };
|
||||
let widget = |item: &horizon_shaders::ScreenItem| -> Option<Widget> {
|
||||
use horizon_shaders::ScreenItem;
|
||||
@@ -1712,6 +1766,26 @@ enum Row {
|
||||
/// A widget's tooltip (`setTooltip`), unwrapped: drawn while hovered, and
|
||||
/// its narration's hint.
|
||||
fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
|
||||
let pack_text = match &wd.kind {
|
||||
WKind::Button {
|
||||
act: Act::NativePackOption(pack, option),
|
||||
..
|
||||
} => Some(&s.shader_packs.get(*pack)?.options.get(*option)?.description),
|
||||
WKind::Button {
|
||||
act: Act::Push(Page::NativePackOptions(pack, at)),
|
||||
..
|
||||
} => Some(
|
||||
&s.shader_packs
|
||||
.get(*pack)?
|
||||
.pages
|
||||
.get(usize::from(*at))?
|
||||
.description,
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(text) = pack_text {
|
||||
return (!text.is_empty()).then(|| Text::literal(text));
|
||||
}
|
||||
let WKind::Button { act, .. } = &wd.kind else {
|
||||
let WKind::Slider { opt, .. } = &wd.kind else {
|
||||
return None;
|
||||
@@ -2055,6 +2129,14 @@ impl OptionsUi {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The name of a Horizon pack's options page past its first, as the page's title.
|
||||
fn pack_page_title(s: &Settings, page: Page) -> Option<Text> {
|
||||
let Page::NativePackOptions(pack, at @ 1..) = page else {
|
||||
return None;
|
||||
};
|
||||
let page = s.shader_packs.get(pack)?.pages.get(usize::from(at))?;
|
||||
Some(Text::literal(&page.name))
|
||||
}
|
||||
fn title(page: Page) -> &'static str {
|
||||
match page {
|
||||
Page::Main => "options.title",
|
||||
@@ -2073,7 +2155,7 @@ impl OptionsUi {
|
||||
Page::Mods => "horizon.pages.mods",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs",
|
||||
Page::ShaderOptions => "horizon.pages.shader_options",
|
||||
Page::ShaderOptions | Page::NativePackOptions(..) => "horizon.pages.shader_options",
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
|
||||
"options.videoTitle"
|
||||
}
|
||||
@@ -2502,13 +2584,19 @@ impl OptionsUi {
|
||||
if s.shader_packs.is_empty() {
|
||||
return vec![Row::Header("horizon.shader_packs.empty")];
|
||||
}
|
||||
let options = s.shader_packs.iter().any(|p| {
|
||||
p.file == s.horizon.shader_pack
|
||||
&& p.kind == crate::shader_pack_list::Kind::Classic
|
||||
});
|
||||
let options = options.then(|| {
|
||||
let picked = s
|
||||
.shader_packs
|
||||
.iter()
|
||||
.position(|p| p.file == s.horizon.shader_pack);
|
||||
let options = picked.and_then(|i| {
|
||||
let pack = &s.shader_packs[i];
|
||||
let act = match pack.kind {
|
||||
crate::shader_pack_list::Kind::Classic => Act::ShaderOptions,
|
||||
_ if pack.options.is_empty() => return None,
|
||||
crate::shader_pack_list::Kind::Horizon => Act::NativePackOptions(i),
|
||||
};
|
||||
let label = tr("horizon.shader_options.open");
|
||||
Row::Buttons(button(0.0, 0.0, 310.0, label, Act::ShaderOptions), None)
|
||||
Some(Row::Buttons(button(0.0, 0.0, 310.0, label, act), None))
|
||||
});
|
||||
options
|
||||
.into_iter()
|
||||
@@ -2526,6 +2614,7 @@ impl OptionsUi {
|
||||
.collect()
|
||||
}
|
||||
Page::ShaderOptions => shader_option_rows(s),
|
||||
Page::NativePackOptions(i, at) => native_pack_option_rows(s, i, at),
|
||||
_ => {
|
||||
let _ = s;
|
||||
Vec::new()
|
||||
@@ -2977,7 +3066,8 @@ impl OptionsUi {
|
||||
w,
|
||||
h: 9.0,
|
||||
kind: WKind::Text {
|
||||
text: tr(Self::title(page)),
|
||||
text: Self::pack_page_title(s, page)
|
||||
.unwrap_or_else(|| tr(Self::title(page))),
|
||||
color: u32::MAX,
|
||||
centered: true,
|
||||
},
|
||||
@@ -3062,6 +3152,9 @@ impl OptionsUi {
|
||||
Page::Mods => Some(("horizon.mods.open_folder", Act::OpenModFolder)),
|
||||
Page::ShaderPacks => Some(("pack.openFolder", Act::OpenShaderPackFolder)),
|
||||
Page::ShaderOptions => Some(("controls.reset", Act::ShaderReset)),
|
||||
Page::NativePackOptions(i, _) => {
|
||||
Some(("controls.reset", Act::NativePackReset(i)))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some((label, act)) = folder {
|
||||
@@ -3722,6 +3815,25 @@ impl OptionsUi {
|
||||
options.cycle(&name, true);
|
||||
events.push(Event::ButtonClick);
|
||||
}
|
||||
let native = widgets.iter().find_map(|wd| match wd.kind {
|
||||
WKind::Button {
|
||||
active: true,
|
||||
act: Act::NativePackOption(pack, option),
|
||||
..
|
||||
} if ui::hovered((x, y), wd.x, wd.y, wd.w, wd.h)
|
||||
&& !(wd.list && (y < top || y >= bottom)) =>
|
||||
{
|
||||
Some((pack, option))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if let Some((pack, option)) = native
|
||||
&& let Some(entry) = s.shader_packs.get_mut(pack)
|
||||
&& entry.step_option(option, true, false)
|
||||
{
|
||||
events.push(Event::ButtonClick);
|
||||
events.push(Event::NativePackOptions(pack));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
if ui::scrollbar_thumb(top, bottom, content, self.scroll).is_some()
|
||||
@@ -3838,6 +3950,22 @@ impl OptionsUi {
|
||||
options.reset();
|
||||
}
|
||||
}
|
||||
Act::NativePackOptions(i) => {
|
||||
self.push(Page::NativePackOptions(i, 0));
|
||||
}
|
||||
Act::NativePackOption(pack, option) => {
|
||||
if let Some(entry) = s.shader_packs.get_mut(pack)
|
||||
&& entry.step_option(option, false, self.shift)
|
||||
{
|
||||
events.push(Event::NativePackOptions(pack));
|
||||
}
|
||||
}
|
||||
Act::NativePackReset(pack) => {
|
||||
if let Some(entry) = s.shader_packs.get_mut(pack) {
|
||||
entry.reset_options();
|
||||
events.push(Event::NativePackOptions(pack));
|
||||
}
|
||||
}
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
Act::None => {}
|
||||
@@ -4745,6 +4873,22 @@ impl OptionsUi {
|
||||
options.reset();
|
||||
}
|
||||
}
|
||||
Act::NativePackOptions(i) => {
|
||||
self.push(Page::NativePackOptions(i, 0));
|
||||
}
|
||||
Act::NativePackOption(pack, option) => {
|
||||
if let Some(entry) = s.shader_packs.get_mut(pack)
|
||||
&& entry.step_option(option, false, shift)
|
||||
{
|
||||
events.push(Event::NativePackOptions(pack));
|
||||
}
|
||||
}
|
||||
Act::NativePackReset(pack) => {
|
||||
if let Some(entry) = s.shader_packs.get_mut(pack) {
|
||||
entry.reset_options();
|
||||
events.push(Event::NativePackOptions(pack));
|
||||
}
|
||||
}
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
Act::None => {}
|
||||
@@ -5032,6 +5176,82 @@ mod tests {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_horizon_packs_options_step_both_ways_from_the_picked_packs_button() {
|
||||
let atlas = atlas();
|
||||
let size = (960.0, 540.0);
|
||||
let mut s = Settings::default();
|
||||
s.horizon.shader_pack = "p".into();
|
||||
s.shader_packs = vec![crate::shader_pack_list::Entry {
|
||||
file: "p".into(),
|
||||
title: "P".into(),
|
||||
kind: crate::shader_pack_list::Kind::Horizon,
|
||||
options: vec![horizon_shader_api::PackOption {
|
||||
id: "a".into(),
|
||||
name: "A".into(),
|
||||
description: String::new(),
|
||||
kind: horizon_shader_api::OptionKind::Float {
|
||||
default: 1.0,
|
||||
min: 0.0,
|
||||
max: 2.0,
|
||||
step: 0.5,
|
||||
},
|
||||
}],
|
||||
values: vec![1.0],
|
||||
pages: vec![
|
||||
horizon_shader_api::OptionPage {
|
||||
id: "main".into(),
|
||||
name: "P".into(),
|
||||
description: String::new(),
|
||||
columns: 2,
|
||||
items: vec![horizon_shader_api::PageItem::Page(1)],
|
||||
},
|
||||
horizon_shader_api::OptionPage {
|
||||
id: "more".into(),
|
||||
name: "More".into(),
|
||||
description: String::new(),
|
||||
columns: 1,
|
||||
items: vec![horizon_shader_api::PageItem::Option(0)],
|
||||
},
|
||||
],
|
||||
}];
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::ShaderPacks);
|
||||
let find = |ui: &mut OptionsUi, s: &Settings, want: &Act| {
|
||||
let (widgets, ..) = ui.layout(s, size.0, size.1);
|
||||
let w = widgets
|
||||
.into_iter()
|
||||
.find(|w| matches!(&w.kind, WKind::Button { act, .. } if act == want))
|
||||
.expect("button");
|
||||
(w.x + w.w / 2.0, w.y + w.h / 2.0)
|
||||
};
|
||||
let (x, y) = find(&mut ui, &s, &Act::NativePackOptions(0));
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
assert_eq!(ui.page(), Page::NativePackOptions(0, 0));
|
||||
// The option is on the pack's second page, behind a button on its first.
|
||||
let (x, y) = find(&mut ui, &s, &Act::Push(Page::NativePackOptions(0, 1)));
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
assert_eq!(ui.page(), Page::NativePackOptions(0, 1));
|
||||
let (x, y) = find(&mut ui, &s, &Act::NativePackOption(0, 0));
|
||||
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
assert!(events.contains(&Event::NativePackOptions(0)));
|
||||
assert_eq!(s.shader_packs[0].values, [1.5]);
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
|
||||
assert_eq!(s.shader_packs[0].values, [0.5]);
|
||||
// Shift-click: the option's default.
|
||||
ui.shift = true;
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
ui.shift = false;
|
||||
assert_eq!(s.shader_packs[0].values, [1.0]);
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
let (x, y) = find(&mut ui, &s, &Act::NativePackReset(0));
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
assert_eq!(s.shader_packs[0].values, [1.0]);
|
||||
ui.back(&mut s);
|
||||
assert_eq!(ui.page(), Page::NativePackOptions(0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_reload_error_blocks_options_until_dismissed() {
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
@@ -47,6 +47,9 @@ pub struct ParticleOptions {
|
||||
/// `GeyserParticleOptions.waterBlocks` and `GeyserBaseParticleOptions`'
|
||||
/// `waterBlocks` and `burstImpulseBase` (0 where the type has none).
|
||||
pub geyser: Option<(i32, f32)>,
|
||||
/// A native mod's own particle type, by its namespaced name (`mymod:spark`):
|
||||
/// `SimpleParticleType` plus the provider `particles/<name>.json` names.
|
||||
pub mod_particle: Option<String>,
|
||||
}
|
||||
impl ParticleOptions {
|
||||
pub fn simple(ty: ParticleType) -> Self {
|
||||
@@ -59,6 +62,15 @@ impl ParticleOptions {
|
||||
trail: None,
|
||||
item: None,
|
||||
geyser: None,
|
||||
mod_particle: None,
|
||||
}
|
||||
}
|
||||
/// A native mod's `SimpleParticleType`: the sprite set of
|
||||
/// `assets/<namespace>/particles/<path>.json`, drawn as a plain billboard.
|
||||
pub fn named(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
mod_particle: Some(name.into()),
|
||||
..Self::simple(ParticleType::Smoke)
|
||||
}
|
||||
}
|
||||
/// `GeyserParticleOptions(type, waterBlocks)`.
|
||||
@@ -1055,6 +1067,8 @@ pub struct ParticleEngine {
|
||||
/// it on the translucent layer (a texel with 0 < alpha < 255).
|
||||
atlas: HashMap<String, (Rect, bool)>,
|
||||
sets: HashMap<ParticleType, u32>,
|
||||
/// A native mod's sprite sets, by namespaced particle name.
|
||||
mod_sets: HashMap<String, u32>,
|
||||
set_sprites: Vec<Vec<u32>>,
|
||||
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
|
||||
emitters: Vec<(ParticleOptions, [f64; 3], f64, f64, u32)>,
|
||||
@@ -1092,6 +1106,7 @@ impl ParticleEngine {
|
||||
rects: Vec::new(),
|
||||
atlas: HashMap::new(),
|
||||
sets: HashMap::new(),
|
||||
mod_sets: HashMap::new(),
|
||||
set_sprites: Vec::new(),
|
||||
emitters: Vec::new(),
|
||||
starters: Vec::new(),
|
||||
@@ -1123,12 +1138,24 @@ impl ParticleEngine {
|
||||
.map(|name| self.atlas.get(name).map(|&(rect, _)| rect))
|
||||
.collect();
|
||||
self.sets.clear();
|
||||
self.mod_sets.clear();
|
||||
self.set_sprites.clear();
|
||||
self.item_sprites.clear();
|
||||
self.load_sprites(root);
|
||||
}
|
||||
/// The sprite sets of every `particles/*.json` under `root`.
|
||||
/// The sprite sets of every `particles/*.json` under `root`: vanilla's own
|
||||
/// and a native mod's (`assets/<namespace>/particles/<name>.json`).
|
||||
fn load_sprites(&mut self, root: &Path) {
|
||||
let assets = root.join("assets");
|
||||
if let Ok(namespaces) = std::fs::read_dir(&assets) {
|
||||
for namespace in namespaces.flatten() {
|
||||
let ns = namespace.file_name().to_string_lossy().into_owned();
|
||||
if ns == "minecraft" || !namespace.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
self.load_mod_sprites(&ns, &namespace.path().join("particles"));
|
||||
}
|
||||
}
|
||||
let dir = root.join("assets/minecraft/particles");
|
||||
for &ty in ParticleType::ALL {
|
||||
let Ok(bytes) = std::fs::read(dir.join(format!("{}.json", ty.name()))) else {
|
||||
@@ -1154,6 +1181,56 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A namespace's particle JSONs that vanilla has no type for: each becomes
|
||||
/// one sprite set under `<ns>:<file name>`.
|
||||
fn load_mod_sprites(&mut self, ns: &str, dir: &Path) {
|
||||
let Ok(files) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for file in files.flatten() {
|
||||
let path = file.path();
|
||||
if path.extension().is_none_or(|e| e != "json") {
|
||||
continue;
|
||||
}
|
||||
let Some(stem) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) else {
|
||||
continue;
|
||||
};
|
||||
let key = format!("{ns}:{stem}");
|
||||
if ParticleType::from_name(&key).is_some() {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
let sprites: Vec<u32> = json["textures"]
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|t| t.as_str())
|
||||
.map(|t| {
|
||||
// A texture is `<ns>:<path>` (bare means
|
||||
// `minecraft:`), and the atlas names a sprite
|
||||
// `<ns>:particle/<path>` — `minecraft`'s own
|
||||
// without its namespace.
|
||||
let name = match t.split_once(':') {
|
||||
Some(("minecraft", path)) => format!("particle/{path}"),
|
||||
Some((ns, path)) => format!("{ns}:particle/{path}"),
|
||||
None => format!("particle/{t}"),
|
||||
};
|
||||
self.intern(&name)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if !sprites.is_empty() {
|
||||
self.mod_sets.insert(key, self.set_sprites.len() as u32);
|
||||
self.set_sprites.push(sprites);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn intern(&mut self, name: &str) -> u32 {
|
||||
if let Some(i) = self.names.iter().position(|n| n == name) {
|
||||
return i as u32;
|
||||
@@ -1626,6 +1703,23 @@ impl ParticleEngine {
|
||||
) -> Option<Particle> {
|
||||
use ParticleType as T;
|
||||
let ty = o.ty;
|
||||
// A native mod's SimpleParticleType: vanilla's TextureSheetParticle with
|
||||
// the sprite set its particles/<name>.json names, no tint, 20..40 ticks.
|
||||
if let Some(name) = &o.mod_particle {
|
||||
let set = *self.mod_sets.get(name)?;
|
||||
let mut r = LegacyRandom::new(seed);
|
||||
let mut q = Particle::moving(&mut r, ParticleType::Smoke, p, v);
|
||||
q.ty = ParticleType::Smoke;
|
||||
q.gravity = 0.0;
|
||||
q.rgba = [1.0; 4];
|
||||
q.lifetime = 20 + r.next_int_bound(20).unwrap_or(0);
|
||||
q.set = Some(set);
|
||||
let sprites = &self.set_sprites[set as usize];
|
||||
q.sprite = Sprite::Index(
|
||||
sprites[r.next_int_bound(sprites.len() as i32).unwrap_or(0) as usize],
|
||||
);
|
||||
return Some(q);
|
||||
}
|
||||
let set = self.set_of(ty);
|
||||
let mut local = LegacyRandom::new(seed);
|
||||
let r = &mut local;
|
||||
@@ -4222,6 +4316,48 @@ const CREEPER: [[f64; 2]; 12] = [
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_mod_particles_json_becomes_its_sprite_set() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-mod-particles-{}", std::process::id()));
|
||||
let particles = dir.join("assets/mymod/particles");
|
||||
std::fs::create_dir_all(&particles).unwrap();
|
||||
std::fs::write(
|
||||
particles.join("spark.json"),
|
||||
br#"{"textures":["mymod:spark/1","mymod:spark/2"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(particles.join("spark.json").exists());
|
||||
let mut engine = ParticleEngine::new(&dir, &HashMap::new(), &HashSet::new());
|
||||
let set = *engine
|
||||
.mod_sets
|
||||
.get("mymod:spark")
|
||||
.expect("the set is loaded");
|
||||
assert_eq!(engine.set_sprites[set as usize].len(), 2);
|
||||
// The named option spawns a billboard from that set, not a vanilla type.
|
||||
let made = engine
|
||||
.make(
|
||||
&ParticleOptions::named("mymod:spark"),
|
||||
[0.0; 3],
|
||||
[0.0; 3],
|
||||
7,
|
||||
)
|
||||
.expect("a mod particle is made");
|
||||
assert_eq!(made.set, Some(set));
|
||||
// A name no pack knows draws nothing, like a missing vanilla sprite set.
|
||||
assert!(
|
||||
engine
|
||||
.make(
|
||||
&ParticleOptions::named("mymod:missing"),
|
||||
[0.0; 3],
|
||||
[0.0; 3],
|
||||
7
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn egg_teleport_unpacks_offsets_and_preserves_seeded_draw_order() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
|
||||
@@ -332,6 +332,7 @@ impl Settings {
|
||||
.parent()
|
||||
.map(crate::graphics::Graphics::load)
|
||||
.unwrap_or_default();
|
||||
crate::mipmap::set_dither_cutouts(horizon.dither_cutout_mips);
|
||||
let controller = path
|
||||
.parent()
|
||||
.map(crate::gamepad::PadSettings::load)
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//! Settings > Shader Packs lists them: Horizon's own (docs/shaders.md) and those in the
|
||||
//! `shaders/` format, side by side.
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use horizon_shader_api::{OptionKind, OptionPage, PackOption};
|
||||
|
||||
pub const DIR: &str = "shaderpacks";
|
||||
|
||||
@@ -14,13 +16,48 @@ pub enum Kind {
|
||||
Classic,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Entry {
|
||||
/// The folder's or file's name in `shaderpacks`: what the pick saves.
|
||||
pub file: String,
|
||||
/// The pack's own name for Horizon's packs, else `file`.
|
||||
pub title: String,
|
||||
pub kind: Kind,
|
||||
/// A Horizon pack's `[[options]]`, and the value of each the player set (`values_path`).
|
||||
pub options: Vec<PackOption>,
|
||||
pub values: Vec<f64>,
|
||||
/// Its options page's `[[pages]]`; empty for one page of every option.
|
||||
pub pages: Vec<OptionPage>,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// Saves the player's option values, for the pack in the folder `dir`.
|
||||
pub fn save_values(&self, dir: &Path) {
|
||||
let path = dir.join(&self.file);
|
||||
if let Err(e) = save_values(&path, &self.options, &self.values) {
|
||||
log::warn!("shader pack options of {}: {e}", self.file);
|
||||
}
|
||||
}
|
||||
|
||||
/// The option `i` one value on (`back`: one before), or at its default (`reset`). Whether
|
||||
/// the pack has that option.
|
||||
pub fn step_option(&mut self, i: usize, back: bool, reset: bool) -> bool {
|
||||
let (Some(o), Some(v)) = (self.options.get(i), self.values.get_mut(i)) else {
|
||||
return false;
|
||||
};
|
||||
*v = if reset {
|
||||
o.kind.default_constant()
|
||||
} else {
|
||||
step_value(&o.kind, *v, back)
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
/// Every option back to its default.
|
||||
pub fn reset_options(&mut self) {
|
||||
let defaults = self.options.iter().map(|o| o.kind.default_constant());
|
||||
self.values = defaults.collect();
|
||||
}
|
||||
}
|
||||
|
||||
/// The packs in `dir`, Horizon's first, each kind by title. A missing folder has none.
|
||||
@@ -53,11 +90,18 @@ fn entry(path: &Path) -> Option<Entry> {
|
||||
.ok()
|
||||
.map(|p| p.manifest)
|
||||
};
|
||||
let title = manifest.map_or_else(|| file.clone(), |m| m.name);
|
||||
let (title, options, pages) = manifest.map_or_else(
|
||||
|| (file.clone(), Vec::new(), Vec::new()),
|
||||
|m| (m.name, m.options, m.pages),
|
||||
);
|
||||
let values = load_values(path, &options);
|
||||
return Some(Entry {
|
||||
file,
|
||||
title,
|
||||
kind: Kind::Horizon,
|
||||
options,
|
||||
values,
|
||||
pages,
|
||||
});
|
||||
}
|
||||
let zip = path.is_file()
|
||||
@@ -68,9 +112,128 @@ fn entry(path: &Path) -> Option<Entry> {
|
||||
title: file.clone(),
|
||||
file,
|
||||
kind: Kind::Classic,
|
||||
options: Vec::new(),
|
||||
values: Vec::new(),
|
||||
pages: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Where the option values of the Horizon pack at `pack` are kept: `<pack>.txt` beside it,
|
||||
/// one `id=value` a line.
|
||||
pub fn values_path(pack: &Path) -> PathBuf {
|
||||
let mut path = pack.as_os_str().to_owned();
|
||||
path.push(".txt");
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
/// The value of each of `options` saved for the pack at `pack`, else its default; a value
|
||||
/// out of the option's range is brought into it.
|
||||
pub fn load_values(pack: &Path, options: &[PackOption]) -> Vec<f64> {
|
||||
let text = std::fs::read_to_string(values_path(pack)).unwrap_or_default();
|
||||
let saved: std::collections::HashMap<&str, f64> = text
|
||||
.lines()
|
||||
.filter_map(|l| l.split_once('='))
|
||||
.filter_map(|(k, v)| Some((k.trim(), v.trim().parse().ok()?)))
|
||||
.collect();
|
||||
options
|
||||
.iter()
|
||||
.map(|o| {
|
||||
saved
|
||||
.get(o.id.as_str())
|
||||
.map_or_else(|| o.kind.default_constant(), |&v| clamp(&o.kind, v))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Saves `values` of `options`; values at their defaults are left out, and with none left
|
||||
/// the file goes.
|
||||
fn save_values(pack: &Path, options: &[PackOption], values: &[f64]) -> std::io::Result<()> {
|
||||
let text: String = options
|
||||
.iter()
|
||||
.zip(values)
|
||||
.filter(|(o, v)| o.kind.default_constant() != **v)
|
||||
.map(|(o, v)| format!("{}={v}\n", o.id))
|
||||
.collect();
|
||||
let path = values_path(pack);
|
||||
if text.is_empty() {
|
||||
match std::fs::remove_file(&path) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
|
||||
_ => Ok(()),
|
||||
}
|
||||
} else {
|
||||
std::fs::write(path, text)
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp(kind: &OptionKind, v: f64) -> f64 {
|
||||
match kind {
|
||||
OptionKind::Bool { .. } => f64::from(u8::from(v != 0.0)),
|
||||
OptionKind::Float { min, max, .. } => v.clamp(f64::from(*min), f64::from(*max)),
|
||||
OptionKind::Int { min, max, .. } => v.round().clamp(f64::from(*min), f64::from(*max)),
|
||||
OptionKind::Choice { values, .. } => v.round().clamp(0.0, values.len() as f64 - 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The option's next value (`back`: the one before), wrapping around at the ends.
|
||||
fn step_value(kind: &OptionKind, v: f64, back: bool) -> f64 {
|
||||
let dir = if back { -1.0 } else { 1.0 };
|
||||
match kind {
|
||||
OptionKind::Bool { .. } => f64::from(u8::from(v == 0.0)),
|
||||
OptionKind::Float { min, max, step, .. } => {
|
||||
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
|
||||
if !back && v >= max - step * 0.01 {
|
||||
min
|
||||
} else if back && v <= min + step * 0.01 {
|
||||
max
|
||||
} else {
|
||||
// On the grid of steps from the minimum, so float error never piles up.
|
||||
let n = ((v - min) / step).round() + dir;
|
||||
(min + n * step).clamp(min, max)
|
||||
}
|
||||
}
|
||||
OptionKind::Int { min, max, .. } => {
|
||||
let (min, max) = (f64::from(*min), f64::from(*max));
|
||||
let n = v.round() + dir;
|
||||
if n > max {
|
||||
min
|
||||
} else if n < min {
|
||||
max
|
||||
} else {
|
||||
n
|
||||
}
|
||||
}
|
||||
OptionKind::Choice { values, .. } => {
|
||||
let n = values.len() as f64;
|
||||
(v.round() + dir).rem_euclid(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The value as the settings page shows it.
|
||||
pub fn show_value(kind: &OptionKind, v: f64) -> String {
|
||||
match kind {
|
||||
OptionKind::Bool { .. } => crate::lang::get(if v != 0.0 {
|
||||
"options.on"
|
||||
} else {
|
||||
"options.off"
|
||||
}),
|
||||
OptionKind::Float { step, .. } => {
|
||||
let digits = if *step >= 1.0 {
|
||||
0
|
||||
} else if *step >= 0.1 {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
format!("{v:.digits$}")
|
||||
}
|
||||
OptionKind::Int { .. } => format!("{v:.0}"),
|
||||
OptionKind::Choice { values, .. } => {
|
||||
values.get(v.round() as usize).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -106,4 +269,59 @@ mod tests {
|
||||
assert!(scan(&d.join("missing")).is_empty());
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
fn option(id: &str, kind: OptionKind) -> PackOption {
|
||||
PackOption {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
description: String::new(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_step_wrap_and_round_trip() {
|
||||
let float = OptionKind::Float {
|
||||
default: 1.0,
|
||||
min: 0.0,
|
||||
max: 2.0,
|
||||
step: 0.5,
|
||||
};
|
||||
assert_eq!(step_value(&float, 1.0, false), 1.5);
|
||||
assert_eq!(step_value(&float, 2.0, false), 0.0);
|
||||
assert_eq!(step_value(&float, 0.0, true), 2.0);
|
||||
assert_eq!(show_value(&float, 1.5), "1.5");
|
||||
let choice = OptionKind::Choice {
|
||||
values: vec!["a".into(), "b".into(), "c".into()],
|
||||
default: 0,
|
||||
};
|
||||
assert_eq!(step_value(&choice, 2.0, false), 0.0);
|
||||
assert_eq!(step_value(&choice, 0.0, true), 2.0);
|
||||
assert_eq!(show_value(&choice, 1.0), "b");
|
||||
let int = OptionKind::Int {
|
||||
default: 0,
|
||||
min: -1,
|
||||
max: 1,
|
||||
};
|
||||
assert_eq!(step_value(&int, 1.0, false), -1.0);
|
||||
let on = OptionKind::Bool { default: true };
|
||||
assert_eq!(step_value(&on, 1.0, false), 0.0);
|
||||
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-shader-values-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let pack = dir.join("p.hshader");
|
||||
let options = [option("a", float), option("b", choice)];
|
||||
// Only what differs from the defaults is written; out of range comes back clamped.
|
||||
save_values(&pack, &options, &[1.0, 2.0]).unwrap();
|
||||
let text = std::fs::read_to_string(values_path(&pack)).unwrap();
|
||||
assert_eq!(text, "b=2\n");
|
||||
std::fs::write(values_path(&pack), "a=9\nb=1\nzzz=4\n").unwrap();
|
||||
assert_eq!(load_values(&pack, &options), [2.0, 1.0]);
|
||||
save_values(&pack, &options, &[1.0, 0.0]).unwrap();
|
||||
assert!(!values_path(&pack).exists());
|
||||
assert_eq!(load_values(&pack, &options), [1.0, 0.0]);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,12 @@ pub fn profile_skin_url_nbt(profile: &horizon_world::nbt::Tag) -> Option<String>
|
||||
unpack(&value, Signature::Unsigned, &TEXTURE_URIS).skin
|
||||
}
|
||||
|
||||
/// `ResolvableProfile.skinPatch().body()` of a skull's `profile` NBT: the
|
||||
/// `texture` id that replaces its skin.
|
||||
pub fn profile_texture_nbt(profile: &horizon_world::nbt::Tag) -> Option<String> {
|
||||
nbt_text(profile.get("texture"))
|
||||
}
|
||||
|
||||
/// The profile's uuid as u128: `id` as an int array (block entity NBT) or a
|
||||
/// uuid string (older and component forms). Used for `DefaultPlayerSkin.get`
|
||||
/// when a head's skin is not downloaded yet.
|
||||
@@ -467,6 +473,11 @@ pub fn head_skin(
|
||||
downloads: &mut Downloader,
|
||||
atlas: impl Fn(&str) -> bool,
|
||||
) -> String {
|
||||
if let Some(texture) = profile.get("texture").and_then(|t| t.as_str())
|
||||
&& let Some(sprite) = patch_skin(texture, downloads, &atlas)
|
||||
{
|
||||
return sprite;
|
||||
}
|
||||
let p = parse_profile(profile);
|
||||
let property = p.textures.or_else(|| {
|
||||
let key = p.dynamic.as_ref()?;
|
||||
@@ -495,6 +506,68 @@ pub fn head_skin(
|
||||
crate::tab_list::default_skin(p.id).to_owned()
|
||||
}
|
||||
|
||||
/// `PlayerSkin.Patch.apply` for a head: the sprite of a profile's `texture`
|
||||
/// override (`ClientAsset.ResourceTexture`, a resource pack's
|
||||
/// `<namespace>:textures/<path>.png`), which replaces the downloaded or
|
||||
/// default skin. Vanilla textures are in the atlas already; another
|
||||
/// namespace's loads into a skin slot, and `None` answers until it is there
|
||||
/// (or when the packs lack it).
|
||||
pub fn patch_skin(
|
||||
texture: &str,
|
||||
downloads: &mut Downloader,
|
||||
atlas: impl Fn(&str) -> bool,
|
||||
) -> Option<String> {
|
||||
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
|
||||
if !valid_texture_id(namespace, path) {
|
||||
return None;
|
||||
}
|
||||
if namespace == "minecraft" && atlas(path) {
|
||||
return Some(path.to_owned());
|
||||
}
|
||||
let location = format!("patch/{namespace}/{path}");
|
||||
if atlas(&location) {
|
||||
downloads.drawn(&location);
|
||||
return Some(location);
|
||||
}
|
||||
downloads.resource(&location, namespace, path);
|
||||
None
|
||||
}
|
||||
|
||||
/// `Identifier`'s characters (`isValidNamespace`, `isValidPath`), and no
|
||||
/// `.`, `..` or empty segment (`FileUtil.decomposePath`, as the packs read
|
||||
/// it): a server's `texture` never names a file outside the packs.
|
||||
fn valid_texture_id(namespace: &str, path: &str) -> bool {
|
||||
let segment = |s: &str| !s.is_empty() && s != "." && s != "..";
|
||||
!namespace.is_empty()
|
||||
&& namespace
|
||||
.chars()
|
||||
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '_' | '-' | '.'))
|
||||
&& path
|
||||
.chars()
|
||||
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '_' | '-' | '.' | '/'))
|
||||
&& segment(namespace)
|
||||
&& path.split('/').all(segment)
|
||||
}
|
||||
|
||||
/// A `width`x`height` RGBA image as the 64x64 skin it maps onto: each texel
|
||||
/// the nearest of the image's at the same normalised UV.
|
||||
fn resample_64(width: u32, height: u32, rgba: &[u8]) -> Option<Vec<u8>> {
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
if w == 0 || h == 0 || rgba.len() < w * h * 4 {
|
||||
return None;
|
||||
}
|
||||
let mut out = vec![0u8; 64 * 64 * 4];
|
||||
for y in 0..64 {
|
||||
for x in 0..64 {
|
||||
let (sx, sy) = (x * w / 64, y * h / 64);
|
||||
let s = (sy * w + sx) * 4;
|
||||
let d = (y * 64 + x) * 4;
|
||||
out[d..d + 4].copy_from_slice(&rgba[s..s + 4]);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `MinecraftProfileTexture.getHash`: the base name of the URL's path.
|
||||
pub fn texture_hash(url: &str) -> Option<String> {
|
||||
let rest = url.split_once("://")?.1;
|
||||
@@ -732,6 +805,10 @@ pub struct Downloader {
|
||||
image_order: std::collections::VecDeque<String>,
|
||||
/// Skins and capes drawn from the atlas since `take_drawn`.
|
||||
drawn: std::collections::HashSet<String>,
|
||||
/// The resource packs' root, where a profile's `texture` patch is read.
|
||||
resource_root: std::path::PathBuf,
|
||||
/// How each `patch/` texture of the resource packs loaded, by location.
|
||||
resources: std::collections::HashMap<String, Download>,
|
||||
}
|
||||
/// `images` is bounded only so a long session on a busy server does not grow
|
||||
/// without end; the disk cache holds everything it drops.
|
||||
@@ -749,8 +826,48 @@ impl Downloader {
|
||||
images: Default::default(),
|
||||
image_order: Default::default(),
|
||||
drawn: Default::default(),
|
||||
resource_root: Default::default(),
|
||||
resources: Default::default(),
|
||||
}
|
||||
}
|
||||
/// The resource packs changed (or were first loaded) at `root`: patch
|
||||
/// textures load again from there.
|
||||
pub fn set_resource_root(&mut self, root: std::path::PathBuf) {
|
||||
for location in std::mem::take(&mut self.resources).into_keys() {
|
||||
self.images.remove(&location);
|
||||
self.image_order.retain(|l| *l != location);
|
||||
}
|
||||
self.resource_root = root;
|
||||
}
|
||||
/// `ClientAsset.ResourceTexture`: loads `<namespace>:textures/<path>.png`
|
||||
/// from the resource packs into a skin slot as `location`, once. A
|
||||
/// texture of another size is sampled as the model's normalised UVs would
|
||||
/// sample it (nearest texel) into the 64x64 slot.
|
||||
fn resource(&mut self, location: &str, namespace: &str, path: &str) {
|
||||
match self.resources.get(location) {
|
||||
Some(Download::Failed) => return,
|
||||
Some(_) => {
|
||||
self.again(location);
|
||||
return;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
let file = self
|
||||
.resource_root
|
||||
.join(format!("assets/{namespace}/textures/{path}.png"));
|
||||
let image = std::fs::read(file)
|
||||
.ok()
|
||||
.and_then(|b| crate::hud::decode_bytes(&b).ok())
|
||||
.and_then(|(w, h, rgba)| resample_64(w, h, &rgba));
|
||||
let state = match image {
|
||||
Some(rgba) => {
|
||||
self.queue(location.to_owned(), rgba);
|
||||
Download::Ready
|
||||
}
|
||||
None => Download::Failed,
|
||||
};
|
||||
self.resources.insert(location.to_owned(), state);
|
||||
}
|
||||
/// How the download of `url` stands, starting it the first time.
|
||||
pub fn state(&mut self, kind: Kind, url: &str) -> Download {
|
||||
while let Ok((key, image)) = self.done.try_recv() {
|
||||
@@ -824,6 +941,15 @@ impl Downloader {
|
||||
if self.queued.contains(location) {
|
||||
return true;
|
||||
}
|
||||
if location.starts_with("patch/") {
|
||||
let Some(image) = self.images.get(location).cloned() else {
|
||||
// Dropped from memory: read from the packs again.
|
||||
self.resources.remove(location);
|
||||
return false;
|
||||
};
|
||||
self.queue(location.to_owned(), image);
|
||||
return true;
|
||||
}
|
||||
let (digest, process): (_, fn(u32, u32, &[u8]) -> Option<Vec<u8>>) =
|
||||
if let Some(digest) = location.strip_prefix("skins/") {
|
||||
(digest, process_legacy_skin)
|
||||
@@ -960,6 +1086,21 @@ pub fn sha1(data: &[u8]) -> [u8; 20] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A profile's `texture` names a pack texture, never a file outside the
|
||||
/// packs: bad characters and `..` segments draw nothing.
|
||||
#[test]
|
||||
fn a_texture_patch_stays_inside_the_packs() {
|
||||
use super::valid_texture_id;
|
||||
assert!(valid_texture_id("ethereal", "entity/plush_blank"));
|
||||
assert!(valid_texture_id("minecraft", "entity/player/wide/steve"));
|
||||
assert!(!valid_texture_id("ethereal", "../../../secret"));
|
||||
assert!(!valid_texture_id("..", "entity/x"));
|
||||
assert!(!valid_texture_id("ethereal", "entity//x"));
|
||||
assert!(!valid_texture_id("Ethereal", "entity/x"));
|
||||
assert!(!valid_texture_id("ethereal", "entity\\x"));
|
||||
assert!(!valid_texture_id("", "entity/x"));
|
||||
}
|
||||
|
||||
/// A pack reload swaps the atlas: its skin slots are blank, so a forgotten
|
||||
/// table holds nothing and hands out slots from the start again, even for
|
||||
/// skins drawn in the last frame.
|
||||
|
||||
@@ -25,6 +25,30 @@ const ROWS: [&[u32]; MAX_PLAYERS - 4] = [
|
||||
&[4, 4, 4, 4],
|
||||
];
|
||||
|
||||
/// Where a window is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Placement {
|
||||
/// A normal window: its corner on the desktop and its size in pixels (`None`: a new one,
|
||||
/// where the system opens it).
|
||||
Free(Option<([i32; 2], [u32; 2])>),
|
||||
/// Borderless over the monitor of this name.
|
||||
Fill(String),
|
||||
}
|
||||
|
||||
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
|
||||
/// centred, each next one a step further, as systems cascade new windows (back to the centre
|
||||
/// after six), so new windows never open exactly on one another.
|
||||
pub fn cascade(number: u8) -> f64 {
|
||||
f64::from(number.saturating_sub(2) % 6) * 32.0
|
||||
}
|
||||
|
||||
/// A window: its number (1 is the game's own) and where it is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Window {
|
||||
pub number: u8,
|
||||
pub placement: Placement,
|
||||
}
|
||||
|
||||
/// `order` made a whole order of `n` players: those it names (once, if there are), then the
|
||||
/// others, P1 first.
|
||||
pub fn ordered(order: &[usize], n: usize) -> Vec<usize> {
|
||||
|
||||
@@ -85,18 +85,93 @@ pub struct Sprites {
|
||||
/// Each layer's frames at the texture's own resolution (square), and the
|
||||
/// frame shown first.
|
||||
native: Vec<(u32, Arc<Vec<Vec<u8>>>, usize)>,
|
||||
/// Still textures by their pixels and mip settings: mods copy vanilla's
|
||||
/// textures under their own names, and one layer serves them all (the
|
||||
/// GPU's array may hold as few as 2048).
|
||||
still: std::collections::HashMap<(Vec<u8>, u32, [u32; 2], [bool; 4]), u32>,
|
||||
/// Each layer's LabPBR maps (`_n` normals, `_s` specular) beside its texture: their first
|
||||
/// frame at their own resolution (square).
|
||||
pbr: Vec<[Option<(u32, Vec<u8>)>; 2]>,
|
||||
/// The layers' normal and specular maps at `pbr_size`, layer after layer, set by
|
||||
/// [`Sprites::finish`]: empty when no texture has either.
|
||||
pub normals: Vec<u8>,
|
||||
pub specular: Vec<u8>,
|
||||
pub pbr_size: u32,
|
||||
/// Still textures by their pixels, their LabPBR maps and mip settings: mods copy vanilla's
|
||||
/// textures under their own names, and one layer serves them all (the GPU's array may
|
||||
/// hold as few as 2048).
|
||||
still: std::collections::HashMap<(Vec<u8>, u64, u32, [u32; 2], [bool; 4]), u32>,
|
||||
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
|
||||
disk: Option<crate::sprite_cache::SpriteCache>,
|
||||
}
|
||||
/// Edge length of vanilla's block textures: the smallest array layer.
|
||||
pub const SPRITE_SIZE: u32 = 16;
|
||||
/// Largest sprite array (all layers of the top mip level): resource packs
|
||||
/// with bigger textures are sampled down to fit.
|
||||
const MAX_ARRAY_BYTES: u64 = 256 << 20;
|
||||
/// The largest sprite array (all layers of the top mip level) when the settings leave it to
|
||||
/// Horizon: resource packs with bigger textures are sampled down to fit.
|
||||
pub const DEFAULT_TEXTURE_BUDGET: u64 = 256 << 20;
|
||||
/// The budget in bytes the next resource load keeps to (`set_texture_budget`).
|
||||
static TEXTURE_BUDGET: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(DEFAULT_TEXTURE_BUDGET);
|
||||
/// The block textures' budget from the `textureBudgetMiB` setting (0 for the default), for
|
||||
/// resources loaded from now on.
|
||||
pub fn set_texture_budget(mib: u32) {
|
||||
let bytes = match mib {
|
||||
0 => DEFAULT_TEXTURE_BUDGET,
|
||||
mib => u64::from(mib) << 20,
|
||||
};
|
||||
TEXTURE_BUDGET.store(bytes, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
/// Whether resource loads read the resource packs' LabPBR maps (`set_labpbr_maps`).
|
||||
static LABPBR_MAPS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether resource loads from now on read the resource packs' LabPBR maps beside their block
|
||||
/// textures. Only a native shader pack's material stage reads them, so without one they cost
|
||||
/// neither loading time nor video memory.
|
||||
pub fn set_labpbr_maps(on: bool) {
|
||||
LABPBR_MAPS.store(on, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
/// Whether resource loads read the LabPBR maps (`set_labpbr_maps`).
|
||||
pub fn labpbr_maps() -> bool {
|
||||
LABPBR_MAPS.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
/// Largest array of each LabPBR map, which only a native shader pack reads.
|
||||
const MAX_MAP_BYTES: u64 = DEFAULT_TEXTURE_BUDGET / 4;
|
||||
/// Fewer layers than any game has (vanilla's are about 1300): what a map is kept at while
|
||||
/// loading assumes at least these many share the array.
|
||||
const LAYERS_AT_LEAST: u64 = 1024;
|
||||
/// A layer's normal map where its texture has none: flat, unoccluded, at full height.
|
||||
pub const FLAT_NORMAL: [u8; 4] = [128, 128, 255, 255];
|
||||
/// A layer's specular map where its texture has none: rough, not metal, not glowing.
|
||||
pub const NO_SPECULAR: [u8; 4] = [0, 0, 0, 0];
|
||||
/// The largest power-of-two side from `start` down (at least `SPRITE_SIZE`) at which `layers`
|
||||
/// square RGBA layers take at most `bytes`.
|
||||
fn side_within(start: u32, layers: u64, bytes: u64) -> u32 {
|
||||
let mut size = start.max(SPRITE_SIZE).next_power_of_two();
|
||||
while size > SPRITE_SIZE && layers * u64::from(size) * u64::from(size) * 4 > bytes {
|
||||
size /= 2;
|
||||
}
|
||||
size
|
||||
}
|
||||
/// A LabPBR map's square `frame` of `side` averaged down by whole powers of two to at most
|
||||
/// `max`, channel by channel: they are data, not colour.
|
||||
fn shrink_map(frame: Vec<u8>, side: u32, max: u32) -> (u32, Vec<u8>) {
|
||||
if side <= max || !side.is_power_of_two() || !max.is_power_of_two() {
|
||||
return (side, frame);
|
||||
}
|
||||
let (out_side, f, side) = (max as usize, (side / max) as usize, side as usize);
|
||||
let mut out = Vec::with_capacity(out_side * out_side * 4);
|
||||
for y in 0..out_side {
|
||||
for x in 0..out_side {
|
||||
let mut sum = [0u32; 4];
|
||||
for dy in 0..f {
|
||||
let row = ((y * f + dy) * side + x * f) * 4;
|
||||
for texel in frame[row..row + f * 4].as_chunks::<4>().0 {
|
||||
for (s, &v) in sum.iter_mut().zip(texel) {
|
||||
*s += u32::from(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = (f * f) as u32;
|
||||
out.extend(sum.map(|s| ((s + n / 2) / n) as u8));
|
||||
}
|
||||
}
|
||||
(max, out)
|
||||
}
|
||||
/// Nearest-resample a square RGBA frame to another square size.
|
||||
pub(crate) fn resample(frame: &[u8], from: u32, to: u32) -> Vec<u8> {
|
||||
if from == to {
|
||||
@@ -156,7 +231,7 @@ impl Sprites {
|
||||
}
|
||||
/// Lay every layer out at one size: the largest texture's (resource packs
|
||||
/// may ship 32x, 64x... textures, like vanilla's stitched atlas), rounded
|
||||
/// up to a power of two and limited to [`MAX_ARRAY_BYTES`]. Smaller
|
||||
/// up to a power of two and limited to the texture budget (`set_texture_budget`). Smaller
|
||||
/// textures are scaled up by whole pixels, so UVs stay per layer.
|
||||
pub fn finish(&mut self) {
|
||||
self.lay_out(true);
|
||||
@@ -170,13 +245,18 @@ impl Sprites {
|
||||
let layers = self.native.len().max(1) as u64;
|
||||
let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE);
|
||||
self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE);
|
||||
let mut size = largest.max(SPRITE_SIZE).next_power_of_two();
|
||||
while size > SPRITE_SIZE && layers * u64::from(size * size) * 4 > MAX_ARRAY_BYTES {
|
||||
size /= 2;
|
||||
let budget = TEXTURE_BUDGET.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let size = side_within(largest, layers, budget);
|
||||
if pixels {
|
||||
log::info!(
|
||||
"Horizon: block sprites: {layers} layers at {size} px (largest {largest} px, budget {} MiB)",
|
||||
budget >> 20
|
||||
);
|
||||
}
|
||||
if !pixels {
|
||||
self.pixels = Vec::new();
|
||||
self.animations = Vec::new();
|
||||
self.pbr = Vec::new();
|
||||
self.size = size;
|
||||
return;
|
||||
}
|
||||
@@ -192,6 +272,30 @@ impl Sprites {
|
||||
animation.frames = resized[animation.layer as usize].clone();
|
||||
}
|
||||
self.size = size;
|
||||
self.normals.clear();
|
||||
self.specular.clear();
|
||||
let largest_map = self.pbr.iter().flatten().flatten().map(|m| m.0).max();
|
||||
self.pbr_size = largest_map.map_or(SPRITE_SIZE, |l| {
|
||||
side_within(l.min(size), layers, MAX_MAP_BYTES)
|
||||
});
|
||||
if largest_map.is_some() {
|
||||
let pbr_size = self.pbr_size;
|
||||
log::info!("Horizon: LabPBR maps at {pbr_size} px, {layers} layers");
|
||||
for maps in std::mem::take(&mut self.pbr) {
|
||||
for (map, (out, none)) in maps.into_iter().zip([
|
||||
(&mut self.normals, FLAT_NORMAL),
|
||||
(&mut self.specular, NO_SPECULAR),
|
||||
]) {
|
||||
match map {
|
||||
Some((from, pixels)) => {
|
||||
let (from, pixels) = shrink_map(pixels, from, pbr_size);
|
||||
out.extend(resample(&pixels, from, pbr_size));
|
||||
}
|
||||
None => out.extend(none.repeat((pbr_size * pbr_size) as usize)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The layer of a texture, added on first use. A texture that is missing
|
||||
/// or does not load (a resource pack's filter or a broken file) becomes
|
||||
@@ -222,6 +326,7 @@ impl Sprites {
|
||||
.collect();
|
||||
let layer = u32::try_from(self.native.len())?;
|
||||
self.opaque.push(true);
|
||||
self.pbr.push([None, None]);
|
||||
self.translucent.push(false);
|
||||
self.mips.push(crate::mipmap::Settings::default());
|
||||
self.layers.insert(name.to_owned(), layer);
|
||||
@@ -242,7 +347,7 @@ impl Sprites {
|
||||
decoded
|
||||
}
|
||||
};
|
||||
self.register(name, decoded)
|
||||
self.register(root, name, decoded)
|
||||
}
|
||||
/// Read and decode one texture and its `.mcmeta`.
|
||||
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
|
||||
@@ -263,40 +368,19 @@ impl Sprites {
|
||||
.as_deref()
|
||||
.map(AnimationMetadata::from_json)
|
||||
.transpose()?;
|
||||
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info()?;
|
||||
let mut bytes = vec![
|
||||
0;
|
||||
reader
|
||||
.output_buffer_size()
|
||||
.ok_or("sprite exceeds decoder limits")?
|
||||
];
|
||||
let info = reader.next_frame(&mut bytes)?;
|
||||
let (png_width, png_height, pixels) = read_rgba(&path)?;
|
||||
let (width, height) = if let Some(animation) = &animation {
|
||||
let [width, height] =
|
||||
animation.frame_size(i32::try_from(info.width)?, i32::try_from(info.height)?);
|
||||
animation.frame_size(i32::try_from(png_width)?, i32::try_from(png_height)?);
|
||||
(u32::try_from(width)?, u32::try_from(height)?)
|
||||
} else {
|
||||
(info.width, info.height)
|
||||
(png_width, png_height)
|
||||
};
|
||||
if width == 0 || height == 0 || info.width % width != 0 || info.height % height != 0 {
|
||||
if width == 0 || height == 0 || png_width % width != 0 || png_height % height != 0 {
|
||||
return Err(format!("sprite {name} has invalid frame dimensions").into());
|
||||
}
|
||||
let channels = info.color_type.samples();
|
||||
let mut pixels = Vec::with_capacity(info.width as usize * info.height as usize * 4);
|
||||
for pixel in bytes[..info.buffer_size()].chunks_exact(channels) {
|
||||
let rgba = match info.color_type {
|
||||
png::ColorType::Rgba => [pixel[0], pixel[1], pixel[2], pixel[3]],
|
||||
png::ColorType::Rgb => [pixel[0], pixel[1], pixel[2], 255],
|
||||
png::ColorType::Grayscale => [pixel[0], pixel[0], pixel[0], 255],
|
||||
png::ColorType::GrayscaleAlpha => [pixel[0], pixel[0], pixel[0], pixel[1]],
|
||||
_ => return Err("unsupported expanded sprite format".into()),
|
||||
};
|
||||
pixels.extend(rgba);
|
||||
}
|
||||
let columns = info.width / width;
|
||||
let count = columns * (info.height / height);
|
||||
let columns = png_width / width;
|
||||
let count = columns * (png_height / height);
|
||||
// Layers are square; a frame of another shape is nearest-resampled to
|
||||
// its longer side so UVs (normalized per layer) are unchanged.
|
||||
let side = width.max(height);
|
||||
@@ -309,7 +393,7 @@ impl Sprites {
|
||||
let source_row = y + row * height / side;
|
||||
for column in 0..side {
|
||||
let offset =
|
||||
((source_row * info.width + x + column * width / side) * 4) as usize;
|
||||
((source_row * png_width + x + column * width / side) * 4) as usize;
|
||||
frame.extend_from_slice(&pixels[offset..offset + 4]);
|
||||
}
|
||||
}
|
||||
@@ -339,7 +423,12 @@ impl Sprites {
|
||||
})
|
||||
}
|
||||
/// Give a decoded texture its layer (or the one an identical still has).
|
||||
fn register(&mut self, name: &str, decoded: crate::sprite_cache::Decoded) -> Result<u32> {
|
||||
fn register(
|
||||
&mut self,
|
||||
root: &Path,
|
||||
name: &str,
|
||||
decoded: crate::sprite_cache::Decoded,
|
||||
) -> Result<u32> {
|
||||
let crate::sprite_cache::Decoded {
|
||||
side,
|
||||
frames,
|
||||
@@ -356,6 +445,13 @@ impl Sprites {
|
||||
.transpose()?;
|
||||
let count = frames.len() as u32;
|
||||
let layer = u32::try_from(self.native.len())?;
|
||||
let pbr = match name.split_once(':') {
|
||||
Some((namespace, path)) if labpbr_maps() => {
|
||||
let path = root.join(format!("assets/{namespace}/textures/{path}.png"));
|
||||
["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count))
|
||||
}
|
||||
_ => [None, None],
|
||||
};
|
||||
let settings = crate::mipmap::Settings {
|
||||
native_size: side,
|
||||
strategy,
|
||||
@@ -364,10 +460,15 @@ impl Sprites {
|
||||
item: name
|
||||
.split_once(':')
|
||||
.is_some_and(|(_, p)| p.starts_with("item/")),
|
||||
dither_cutouts: crate::mipmap::dither_cutouts(),
|
||||
};
|
||||
let still = (animation.is_none() && frames.len() == 1).then(|| {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut maps = std::collections::hash_map::DefaultHasher::new();
|
||||
pbr.hash(&mut maps);
|
||||
(
|
||||
frames[0].clone(),
|
||||
maps.finish(),
|
||||
side,
|
||||
[
|
||||
settings.strategy as u32,
|
||||
@@ -401,12 +502,66 @@ impl Sprites {
|
||||
self.mips.push(settings);
|
||||
self.opaque.push(opaque);
|
||||
self.translucent.push(translucent);
|
||||
self.pbr.push(pbr);
|
||||
self.layers.insert(name.to_owned(), layer);
|
||||
self.native.push((side, frames, initial));
|
||||
Ok(layer)
|
||||
}
|
||||
}
|
||||
|
||||
/// A PNG's width, height and pixels as RGBA.
|
||||
fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info()?;
|
||||
let mut bytes = vec![
|
||||
0;
|
||||
reader
|
||||
.output_buffer_size()
|
||||
.ok_or("sprite exceeds decoder limits")?
|
||||
];
|
||||
let info = reader.next_frame(&mut bytes)?;
|
||||
let channels = info.color_type.samples();
|
||||
let mut pixels = Vec::with_capacity(info.width as usize * info.height as usize * 4);
|
||||
for pixel in bytes[..info.buffer_size()].chunks_exact(channels) {
|
||||
let rgba = match info.color_type {
|
||||
png::ColorType::Rgba => [pixel[0], pixel[1], pixel[2], pixel[3]],
|
||||
png::ColorType::Rgb => [pixel[0], pixel[1], pixel[2], 255],
|
||||
png::ColorType::Grayscale => [pixel[0], pixel[0], pixel[0], 255],
|
||||
png::ColorType::GrayscaleAlpha => [pixel[0], pixel[0], pixel[0], pixel[1]],
|
||||
_ => return Err("unsupported expanded sprite format".into()),
|
||||
};
|
||||
pixels.extend(rgba);
|
||||
}
|
||||
Ok((info.width, info.height, pixels))
|
||||
}
|
||||
|
||||
/// The LabPBR map with `suffix` beside the texture at `path`, whose frames lie in a column of
|
||||
/// `count`: its first frame, square, no larger than the maps' array allows any game's
|
||||
/// textures (a 512x pack's maps would otherwise fill the memory while loading).
|
||||
fn pbr_map(path: &Path, suffix: &str, count: u32) -> Option<(u32, Vec<u8>)> {
|
||||
let stem = path.file_stem()?.to_str()?;
|
||||
let map = path.with_file_name(format!("{stem}{suffix}.png"));
|
||||
let (map_width, map_height, pixels) = read_rgba(&map).ok()?;
|
||||
let (w, h) = (map_width, map_height / count.max(1));
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
let side = w.max(h);
|
||||
let mut frame = Vec::with_capacity((side * side * 4) as usize);
|
||||
for row in 0..side {
|
||||
for column in 0..side {
|
||||
let offset = (((row * h / side) * map_width + column * w / side) * 4) as usize;
|
||||
frame.extend_from_slice(&pixels[offset..offset + 4]);
|
||||
}
|
||||
}
|
||||
Some(shrink_map(
|
||||
frame,
|
||||
side,
|
||||
side_within(side, LAYERS_AT_LEAST, MAX_MAP_BYTES),
|
||||
))
|
||||
}
|
||||
|
||||
/// SpriteContents: the `.mcmeta` `texture` section (TextureMetadataSection's
|
||||
/// mipmap strategy and alpha cutoff bias).
|
||||
fn texture_section(
|
||||
@@ -452,6 +607,54 @@ pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, avail
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn the_texture_budget_picks_the_largest_side_that_fits() {
|
||||
// 1300 layers: vanilla's count, about.
|
||||
assert_eq!(side_within(512, 1300, DEFAULT_TEXTURE_BUDGET), 128);
|
||||
assert_eq!(side_within(512, 1300, 1536 << 20), 512);
|
||||
assert_eq!(side_within(64, 1300, 1536 << 20), 64);
|
||||
// Never under vanilla's size, whatever the budget.
|
||||
assert_eq!(side_within(512, 1300, 1), SPRITE_SIZE);
|
||||
}
|
||||
#[test]
|
||||
fn a_labpbr_map_shrinks_by_plain_means_and_a_layer_without_one_gets_the_default() {
|
||||
// Alpha is data here: it does not weigh the other channels.
|
||||
let frame = [
|
||||
[200, 100, 0, 255],
|
||||
[0, 0, 0, 0],
|
||||
[200, 100, 0, 255],
|
||||
[0, 0, 0, 0],
|
||||
]
|
||||
.concat();
|
||||
assert_eq!(shrink_map(frame, 2, 1), (1, vec![100, 50, 0, 128]));
|
||||
let mut sprites = Sprites {
|
||||
native: vec![
|
||||
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
|
||||
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
|
||||
],
|
||||
pbr: vec![
|
||||
[Some((16, [9, 8, 7, 6].repeat(16 * 16))), None],
|
||||
[None, None],
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
sprites.finish();
|
||||
assert_eq!(sprites.pbr_size, 16);
|
||||
assert_eq!(sprites.normals[..4], [9, 8, 7, 6]);
|
||||
assert_eq!(sprites.normals[16 * 16 * 4..][..4], FLAT_NORMAL);
|
||||
assert_eq!(sprites.specular[..4], NO_SPECULAR);
|
||||
assert_eq!(sprites.specular.len(), 2 * 16 * 16 * 4);
|
||||
}
|
||||
#[test]
|
||||
fn textures_without_labpbr_maps_keep_none() {
|
||||
let mut sprites = Sprites {
|
||||
native: vec![(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0)],
|
||||
pbr: vec![[None, None]],
|
||||
..Default::default()
|
||||
};
|
||||
sprites.finish();
|
||||
assert!(sprites.normals.is_empty() && sprites.specular.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn mipmap_option_keeps_real_16px_reductions() {
|
||||
for levels in 0..=4 {
|
||||
assert_eq!(sprite_mipmap_limit(levels, 32, 16, 6), levels + 1);
|
||||
|
||||
@@ -215,6 +215,8 @@ enum Job {
|
||||
/// Proto chunks checked out to be saved (`ChunkGenerator::save_protos`),
|
||||
/// off the main thread like vanilla's IOWorker.
|
||||
Save(Vec<((i32, i32), Proto)>),
|
||||
/// A proto chunk checked out for its light view (`light_region`).
|
||||
LightView(i32, i32, Proto),
|
||||
/// Meshes the renderer is done with (`Streaming::free`): their memory
|
||||
/// is given back here, not in the frame.
|
||||
Free(Vec<((i32, i32, i32), SectionMesh)>),
|
||||
@@ -235,6 +237,8 @@ pub enum Done {
|
||||
Lit(i32, i32, Vec<crate::chunk::Light>, u64),
|
||||
Meshed(i32, i32, i32, SectionMesh, u64),
|
||||
Saved(Vec<((i32, i32), Proto)>),
|
||||
/// A proto chunk back from `Job::LightView`, with its view.
|
||||
LightView(i32, i32, Proto, Option<Chunk>),
|
||||
}
|
||||
struct Queued {
|
||||
priority: i64,
|
||||
@@ -279,6 +283,8 @@ struct Shared {
|
||||
/// Workers that have freed their buffers ([`IDLE_RELEASE`]) and taken no
|
||||
/// job since: the last of them gives the freed memory back to the OS.
|
||||
released: AtomicUsize,
|
||||
/// The workers that started.
|
||||
workers: AtomicUsize,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct MeshPool {
|
||||
@@ -352,7 +358,6 @@ pub struct Streaming {
|
||||
/// Columns generated and saved as they were left behind: new when they
|
||||
/// next arrive, though read from the save (`World::loaded_chunks`).
|
||||
dropped_new: FxHashSet<(i32, i32)>,
|
||||
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
|
||||
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
|
||||
threads: Vec<thread::JoinHandle<()>>,
|
||||
order: u64,
|
||||
@@ -377,6 +382,8 @@ pub struct Streaming {
|
||||
decorating_finals: HashSet<(i32, i32)>,
|
||||
/// Save jobs in flight.
|
||||
saving: usize,
|
||||
/// Proto chunks checked out to a worker for their light view.
|
||||
viewing: FxHashSet<(i32, i32)>,
|
||||
/// Vanilla's decoration order around the player, and the centre and
|
||||
/// radius it was last ranked for.
|
||||
decoration_order: DecorationOrder,
|
||||
@@ -618,8 +625,8 @@ impl Streaming {
|
||||
done: Mailbox::default(),
|
||||
meshes: Mutex::new(MeshPool::default()),
|
||||
released: AtomicUsize::new(0),
|
||||
workers: AtomicUsize::new(0),
|
||||
});
|
||||
let workers = threads.max(1);
|
||||
let light_tables = context.light.clone();
|
||||
let saves: Arc<dyn Saves> = generator.clone();
|
||||
let threads = (0..threads.max(1))
|
||||
@@ -673,7 +680,7 @@ impl Streaming {
|
||||
// runs short: once every worker has freed its own,
|
||||
// hand them back.
|
||||
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
|
||||
== workers
|
||||
== shared.workers.load(Ordering::Relaxed)
|
||||
{
|
||||
// No worker needs retained mesh capacity now.
|
||||
// Drop it outside the pool lock before asking
|
||||
@@ -732,6 +739,10 @@ impl Streaming {
|
||||
generator.save_protos(&refs);
|
||||
Done::Saved(protos)
|
||||
}
|
||||
Job::LightView(x, z, proto) => {
|
||||
let view = generator.light_view(&proto);
|
||||
Done::LightView(x, z, proto, view)
|
||||
}
|
||||
Job::Free(meshes) => {
|
||||
for (_, mesh) in meshes {
|
||||
let rejected = shared.meshes.lock().unwrap().put(mesh);
|
||||
@@ -780,20 +791,22 @@ impl Streaming {
|
||||
shared.done.send(done);
|
||||
}
|
||||
})
|
||||
.expect("spawn worker")
|
||||
})
|
||||
.collect();
|
||||
let light_view = {
|
||||
let generator = generator.clone();
|
||||
Arc::new(move |p: &Proto| generator.light_view(p))
|
||||
};
|
||||
// A thread the system refuses (a container's process limit reached) leaves the pool a
|
||||
// worker short: a server that panicked here went down with its players' moves unsaved.
|
||||
.filter_map(|spawned| {
|
||||
spawned
|
||||
.inspect_err(|e| log::error!("Horizon: chunk worker not started: {e}"))
|
||||
.ok()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
shared.workers.store(threads.len(), Ordering::Relaxed);
|
||||
let non_air_sections = {
|
||||
let generator = generator.clone();
|
||||
Arc::new(move |p: &Proto| generator.non_air_sections(p))
|
||||
};
|
||||
Self {
|
||||
shared,
|
||||
light_view,
|
||||
non_air_sections,
|
||||
threads,
|
||||
order: 0,
|
||||
@@ -808,6 +821,7 @@ impl Streaming {
|
||||
decorating: 0,
|
||||
decorating_finals: HashSet::new(),
|
||||
saving: 0,
|
||||
viewing: Default::default(),
|
||||
decoration_order: DecorationOrder::default(),
|
||||
order_area: None,
|
||||
queue_basis: Default::default(),
|
||||
@@ -1092,28 +1106,42 @@ impl Streaming {
|
||||
/// The 3x3 a light job reads: the finished columns, and for a neighbour
|
||||
/// that will not finish (past the area), its decorated proto chunk, as
|
||||
/// vanilla lights its last FULL ring against neighbours at FEATURES.
|
||||
/// A proto neighbour without its view yet is checked out to a worker,
|
||||
/// which builds the view off the server thread (`Job::LightView`); the
|
||||
/// region is asked for again once it is back.
|
||||
fn light_region(&mut self, world: &World, x: i32, z: i32) -> Option<Box<Region>> {
|
||||
let mut region: Region = Default::default();
|
||||
let mut complete = true;
|
||||
for dz in 0..3 {
|
||||
for dx in 0..3 {
|
||||
let n = (x + dx as i32 - 1, z + dz as i32 - 1);
|
||||
region[dz][dx] = Some(match world.chunks.get(&n) {
|
||||
Some(chunk) => chunk.clone(),
|
||||
region[dz][dx] = match world.chunks.get(&n) {
|
||||
Some(chunk) => Some(chunk.clone()),
|
||||
None if !self.in_range(n.0, n.1, 1) && self.decorated.contains(&n) => {
|
||||
if let Some(view) = self.light_views.get(&n) {
|
||||
view.clone()
|
||||
Some(view.clone())
|
||||
} else {
|
||||
let proto = self.protos.get(&n)?.as_ref()?;
|
||||
let view = Arc::new((self.light_view)(proto)?);
|
||||
self.light_views.insert(n, view.clone());
|
||||
view
|
||||
let proto = self.protos.get_mut(&n)?.take()?;
|
||||
self.viewing.insert(n);
|
||||
self.submit(Some((x, z)), 0, Job::LightView(n.0, n.1, proto));
|
||||
complete = false;
|
||||
None
|
||||
}
|
||||
}
|
||||
None => return None,
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
Some(Box::new(region))
|
||||
complete.then(|| Box::new(region))
|
||||
}
|
||||
/// A proto chunk back from its light view: the view waits in
|
||||
/// `light_views` for the light jobs that asked for it.
|
||||
fn receive_light_view(&mut self, x: i32, z: i32, proto: Proto, view: Option<Chunk>) {
|
||||
self.viewing.remove(&(x, z));
|
||||
self.protos.insert((x, z), Some(proto));
|
||||
if let Some(view) = view {
|
||||
self.light_views.insert((x, z), Arc::new(view));
|
||||
}
|
||||
}
|
||||
/// Whether `light_region` would find all nine columns: the same walk
|
||||
/// without building the snapshot, so the scheduler can rank the ready
|
||||
@@ -1201,6 +1229,7 @@ impl Streaming {
|
||||
pub fn busy(&self) -> bool {
|
||||
!(self.generating.is_empty()
|
||||
&& self.decorating == 0
|
||||
&& self.viewing.is_empty()
|
||||
&& self.finishing.is_empty()
|
||||
&& self.lighting.is_empty()
|
||||
&& self.meshing.is_empty()
|
||||
@@ -1264,6 +1293,7 @@ impl Streaming {
|
||||
}
|
||||
Done::Generated(chunk) => self.receive_generated(chunk, world),
|
||||
Done::Saved(protos) => self.receive_saved(protos),
|
||||
Done::LightView(x, z, proto, view) => self.receive_light_view(x, z, proto, view),
|
||||
Done::Lit(x, z, sections, seq) => {
|
||||
if self.lighting.get(&(x, z)) != Some(&seq) {
|
||||
continue;
|
||||
@@ -1822,7 +1852,11 @@ impl Streaming {
|
||||
return;
|
||||
}
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
|
||||
while self.decorating > 0
|
||||
|| !self.finishing.is_empty()
|
||||
|| self.saving > 0
|
||||
|| !self.viewing.is_empty()
|
||||
{
|
||||
let left = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
match self.shared.done.recv_timeout(left) {
|
||||
Some(Done::Decorated(x, z, region, finals, writes)) => {
|
||||
@@ -1830,6 +1864,9 @@ impl Streaming {
|
||||
}
|
||||
Some(Done::Generated(chunk)) => self.receive_generated(chunk, world),
|
||||
Some(Done::Saved(protos)) => self.receive_saved(protos),
|
||||
Some(Done::LightView(x, z, proto, view)) => {
|
||||
self.receive_light_view(x, z, proto, view)
|
||||
}
|
||||
Some(other) => self.deferred.push(other),
|
||||
None => {
|
||||
log::warn!("Horizon: chunk generation did not finish before saving");
|
||||
@@ -1860,13 +1897,18 @@ impl Streaming {
|
||||
}
|
||||
Done::Generated(chunk) => self.receive_generated(chunk, world),
|
||||
Done::Saved(protos) => self.receive_saved(protos),
|
||||
Done::LightView(x, z, proto, view) => self.receive_light_view(x, z, proto, view),
|
||||
other => self.deferred.push(other),
|
||||
}
|
||||
if started.elapsed() >= std::time::Duration::from_millis(2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
|
||||
if self.decorating > 0
|
||||
|| !self.finishing.is_empty()
|
||||
|| self.saving > 0
|
||||
|| !self.viewing.is_empty()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let written: Vec<_> = self.written.drain().collect();
|
||||
@@ -1940,7 +1982,11 @@ impl Streaming {
|
||||
counts[1] += 1;
|
||||
} else {
|
||||
counts[2] += 1;
|
||||
if self.light_region(world, key.0, key.1).is_some() {
|
||||
if self.light_region(world, key.0, key.1).is_some()
|
||||
|| (-1..=1).any(|dz| {
|
||||
(-1..=1).any(|dx| self.viewing.contains(&(key.0 + dx, key.1 + dz)))
|
||||
})
|
||||
{
|
||||
world.dirty_light.insert(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,25 @@ thread_local! {
|
||||
static TARGETS: RefCell<(Vec<Target>, Vec<Target>, u64)> = const { RefCell::new((Vec::new(), Vec::new(), 0)) };
|
||||
}
|
||||
|
||||
/// Splitscreen: one player's buttons and focus targets, kept aside while another player's game
|
||||
/// runs on this thread ([`take`] and [`put`] around each turn).
|
||||
#[derive(Default)]
|
||||
pub struct Saved(
|
||||
(Vec<Widget>, Vec<Widget>, u64),
|
||||
(Vec<Target>, Vec<Target>, u64),
|
||||
);
|
||||
|
||||
/// This thread's buttons and targets, leaving none.
|
||||
pub fn take() -> Saved {
|
||||
Saved(WIDGETS.take(), TARGETS.take())
|
||||
}
|
||||
|
||||
/// Bring back what [`take`] kept.
|
||||
pub fn put(saved: Saved) {
|
||||
WIDGETS.set(saved.0);
|
||||
TARGETS.set(saved.1);
|
||||
}
|
||||
|
||||
/// An interactive rectangle (GUI pixels) a controller can focus and click.
|
||||
pub fn target(rect: [f32; 4]) {
|
||||
target_as(rect, Kind::Button);
|
||||
|
||||
@@ -83,6 +83,13 @@ open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
|
||||
# on the PATH.
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
|
||||
# `above_menu_bar`); all already in the tree through winit and wgpu.
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
|
||||
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
|
||||
# DirectX 12 driver (`graphicsApi`, graphics.rs).
|
||||
|
||||
@@ -5,6 +5,15 @@ fn main() {
|
||||
let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
|
||||
let ico = format!("{dir}/../../packaging/icons/horizon.ico");
|
||||
println!("cargo:rerun-if-changed={ico}");
|
||||
// The game's main thread gets 8 MB of stack on Windows as on macOS and Linux (Windows gives 1
|
||||
// MB): splitscreen builds a joining player's `Game`, ~67 KB, deep in the event loop.
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
let arg = match std::env::var("CARGO_CFG_TARGET_ENV").as_deref() {
|
||||
Ok("msvc") => "/STACK:8388608",
|
||||
_ => "-Wl,--stack,8388608",
|
||||
};
|
||||
println!("cargo:rustc-link-arg-bin=horizon-client={arg}");
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
let mut resource = winresource::WindowsResource::new();
|
||||
|
||||
@@ -291,6 +291,8 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
for d in items {
|
||||
let _ = match d {
|
||||
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
|
||||
// The fixture's mobs keep no whole stacks.
|
||||
drops::Drop::Stack(s) => writeln!(out, "drop {} {}", s.item, s.count),
|
||||
drops::Drop::CarriedBlock(state) => {
|
||||
let stacks = enderman::carried_block_drops(
|
||||
&block_items,
|
||||
|
||||
@@ -1315,7 +1315,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
continue;
|
||||
}
|
||||
if let Some(Some((name, volume, pitch))) =
|
||||
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random)
|
||||
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random, None)
|
||||
{
|
||||
events.push(MobEvent::Sound(name, [0.0; 3], volume, pitch));
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
))?;
|
||||
let parse_ms = parse_started.elapsed().as_secs_f64() * 1000.0;
|
||||
let facts_started = Instant::now();
|
||||
let (facts, gameplay) = BlockFacts::load_with_projection(root, assets.registry, &json)?;
|
||||
let (facts, gameplay) =
|
||||
BlockFacts::load_with_projection(root, assets.registry, &json, true)?;
|
||||
let facts_ms = facts_started.elapsed().as_secs_f64() * 1000.0;
|
||||
let behaviours_started = Instant::now();
|
||||
let behaviours = Behaviours::load_with_gameplay(assets.registry, root, &json, &gameplay)?;
|
||||
|
||||
@@ -1071,6 +1071,15 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
}
|
||||
effects.advance(step);
|
||||
let name = &*self.scene.facts.get(state).name;
|
||||
crate::block_contact::emit(crate::block_contact::Event::Inside(
|
||||
crate::block_contact::Contact {
|
||||
entity: self.p.id as i32,
|
||||
kind: self.p.kind.id(),
|
||||
block: name,
|
||||
state,
|
||||
position: p,
|
||||
},
|
||||
));
|
||||
if matches!(name, "minecraft:fire" | "minecraft:soul_fire") {
|
||||
// BaseFireBlock.entityInside (its inFire hurt does nothing to arrows).
|
||||
effects.apply(Effect::ClearFreeze);
|
||||
|
||||
@@ -92,9 +92,7 @@ impl Banners {
|
||||
});
|
||||
}
|
||||
banners.no_item_required = read_tag(root, "no_item_required", 0);
|
||||
if let Some(raw) = fs::read(root.join("items.json")).ok().and_then(|b| {
|
||||
serde_json::from_slice::<std::collections::BTreeMap<String, Value>>(&b).ok()
|
||||
}) {
|
||||
if let Ok(raw) = horizon_world::item_defaults::read(root) {
|
||||
for (item, c) in raw {
|
||||
let id: Arc<str> = Arc::from(item.as_str());
|
||||
if let Some(dye) = c["minecraft:dye"].as_str() {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Synchronous mod-block contacts from the server's simulation code.
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
cell::Cell,
|
||||
sync::{
|
||||
OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Contact<'a> {
|
||||
pub entity: i32,
|
||||
pub kind: &'a str,
|
||||
pub block: &'a str,
|
||||
pub state: BlockStateId,
|
||||
pub position: BlockPos,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Event<'a> {
|
||||
StepOn(Contact<'a>),
|
||||
Inside(Contact<'a>),
|
||||
FallOn(Contact<'a>, f64),
|
||||
}
|
||||
|
||||
pub static HOOK: OnceLock<fn(&str, Event<'_>, &mut f32)> = OnceLock::new();
|
||||
/// Whether the loaded blocks hold one outside `minecraft:` (a content mod's).
|
||||
/// Without one no contact can dispatch, so the item and projectile sweeps
|
||||
/// skip their per-tick work: every item and thrown thing on a server would
|
||||
/// otherwise sweep its blocks each tick for nothing.
|
||||
static MOD_BLOCKS: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Called when the block tables load.
|
||||
pub fn set_mod_blocks(any: bool) {
|
||||
MOD_BLOCKS.store(any, Ordering::Relaxed);
|
||||
}
|
||||
thread_local! {
|
||||
static LEVEL: Cell<Option<&'static str>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Scope authority to this thread and restore it even on an error or panic.
|
||||
/// Nested guest actions may temporarily enter a different dimension.
|
||||
pub fn in_level<R>(dimension: Option<&'static str>, f: impl FnOnce() -> R) -> R {
|
||||
struct Restore(Option<&'static str>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
LEVEL.set(self.0);
|
||||
}
|
||||
}
|
||||
let _restore = Restore(LEVEL.replace(dimension));
|
||||
f()
|
||||
}
|
||||
|
||||
pub fn emit(event: Event<'_>) {
|
||||
dispatch(event, &mut 0.0);
|
||||
}
|
||||
|
||||
/// Damage is the calculated fall damage, before hurt handling and fall sounds.
|
||||
pub fn fall(contact: Contact<'_>, distance: f64, mut damage: f32) -> f32 {
|
||||
dispatch(Event::FallOn(contact, distance), &mut damage);
|
||||
damage
|
||||
}
|
||||
|
||||
fn dispatch(event: Event<'_>, damage: &mut f32) {
|
||||
let contact = match event {
|
||||
Event::StepOn(c) | Event::Inside(c) | Event::FallOn(c, _) => c,
|
||||
};
|
||||
if contact.block.starts_with("minecraft:") {
|
||||
return;
|
||||
}
|
||||
if let Some(dimension) = LEVEL.get()
|
||||
&& let Some(hook) = HOOK.get()
|
||||
{
|
||||
let original = *damage;
|
||||
hook(dimension, event, damage);
|
||||
if !damage.is_finite() {
|
||||
*damage = original;
|
||||
}
|
||||
*damage = damage.max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reuse the entity block sweep for item and projectile ticks without an effect collector.
|
||||
pub fn swept_inside<'a>(
|
||||
entity: i32,
|
||||
kind: &str,
|
||||
from: horizon_core::geometry::Vec3,
|
||||
to: horizon_core::geometry::Vec3,
|
||||
bounds: horizon_core::geometry::Aabb,
|
||||
mut block: impl FnMut(BlockPos) -> (BlockStateId, &'a str),
|
||||
) {
|
||||
if !MOD_BLOCKS.load(Ordering::Relaxed) || LEVEL.get().is_none() || HOOK.get().is_none() {
|
||||
return;
|
||||
}
|
||||
let bounds = bounds.deflate(horizon_core::geometry::Vec3::new(1e-5, 1e-5, 1e-5));
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
let _ =
|
||||
horizon_core::geometry::sweep_intersected_blocks(from, to, bounds, 1_000_000, |p, step| {
|
||||
if step >= 16 {
|
||||
return false;
|
||||
}
|
||||
if visited.insert(p) {
|
||||
let (state, name) = block(p);
|
||||
emit(Event::Inside(Contact {
|
||||
entity,
|
||||
kind,
|
||||
block: name,
|
||||
state,
|
||||
position: p,
|
||||
}));
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
thread_local! {
|
||||
static SEEN: Cell<usize> = const { Cell::new(0) };
|
||||
}
|
||||
fn hook(dimension: &str, event: Event<'_>, damage: &mut f32) {
|
||||
assert_eq!(dimension, "minecraft:the_nether");
|
||||
let c = match event {
|
||||
Event::FallOn(c, distance) => {
|
||||
assert_eq!(distance, 12.0);
|
||||
*damage = match *damage {
|
||||
99.0 => f32::NAN,
|
||||
98.0 => -1.0,
|
||||
_ => *damage * 0.2,
|
||||
};
|
||||
c
|
||||
}
|
||||
Event::Inside(c) | Event::StepOn(c) => c,
|
||||
};
|
||||
assert_eq!(
|
||||
(c.entity, c.kind, c.state, c.position),
|
||||
(
|
||||
17,
|
||||
"minecraft:zombie",
|
||||
BlockStateId(9),
|
||||
BlockPos::new(1, 2, 3)
|
||||
)
|
||||
);
|
||||
SEEN.set(SEEN.get() + 1);
|
||||
}
|
||||
#[test]
|
||||
fn contacts_require_server_scope_and_mod_blocks_and_restore_nested_levels() {
|
||||
HOOK.set(hook).unwrap();
|
||||
let c = Contact {
|
||||
entity: 17,
|
||||
kind: "minecraft:zombie",
|
||||
block: "test:hay",
|
||||
state: BlockStateId(9),
|
||||
position: BlockPos::new(1, 2, 3),
|
||||
};
|
||||
assert_eq!(fall(c, 12.0, 10.0), 10.0);
|
||||
in_level(Some("minecraft:the_nether"), || {
|
||||
emit(Event::StepOn(c));
|
||||
emit(Event::Inside(c));
|
||||
assert_eq!(fall(c, 12.0, 10.0), 2.0);
|
||||
in_level(None, || assert_eq!(fall(c, 12.0, 10.0), 10.0));
|
||||
assert_eq!(fall(c, 12.0, 0.0), 0.0);
|
||||
emit(Event::Inside(Contact {
|
||||
block: "minecraft:hay_block",
|
||||
..c
|
||||
}));
|
||||
});
|
||||
assert_eq!(fall(c, 12.0, 10.0), 10.0);
|
||||
assert_eq!(SEEN.get(), 4);
|
||||
in_level(Some("minecraft:the_nether"), || {
|
||||
assert_eq!(fall(c, 12.0, 99.0), 99.0);
|
||||
assert_eq!(fall(c, 12.0, 98.0), 0.0);
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
let to = Vec3::new(2.5, 2.0, 3.5);
|
||||
let bounds = Aabb::new(Vec3::new(2.25, 2.0, 3.25), Vec3::new(2.75, 2.5, 3.75));
|
||||
let block = |p| {
|
||||
(
|
||||
BlockStateId(9),
|
||||
if p == c.position {
|
||||
"test:hay"
|
||||
} else {
|
||||
"minecraft:air"
|
||||
},
|
||||
)
|
||||
};
|
||||
set_mod_blocks(true);
|
||||
swept_inside(17, c.kind, Vec3::new(0.5, 2.0, 3.5), to, bounds, block);
|
||||
assert_eq!(SEEN.get(), 7, "a block crossed mid-move is notified once");
|
||||
let at = Vec3::new(1.5, 2.0, 3.5);
|
||||
let bounds = bounds.move_by(Vec3::new(-1.0, 0.0, 0.0));
|
||||
swept_inside(17, c.kind, at, at, bounds, block);
|
||||
assert_eq!(SEEN.get(), 8, "stationary overlap still notifies");
|
||||
});
|
||||
let _ =
|
||||
std::panic::catch_unwind(|| in_level(Some("minecraft:the_nether"), || panic!("test")));
|
||||
assert_eq!(fall(c, 12.0, 10.0), 10.0);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Identity of the ordered block-state registry used by chunk packets, and of
|
||||
//! the item, mob effect and entity type registries once content mods add to
|
||||
//! them (item stacks, effect and entity packets carry their network ids).
|
||||
//! the item, mob effect, entity type and menu registries once content mods
|
||||
//! add to them (item stacks, effect, entity and open screen packets carry
|
||||
//! their network ids).
|
||||
use horizon_protocol::registry::Builtin;
|
||||
use serde::{Deserialize, Serialize, de::IgnoredAny};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -77,14 +78,13 @@ pub fn identity(root: &Path) -> Result<[u8; 32], IdentityError> {
|
||||
if !components.is_empty() {
|
||||
hash = hash_entries(hash, "horizon:nbt_components/v1", &components);
|
||||
// Defaults are not sent in a stack patch. Both peers must therefore
|
||||
// agree on their values as well as the registered wire IDs.
|
||||
let bytes = std::fs::read(root.join("items.json")).map_err(IdentityError::Read)?;
|
||||
let items: BTreeMap<String, BTreeMap<String, serde_json::Value>> =
|
||||
serde_json::from_slice(&bytes).map_err(IdentityError::Json)?;
|
||||
// agree on their values as well as the registered wire IDs, native
|
||||
// mods' changes included.
|
||||
let items = horizon_world::item_defaults::read(root).map_err(IdentityError::Read)?;
|
||||
let mut defaults = Vec::new();
|
||||
for (item, components) in items {
|
||||
for (name, value) in components {
|
||||
if horizon_protocol::item::custom::id(&name).is_some() {
|
||||
for (name, value) in components.as_object().into_iter().flatten() {
|
||||
if horizon_protocol::item::custom::id(name).is_some() {
|
||||
defaults.push((defaults.len() as u64, format!("{item}\0{name}\0{value}")));
|
||||
}
|
||||
}
|
||||
@@ -109,10 +109,11 @@ fn hash_facts(bytes: &[u8]) -> Result<[u8; 32], IdentityError> {
|
||||
}
|
||||
|
||||
/// The built-in registries whose network ids content mods extend besides blocks.
|
||||
const REGISTRIES: [&str; 3] = [
|
||||
const REGISTRIES: [&str; 4] = [
|
||||
"minecraft:item",
|
||||
"minecraft:mob_effect",
|
||||
"minecraft:entity_type",
|
||||
"minecraft:menu",
|
||||
];
|
||||
|
||||
fn parse_registries(bytes: &[u8]) -> Result<Builtin, IdentityError> {
|
||||
@@ -181,6 +182,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// a mod menu type in the report changes the identity, so a client
|
||||
/// without it is refused before it could be sent an id it lacks.
|
||||
#[test]
|
||||
fn a_mod_menu_type_joins_the_identity() {
|
||||
let root = |name: &str, extra: &str| {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-content-identity-menu-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let facts = br#"{"states":[["minecraft:stone",false,false,"Block{minecraft:stone}",{"id":"minecraft:stone"}]]}"#;
|
||||
std::fs::write(root.join("facts.json"), facts).unwrap();
|
||||
let report = format!(
|
||||
r#"{{"minecraft:menu":{{"entries":{{"minecraft:generic_9x1":{{"protocol_id":0}}{extra}}}}}}}"#
|
||||
);
|
||||
std::fs::write(root.join("registries.json"), report).unwrap();
|
||||
let hash = identity(&root).unwrap();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
hash
|
||||
};
|
||||
let vanilla = root("vanilla", "");
|
||||
let press = root("press", r#","ruby:press":{"protocol_id":25}"#);
|
||||
assert_ne!(press, vanilla);
|
||||
assert_ne!(press, root("mill", r#","ruby:mill":{"protocol_id":25}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_without_mod_entries_keeps_its_block_identity() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
|
||||
@@ -44,10 +44,73 @@ impl Ingredient {
|
||||
match v {
|
||||
Value::String(s) => add(s),
|
||||
Value::Array(a) => a.iter().filter_map(Value::as_str).for_each(&mut add),
|
||||
Value::Object(o) => return Self::parse_object(o, items),
|
||||
_ => return None,
|
||||
}
|
||||
(!list.is_empty()).then(|| Self::of(list))
|
||||
}
|
||||
/// NeoForge's ingredient types (`NeoForgeRegistries.Keys.INGREDIENT_TYPES`),
|
||||
/// the standard shapes mods' recipes carry, each resolved to the flat item
|
||||
/// list it matches. A kind outside these still fails the file: a mod's
|
||||
/// own ingredient types come with their own hook, not the loader.
|
||||
fn parse_object(o: &serde_json::Map<String, Value>, items: &Items) -> Option<Self> {
|
||||
// Current NeoForge marks a custom ingredient with
|
||||
// `neoforge:ingredient_type`; `type` is the older packs' key.
|
||||
let kind = o
|
||||
.get("neoforge:ingredient_type")
|
||||
.or_else(|| o.get("type"))?
|
||||
.as_str()?;
|
||||
let children = || {
|
||||
o.get("children")
|
||||
.or_else(|| o.get("ingredients"))
|
||||
.and_then(Value::as_array)
|
||||
};
|
||||
let kept = match kind {
|
||||
// `CompoundIngredient`: any of its children matching the stack
|
||||
// is enough — the union of their items.
|
||||
"neoforge:compound" => {
|
||||
let subs: Vec<Self> = children()?
|
||||
.iter()
|
||||
.map(|s| Self::parse(s, items))
|
||||
.collect::<Option<_>>()?;
|
||||
let mut seen = Vec::new();
|
||||
for sub in &subs {
|
||||
for item in &sub.1 {
|
||||
if !seen.contains(item) {
|
||||
seen.push(item.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
// `IntersectionIngredient`: every child must match the same
|
||||
// stack — the items all of them name.
|
||||
"neoforge:intersection" => {
|
||||
let subs: Vec<Self> = children()?
|
||||
.iter()
|
||||
.map(|s| Self::parse(s, items))
|
||||
.collect::<Option<_>>()?;
|
||||
subs.first()?
|
||||
.1
|
||||
.iter()
|
||||
.filter(|i| subs[1..].iter().all(|s| s.0.contains(*i)))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
// The base's items without the subtracted's.
|
||||
"neoforge:difference" => {
|
||||
let base = Self::parse(o.get("base")?, items)?;
|
||||
let gone = Self::parse(o.get("subtracted")?, items)?;
|
||||
base.1
|
||||
.iter()
|
||||
.filter(|i| !gone.0.contains(*i))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
(!kept.is_empty()).then(|| Self::of(kept))
|
||||
}
|
||||
pub fn test(&self, stack: &ItemStack) -> bool {
|
||||
self.0.contains(&*stack.item)
|
||||
}
|
||||
@@ -1252,6 +1315,18 @@ impl Recipes {
|
||||
let mut meta = HashMap::new();
|
||||
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
|
||||
let mut sources: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
// NeoForge's load conditions: `mod_loaded` asks which mods' data is
|
||||
// in this root (`minecraft`, always, and the content mods').
|
||||
// `mod_loaded` asks which mods are loaded, not which namespaces
|
||||
// they own: a mod that also owns a namespace doesn't make that
|
||||
// namespace another mod.
|
||||
let mods: Vec<String> = {
|
||||
let mut mods = horizon_items::content_namespaces::mod_ids(root);
|
||||
if !mods.iter().any(|m| m == "minecraft") {
|
||||
mods.push("minecraft".to_owned());
|
||||
}
|
||||
mods
|
||||
};
|
||||
for (name, p) in recipe_files(&root.join("data/minecraft/recipe"))? {
|
||||
sources.push((name, fs::read(&p)?));
|
||||
}
|
||||
@@ -1291,6 +1366,13 @@ impl Recipes {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// A file whose load conditions don't hold is silently absent,
|
||||
// as NeoForge's ICondition leaves it out of the manager.
|
||||
if !horizon_world::conditions::conditions_hold(&v, &|id: &str| {
|
||||
mods.iter().any(|m| m == id)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
if let Some(kind) = custom_type(&v) {
|
||||
let id = if name.contains(':') {
|
||||
name
|
||||
@@ -1759,6 +1841,99 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A mod's recipe whose ingredient is one of NeoForge's standard shapes
|
||||
/// loads and carries the resolved items, where the plain string parser
|
||||
/// used to drop the file whole.
|
||||
#[test]
|
||||
fn a_neoforge_ingredient_recipe_loads() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-neo-ingredient-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
|
||||
fs::create_dir_all(root.join("data/mymod/recipe")).unwrap();
|
||||
fs::write(
|
||||
root.join(horizon_items::content_namespaces::MARKER),
|
||||
br#"["mymod"]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let recipe = r##"{
|
||||
"type": "minecraft:crafting_shapeless",
|
||||
"ingredients": [
|
||||
{"neoforge:ingredient_type": "neoforge:compound",
|
||||
"children": ["minecraft:oak_planks", "minecraft:birch_planks"]},
|
||||
{"type": "neoforge:difference",
|
||||
"base": ["minecraft:oak_planks", "minecraft:birch_planks"],
|
||||
"subtracted": "minecraft:birch_planks"}
|
||||
],
|
||||
"result": {"id": "minecraft:stick", "count": 4}
|
||||
}"##;
|
||||
fs::write(root.join("data/mymod/recipe/treated.json"), recipe).unwrap();
|
||||
let items = Items::for_tests(HashMap::new());
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
|
||||
assert_eq!(recipes.crafting.len(), 1);
|
||||
assert_eq!(recipes.crafting[0].0, "mymod:treated");
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// NeoForge's standard ingredient shapes resolve to their flat item
|
||||
/// lists; a kind the loader doesn't know still fails the file.
|
||||
#[test]
|
||||
fn neoforge_ingredient_shapes_parse() {
|
||||
let mut tags = HashMap::new();
|
||||
tags.insert(
|
||||
"minecraft:planks".to_owned(),
|
||||
["minecraft:oak_planks", "minecraft:birch_planks"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<HashSet<_>>(),
|
||||
);
|
||||
let items = Items::for_tests(tags);
|
||||
let json = |s: &str| serde_json::from_str::<Value>(s).unwrap();
|
||||
// Compound (`CompoundIngredient`): any child matching is enough —
|
||||
// the union of their items, a tag beside an item gives both.
|
||||
let union = Ingredient::parse(
|
||||
&json(r##"{"neoforge:ingredient_type":"neoforge:compound","children":["#minecraft:planks","minecraft:stone"]}"##),
|
||||
&items,
|
||||
)
|
||||
.unwrap();
|
||||
// The tag's items come from a HashSet: their order is not fixed.
|
||||
let mut all = union.1.clone();
|
||||
all.sort();
|
||||
assert_eq!(
|
||||
all,
|
||||
[
|
||||
"minecraft:birch_planks",
|
||||
"minecraft:oak_planks",
|
||||
"minecraft:stone"
|
||||
]
|
||||
);
|
||||
// Intersection: every child must match — only the items all name.
|
||||
let both = Ingredient::parse(
|
||||
&json(r##"{"type":"neoforge:intersection","children":["#minecraft:planks","minecraft:oak_planks"]}"##),
|
||||
&items,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(both.1, ["minecraft:oak_planks"]);
|
||||
// Difference: the base without the subtracted.
|
||||
let no_oak = Ingredient::parse(
|
||||
&json(r##"{"type":"neoforge:difference","base":"#minecraft:planks","subtracted":"minecraft:oak_planks"}"##),
|
||||
&items,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(no_oak.1, ["minecraft:birch_planks"]);
|
||||
// An empty intersection and an unknown kind fail the file, as today.
|
||||
let empty = Ingredient::parse(
|
||||
&json(
|
||||
r#"{"type":"neoforge:intersection","children":["minecraft:oak_planks","minecraft:birch_planks"]}"#,
|
||||
),
|
||||
&items,
|
||||
);
|
||||
assert!(empty.is_none());
|
||||
let unknown =
|
||||
Ingredient::parse(&json(r#"{"type":"mymod:aspect","aspect":"fire"}"#), &items);
|
||||
assert!(unknown.is_none());
|
||||
}
|
||||
|
||||
fn ingredient(items: &[&str]) -> Ingredient {
|
||||
Ingredient::of(items.iter().map(|s| (*s).to_owned()).collect())
|
||||
}
|
||||
@@ -1928,6 +2103,66 @@ mod tests {
|
||||
assert_eq!((&*made.item, made.count), ("minecraft:stick", 1));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A recipe whose `neoforge:conditions` hold loads, one whose don't is
|
||||
/// silently absent, from the root's own files and a pack's alike.
|
||||
#[test]
|
||||
fn load_conditions_gate_recipes() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-conditions-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
|
||||
fs::write(
|
||||
root.join(horizon_items::content_namespaces::MARKER),
|
||||
br#"["mymod"]"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join(horizon_items::content_namespaces::MOD_IDS_MARKER),
|
||||
br#"["mymod"]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let file = |conditions: &str, tag: &str| {
|
||||
format!(
|
||||
r#"{{"type":"mymod:crusher","tag":"{tag}","neoforge:conditions":{conditions}}}"#
|
||||
)
|
||||
.into_bytes()
|
||||
};
|
||||
// `mymod` is in the root, `ic2` is not.
|
||||
fs::write(
|
||||
root.join("data/minecraft/recipe/yes.json"),
|
||||
file(r#"[{"type":"neoforge:mod_loaded","modid":"mymod"}]"#, "yes"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("data/minecraft/recipe/no.json"),
|
||||
file(r#"[{"type":"neoforge:mod_loaded","modid":"ic2"}]"#, "no"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("data/minecraft/recipe/inverted.json"),
|
||||
file(
|
||||
r#"[{"type":"neoforge:not","value":{"type":"neoforge:mod_loaded","modid":"ic2"}}]"#,
|
||||
"inverted",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let items = Items::for_tests(HashMap::new());
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
|
||||
let ids = &recipes.custom["mymod:crusher"];
|
||||
assert_eq!(
|
||||
ids.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
|
||||
["minecraft:inverted", "minecraft:yes"]
|
||||
);
|
||||
// A pack's gated file answers the same gate.
|
||||
let packs = [(
|
||||
"recipe/minecraft:pack_yes".to_owned(),
|
||||
vec![file(r#"[{"type":"neoforge:true"}]"#, "pack_yes")],
|
||||
)];
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay { files: &packs }).unwrap();
|
||||
let ids = &recipes.custom["mymod:crusher"];
|
||||
assert!(ids.iter().any(|r| r.id == "minecraft:pack_yes"));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
#[test]
|
||||
fn cook_finds_the_first_recipe_the_scan_finds() {
|
||||
let recipes = cooking_book();
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! `DebugLevelSource`: the `minecraft:debug` generator of the Debug world preset,
|
||||
//! a grid of every block state floating over a layer of barriers.
|
||||
use crate::{
|
||||
chunk::{Chunk, LevelShape},
|
||||
streaming::ChunkGenerator,
|
||||
};
|
||||
use horizon_world::{nbt::Tag, registry::BlockRegistry};
|
||||
|
||||
/// `DebugLevelSource.HEIGHT`: the layer the block states stand in.
|
||||
pub const HEIGHT: i32 = 70;
|
||||
/// `DebugLevelSource.BARRIER_HEIGHT`.
|
||||
pub const BARRIER_HEIGHT: i32 = 60;
|
||||
|
||||
/// The debug world's generator: every block state in registry order
|
||||
/// (`ALL_BLOCKS`) on a square grid, one every other block, at `HEIGHT`.
|
||||
pub struct DebugTerrain {
|
||||
shape: LevelShape,
|
||||
biome: u16,
|
||||
barrier: u32,
|
||||
/// `ALL_BLOCKS.size()`: block states are numbered in that order, so the
|
||||
/// state at an index is the state with that id.
|
||||
states: u32,
|
||||
}
|
||||
impl DebugTerrain {
|
||||
/// The generator of a world whose overworld is `minecraft:debug`.
|
||||
pub fn from_settings(
|
||||
settings: &Tag,
|
||||
registry: &BlockRegistry,
|
||||
biomes: &crate::biome::Biomes,
|
||||
shape: LevelShape,
|
||||
) -> Option<Self> {
|
||||
let generator = settings
|
||||
.get("dimensions")?
|
||||
.get("minecraft:overworld")?
|
||||
.get("generator")?;
|
||||
if crate::save::text(generator.get("type"))?.trim_start_matches("minecraft:") != "debug" {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
shape,
|
||||
// `FixedBiomeSource(plains)`.
|
||||
biome: biomes.id("minecraft:plains"),
|
||||
barrier: registry.default_state("minecraft:barrier")?.0,
|
||||
states: u32::try_from(registry.len()).ok()?,
|
||||
})
|
||||
}
|
||||
/// `DebugLevelSource.getBlockStateFor`: the state id at a block column
|
||||
/// (air, id 0, where the grid has none).
|
||||
pub fn state_for(&self, x: i32, z: i32) -> u32 {
|
||||
grid_state(self.states, x, z)
|
||||
}
|
||||
}
|
||||
/// `DebugLevelSource.GRID_WIDTH` and `GRID_HEIGHT` for `count` block states:
|
||||
/// `Mth.ceil(Mth.sqrt(size))` and `ceil(size / width)`.
|
||||
fn grid(count: u32) -> (i32, i32) {
|
||||
let width = (count as f32).sqrt().ceil() as i32;
|
||||
let height = (count as f32 / width as f32).ceil() as i32;
|
||||
(width, height)
|
||||
}
|
||||
/// `getBlockStateFor(worldX, worldZ)` as a state id.
|
||||
fn grid_state(count: u32, mut x: i32, mut z: i32) -> u32 {
|
||||
let (width, height) = grid(count);
|
||||
if x > 0 && z > 0 && x % 2 != 0 && z % 2 != 0 {
|
||||
x /= 2;
|
||||
z /= 2;
|
||||
if x <= width && z <= height {
|
||||
let index = (x * width + z).unsigned_abs();
|
||||
if index < count {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
impl ChunkGenerator for DebugTerrain {
|
||||
type Worker = ();
|
||||
fn worker(&self) {}
|
||||
/// `DebugLevelSource.buildTerrain` builds nothing; `applyBiomeDecoration`
|
||||
/// writes the barriers and the grid.
|
||||
fn proto(&self, _: &mut (), cx: i32, cz: i32) -> crate::streaming::Proto {
|
||||
let mut chunk = Chunk::with_layout(cx, cz, self.shape.column());
|
||||
for s in &mut chunk.sections {
|
||||
std::sync::Arc::make_mut(s).biomes = horizon_core::paletted::PalettedContainer::new(
|
||||
crate::chunk::BIOME_STRATEGY,
|
||||
u32::from(self.biome),
|
||||
);
|
||||
}
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
chunk.set(x, BARRIER_HEIGHT, z, self.barrier);
|
||||
let state = self.state_for(cx * 16 + x, cz * 16 + z);
|
||||
if state != 0 {
|
||||
chunk.set(x, HEIGHT, z, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
for s in &mut chunk.sections {
|
||||
std::sync::Arc::make_mut(s).compact();
|
||||
}
|
||||
Box::new(chunk)
|
||||
}
|
||||
/// `spawnOriginalMobs` is empty.
|
||||
fn finish(&self, proto: crate::streaming::Proto) -> Chunk {
|
||||
*proto.downcast::<Chunk>().expect("debug terrain proto")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `getBlockStateFor`: only odd, positive columns hold a state, in the order
|
||||
/// `x / 2 * width + z / 2`, and nothing past the last state.
|
||||
#[test]
|
||||
fn the_grid_holds_a_state_every_other_block() {
|
||||
// 1000 states: a 32 wide grid, 32 high (ceil(1000 / 32) = 32).
|
||||
assert_eq!(grid(1000), (32, 32));
|
||||
assert_eq!(grid(1), (1, 1));
|
||||
assert_eq!(grid(26_000), (162, 161));
|
||||
// Even, zero and negative columns are air.
|
||||
for (x, z) in [(2, 1), (1, 2), (0, 1), (1, 0), (-1, 1), (1, -1)] {
|
||||
assert_eq!(grid_state(1000, x, z), 0, "{x} {z}");
|
||||
}
|
||||
// (1, 1) is index 0 (air), (3, 5) is 1 * 32 + 2 = 34.
|
||||
assert_eq!(grid_state(1000, 1, 1), 0);
|
||||
assert_eq!(grid_state(1000, 3, 5), 34);
|
||||
// x = 2 * 32 + 1 maps to x / 2 = 32: index 32 * 32 + z / 2, past the 1000 states.
|
||||
assert_eq!(grid_state(1000, 65, 1), 0);
|
||||
assert_eq!(grid_state(1000, 61, 3), 30 * 32 + 1);
|
||||
}
|
||||
}
|
||||
@@ -306,6 +306,37 @@ impl ItemEntity {
|
||||
&mut self.velocity,
|
||||
);
|
||||
}
|
||||
if !no_physics {
|
||||
// Entity.applyEffectsFromBlocks: stepOn, then checkInsideBlocks.
|
||||
if self.on_ground {
|
||||
let p = BlockPos::containing(self.position.add_vector(Vec3::new(
|
||||
0.0,
|
||||
-f64::from(0.2_f32),
|
||||
0.0,
|
||||
)));
|
||||
let state = world.block(p.x, p.y, p.z);
|
||||
crate::block_contact::emit(crate::block_contact::Event::StepOn(
|
||||
crate::block_contact::Contact {
|
||||
entity: self.id as i32,
|
||||
kind: "minecraft:item",
|
||||
block: &facts.get(state).name,
|
||||
state,
|
||||
position: p,
|
||||
},
|
||||
));
|
||||
}
|
||||
crate::block_contact::swept_inside(
|
||||
self.id as i32,
|
||||
"minecraft:item",
|
||||
self.previous,
|
||||
self.position,
|
||||
self.bounds(),
|
||||
|p| {
|
||||
let state = world.block(p.x, p.y, p.z);
|
||||
(state, &facts.get(state).name)
|
||||
},
|
||||
);
|
||||
}
|
||||
if self.velocity.subtract(old_velocity).length_squared() > 0.01 {
|
||||
self.needs_sync = true;
|
||||
}
|
||||
@@ -1018,9 +1049,15 @@ pub struct ArmorStand {
|
||||
pub health: f32,
|
||||
pub id: u32,
|
||||
pub position: Vec3,
|
||||
/// `Entity.xo`, `yo`, `zo`: the position at the start of the tick, which
|
||||
/// the renderer blends toward `position` by the partial tick.
|
||||
pub previous: Vec3,
|
||||
pub velocity: Vec3,
|
||||
pub on_ground: bool,
|
||||
/// `yRot`, which the body follows (`ArmorStand.tickHeadTurn`: `yBodyRot`).
|
||||
pub yaw: f32,
|
||||
/// `yBodyRotO`: the body's yaw at the start of the tick.
|
||||
pub previous_yaw: f32,
|
||||
/// mainhand, offhand, feet, legs, chest, head (EquipmentSlot order).
|
||||
pub equipment: [Option<crate::items::ItemStack>; 6],
|
||||
pub small: bool,
|
||||
@@ -1041,6 +1078,9 @@ pub struct ArmorStand {
|
||||
/// rides, which seats it every tick (`positionRider`), as NPC plugins hang
|
||||
/// a second name over a player's head.
|
||||
pub vehicle: Option<u32>,
|
||||
/// `ArmorStand.disabledSlots`: bit `filterFlag` locks a slot, bit
|
||||
/// `filterFlag + 8` takes from it and bit `filterFlag + 16` puts into it.
|
||||
pub disabled_slots: i32,
|
||||
}
|
||||
impl ArmorStand {
|
||||
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
|
||||
@@ -1056,15 +1096,50 @@ impl ArmorStand {
|
||||
[-1.0, 0.0, -1.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
];
|
||||
/// Where the renderer draws the stand `alpha` of the way through the tick
|
||||
/// (`EntityRenderer`'s `Mth.lerp(partialTick, xo, x)` and so on).
|
||||
pub fn render_position(&self, alpha: f64) -> Vec3 {
|
||||
self.previous.lerp(self.position, alpha)
|
||||
}
|
||||
/// The body's yaw `alpha` of the way through the tick
|
||||
/// (`LivingEntityRenderer.solveBodyRot`: `Mth.rotLerp(partialTick,
|
||||
/// yBodyRotO, yBodyRot)`).
|
||||
pub fn render_yaw(&self, alpha: f32) -> f32 {
|
||||
self.previous_yaw + alpha * crate::camera::wrap_degrees(self.yaw - self.previous_yaw)
|
||||
}
|
||||
/// A server's stand on its client, each tick:
|
||||
/// `setOldPosAndRot` (`ClientLevel.tickNonPassenger`), then the step of
|
||||
/// its `SteppedInterpolationHandler` (`step`: the position and yaw it is
|
||||
/// put at, when one is due). The yaw
|
||||
/// turns the short way and stays continuous, so a frame between ticks
|
||||
/// never spins it.
|
||||
pub fn interpolate(&mut self, step: Option<(Vec3, f32)>) {
|
||||
self.previous = self.position;
|
||||
self.previous_yaw = self.yaw;
|
||||
if let Some((at, yaw)) = step {
|
||||
self.position = at;
|
||||
self.yaw += crate::camera::wrap_degrees(yaw - self.yaw);
|
||||
}
|
||||
}
|
||||
/// Put there at once, with nothing to blend from
|
||||
/// (`Entity.snapTo`).
|
||||
pub fn snap(&mut self, at: Vec3, yaw: f32) {
|
||||
self.position = at;
|
||||
self.previous = at;
|
||||
self.yaw = yaw;
|
||||
self.previous_yaw = yaw;
|
||||
}
|
||||
pub fn new(id: u32, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
fire_ticks: 0,
|
||||
health: 20.0,
|
||||
id,
|
||||
position,
|
||||
previous: position,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: true,
|
||||
yaw,
|
||||
previous_yaw: yaw,
|
||||
equipment: Default::default(),
|
||||
small: false,
|
||||
show_arms: false,
|
||||
@@ -1076,8 +1151,29 @@ impl ArmorStand {
|
||||
last_hit: i64::MIN / 2,
|
||||
pose: Self::DEFAULT_POSE,
|
||||
vehicle: None,
|
||||
disabled_slots: 0,
|
||||
}
|
||||
}
|
||||
/// `EquipmentSlot.getFilterFlag` of an `equipment` index (main hand, off
|
||||
/// hand, feet, legs, chest, head).
|
||||
fn filter_flag(slot: usize) -> u32 {
|
||||
[0, 5, 1, 2, 3, 4][slot]
|
||||
}
|
||||
/// `ArmorStand.isDisabled`: the slot is locked, or it is a hand and the
|
||||
/// stand has no arms.
|
||||
pub fn is_disabled(&self, slot: usize) -> bool {
|
||||
self.disabled_slots & (1 << Self::filter_flag(slot)) != 0 || (slot < 2 && !self.show_arms)
|
||||
}
|
||||
/// The `swapItem` locks: taking from a filled slot or putting into an
|
||||
/// empty one can each be disabled.
|
||||
pub fn swap_locked(&self, slot: usize) -> bool {
|
||||
let offset = if self.equipment[slot].is_some() {
|
||||
8
|
||||
} else {
|
||||
16
|
||||
};
|
||||
self.disabled_slots & (1 << (Self::filter_flag(slot) + offset)) != 0
|
||||
}
|
||||
pub fn environment_tick(
|
||||
&mut self,
|
||||
world: &World,
|
||||
@@ -1367,6 +1463,53 @@ mod fire_environment_tests {
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod armor_stand_render_tests {
|
||||
use super::*;
|
||||
|
||||
/// A falling stand is drawn between last tick's position and this one's,
|
||||
/// so it slides down every frame instead of jumping once a tick (#4416).
|
||||
#[test]
|
||||
fn a_falling_stand_is_drawn_between_its_ticks() {
|
||||
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 70.0, 0.5), 0.0);
|
||||
// A new stand has no move behind it.
|
||||
assert_eq!(stand.render_position(0.5), stand.position);
|
||||
let fall = |s: &ArmorStand| Some((Vec3::new(0.5, s.position.y - 1.0, 0.5), 0.0));
|
||||
stand.interpolate(fall(&stand));
|
||||
assert_eq!(stand.render_position(0.0).y, 70.0);
|
||||
assert_eq!(stand.render_position(0.25).y, 69.75);
|
||||
assert_eq!(stand.render_position(1.0).y, 69.0);
|
||||
stand.interpolate(fall(&stand));
|
||||
assert_eq!(stand.render_position(0.5).y, 68.5);
|
||||
// Landed: no step is due, so it stays put, and so does every frame.
|
||||
stand.interpolate(None);
|
||||
assert_eq!(stand.render_position(0.5).y, 68.0);
|
||||
}
|
||||
|
||||
/// A turning stand's body turns between ticks too, the short way round.
|
||||
#[test]
|
||||
fn a_turning_stand_is_drawn_between_its_ticks() {
|
||||
let mut stand = ArmorStand::new(1, Vec3::ZERO, 170.0);
|
||||
assert_eq!(stand.render_yaw(0.5), 170.0);
|
||||
// To -170 is 20 degrees on through 180, not 340 back through 0.
|
||||
stand.interpolate(Some((Vec3::ZERO, -170.0)));
|
||||
assert_eq!(stand.render_yaw(0.0), 170.0);
|
||||
assert_eq!(stand.render_yaw(0.5), 180.0);
|
||||
assert_eq!(stand.render_yaw(1.0), 190.0);
|
||||
stand.interpolate(None);
|
||||
assert_eq!(stand.render_yaw(0.5), 190.0);
|
||||
}
|
||||
|
||||
/// A snap (a move too far to interpolate) leaves nothing to blend from.
|
||||
#[test]
|
||||
fn a_snapped_stand_is_drawn_where_it_landed() {
|
||||
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 70.0, 0.5), 0.0);
|
||||
stand.interpolate(Some((Vec3::new(0.5, 69.0, 0.5), 0.0)));
|
||||
stand.snap(Vec3::new(200.5, 64.0, 0.5), 90.0);
|
||||
assert_eq!(stand.render_position(0.5), Vec3::new(200.5, 64.0, 0.5));
|
||||
assert_eq!(stand.render_yaw(0.5), 90.0);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod merge_tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -511,6 +511,10 @@ pub fn mob_to_nbt(
|
||||
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
|
||||
if is_animal(id) {
|
||||
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
|
||||
// Animal.addAdditionalSaveData: the player who fed it.
|
||||
if let Some(cause) = m.extra.love_cause {
|
||||
map.insert("LoveCause".into(), Tag::IntArray(cause.to_vec()));
|
||||
}
|
||||
}
|
||||
let species = m.species();
|
||||
// MushroomCow saves its variant under `Type` (below), not `variant`.
|
||||
@@ -538,7 +542,15 @@ pub fn mob_to_nbt(
|
||||
"minecraft:fox" => {
|
||||
let kind = if m.extra.variant == 1 { "snow" } else { "red" };
|
||||
map.insert("Type".into(), string(kind));
|
||||
map.entry("Trusted".into()).or_insert(Tag::List(Vec::new()));
|
||||
// The trusted UUIDs (`Fox.TRUSTED_LIST_CODEC`: a list of int arrays).
|
||||
map.insert(
|
||||
"Trusted".into(),
|
||||
Tag::List(
|
||||
crate::mobs::fox::trusted_entities(m)
|
||||
.map(|uuid| Tag::IntArray(uuid.to_vec()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
for (key, bit) in [
|
||||
("Sleeping", crate::mobs::fox::SLEEPING),
|
||||
("Sitting", crate::mobs::fox::SITTING),
|
||||
@@ -575,7 +587,10 @@ pub fn mob_to_nbt(
|
||||
// Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny).
|
||||
"minecraft:rabbit" => {
|
||||
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
|
||||
map.entry("MoreCarrotTicks".into()).or_insert(Tag::Int(0));
|
||||
map.insert(
|
||||
"MoreCarrotTicks".into(),
|
||||
Tag::Int(m.extra.more_carrot_ticks),
|
||||
);
|
||||
}
|
||||
"minecraft:goat" => {
|
||||
map.insert(
|
||||
@@ -1141,6 +1156,25 @@ pub fn mob_to_nbt(
|
||||
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
|
||||
continue;
|
||||
}
|
||||
// A worn piece or held item that kept its damage, name or
|
||||
// components is saved with them.
|
||||
if slot != "mainhand"
|
||||
&& item.is_some()
|
||||
&& let Some(stack) = crate::mobs::finalize::stored_stack(
|
||||
m,
|
||||
[
|
||||
crate::mobs::finalize::MAINHAND,
|
||||
crate::mobs::finalize::OFFHAND,
|
||||
crate::mobs::finalize::HEAD,
|
||||
crate::mobs::finalize::CHEST,
|
||||
crate::mobs::finalize::LEGS,
|
||||
crate::mobs::finalize::FEET,
|
||||
][index],
|
||||
)
|
||||
{
|
||||
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
|
||||
continue;
|
||||
}
|
||||
// Enchanted or trimmed gear is saved with its components.
|
||||
if let (Some(id), Some(g)) = (item, crate::mobs::gear::gear(m))
|
||||
&& (!g.slots[index].is_empty() || g.trims[index].is_some())
|
||||
@@ -1405,6 +1439,10 @@ pub fn mob_from_nbt(
|
||||
crate::mobs::gear::set(&mut m, index, stack.enchantments().to_vec());
|
||||
let trim = stack.data().and_then(|d| d.trim.clone());
|
||||
crate::mobs::gear::set_trim(&mut m, index, trim);
|
||||
// The damage, name and components stay with the slot.
|
||||
if index != crate::mobs::gear::MAIN {
|
||||
crate::mobs::gear::set_stack(&mut m, index, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1463,6 +1501,8 @@ pub fn mob_from_nbt(
|
||||
}
|
||||
if is_animal(&m.kind.id) {
|
||||
m.extra.in_love = number(tag.get("InLove")).unwrap_or(0) as i32;
|
||||
// Animal.readAdditionalSaveData: the player who fed it into love.
|
||||
m.extra.love_cause = crate::save::uuid_from_tag(tag.get("LoveCause"));
|
||||
}
|
||||
m.name_visible = flag(tag, "CustomNameVisible");
|
||||
m.no_ai = flag(tag, "NoAI");
|
||||
@@ -1592,6 +1632,17 @@ pub fn mob_from_nbt(
|
||||
match m.species() {
|
||||
crate::mobs::Species::Fox => {
|
||||
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
|
||||
// Fox.readAdditionalSaveData: clearTrusted, then addTrustedEntity for
|
||||
// each of "Trusted".
|
||||
crate::mobs::fox::clear_trusted(&mut m);
|
||||
if let Some(Tag::List(trusted)) = tag.get("Trusted") {
|
||||
for uuid in trusted
|
||||
.iter()
|
||||
.filter_map(|t| crate::save::uuid_from_tag(Some(t)))
|
||||
{
|
||||
crate::mobs::fox::add_trusted_entity(&mut m, uuid);
|
||||
}
|
||||
}
|
||||
// Fox.readAdditionalSaveData: the sleeping, sitting and crouching flags.
|
||||
for (key, bit) in [
|
||||
("Sleeping", crate::mobs::fox::SLEEPING),
|
||||
@@ -1633,6 +1684,10 @@ pub fn mob_from_nbt(
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// `Rabbit.readAdditionalSaveData`.
|
||||
if m.species() == crate::mobs::Species::Rabbit {
|
||||
m.extra.more_carrot_ticks = number(tag.get("MoreCarrotTicks")).unwrap_or(0) as i32;
|
||||
}
|
||||
// `Ocelot.readAdditionalSaveData`.
|
||||
if m.species() == crate::mobs::Species::Ocelot {
|
||||
m.extra.trusting = flag(tag, "Trusting");
|
||||
@@ -2155,6 +2210,7 @@ pub fn arrow_from_nbt(
|
||||
p.velocity = motion(tag);
|
||||
[p.yaw, p.pitch] = rotation(tag);
|
||||
p.left_owner = flag(tag, "LeftOwner");
|
||||
p.owner_uuid = owner;
|
||||
let a = p.arrow.as_mut()?;
|
||||
a.life = number(tag.get("life")).unwrap_or(0) as i16 as i32;
|
||||
a.shake = (number(tag.get("shake")).unwrap_or(0) as i8 as u8).into();
|
||||
@@ -2198,26 +2254,121 @@ pub fn fireball_to_nbt(
|
||||
"acceleration_power",
|
||||
Tag::Double(p.acceleration.to_bits()),
|
||||
);
|
||||
// Projectile: LeftOwner only when set; HasBeenShot once it has ticked
|
||||
// (a fireball saved before its first tick keeps what it was loaded with).
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"LeftOwner".into());
|
||||
}
|
||||
if p.left_owner {
|
||||
put(&mut tag, "LeftOwner", Tag::Byte(1));
|
||||
}
|
||||
if p.age > 0 || tag.get("HasBeenShot").is_none() {
|
||||
put(&mut tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
|
||||
}
|
||||
put_projectile(&mut tag, p, owner);
|
||||
if let Some(power) = power {
|
||||
put(&mut tag, "ExplosionPower", Tag::Byte(power as i8));
|
||||
}
|
||||
Some(tag)
|
||||
}
|
||||
|
||||
/// `Projectile.addAdditionalSaveData`: Owner, LeftOwner only when set, and
|
||||
/// HasBeenShot once it has ticked (one saved before its first tick keeps
|
||||
/// what it was loaded with).
|
||||
fn put_projectile(tag: &mut Tag, p: &Projectile, owner: Option<[i32; 4]>) {
|
||||
if let Tag::Compound(map) = tag {
|
||||
map.remove(&"LeftOwner".into());
|
||||
}
|
||||
if p.left_owner {
|
||||
put(tag, "LeftOwner", Tag::Byte(1));
|
||||
}
|
||||
if p.age > 0 || tag.get("HasBeenShot").is_none() {
|
||||
put(tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
|
||||
}
|
||||
if let Some(owner) = owner {
|
||||
put(&mut tag, "Owner", Tag::IntArray(owner.to_vec()));
|
||||
put(tag, "Owner", Tag::IntArray(owner.to_vec()));
|
||||
}
|
||||
}
|
||||
|
||||
/// The thrown item projectiles Horizon saves: each entity type's id is also
|
||||
/// the item it saves when none was loaded (`getDefaultItem`).
|
||||
const THROWABLES: [(ProjectileKind, &str); 3] = [
|
||||
(ProjectileKind::Snowball, "minecraft:snowball"),
|
||||
(ProjectileKind::Egg, "minecraft:egg"),
|
||||
(ProjectileKind::EnderPearl, "minecraft:ender_pearl"),
|
||||
];
|
||||
fn throwable_item(kind: &ProjectileKind) -> Option<&'static str> {
|
||||
if let ProjectileKind::Thrown(t) = kind {
|
||||
return Some(t.item);
|
||||
}
|
||||
THROWABLES
|
||||
.iter()
|
||||
.find(|(k, _)| k == kind)
|
||||
.map(|&(_, id)| id)
|
||||
}
|
||||
|
||||
/// The thrown kind an entity id names: vanilla's, or a content mod's type.
|
||||
pub fn throwable_kind(id: &str) -> Option<ProjectileKind> {
|
||||
match THROWABLES.iter().find(|(_, i)| *i == id) {
|
||||
Some((kind, _)) => Some(kind.clone()),
|
||||
None => crate::projectiles::thrown_type(id).map(ProjectileKind::Thrown),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether its chunk saves it: the kinds `arrow_to_nbt`, `fireball_to_nbt`
|
||||
/// and [`throwable_to_nbt`] write.
|
||||
pub fn saved_with_chunk(p: &Projectile) -> bool {
|
||||
matches!(
|
||||
p.kind,
|
||||
ProjectileKind::LargeFireball { .. } | ProjectileKind::SmallFireball
|
||||
) || (p.arrow.is_some() && matches!(p.kind, ProjectileKind::Arrow { .. }))
|
||||
|| throwable_item(&p.kind).is_some()
|
||||
}
|
||||
|
||||
/// `ThrowableItemProjectile.addAdditionalSaveData`: `Projectile`'s keys and
|
||||
/// the `Item` it flies as.
|
||||
pub fn throwable_to_nbt(
|
||||
p: &Projectile,
|
||||
uuid: [i32; 4],
|
||||
owner: Option<[i32; 4]>,
|
||||
raw: Option<&Tag>,
|
||||
) -> Option<Tag> {
|
||||
let item = throwable_item(&p.kind)?;
|
||||
let base = Base {
|
||||
id: p.kind.id(),
|
||||
uuid,
|
||||
position: p.position,
|
||||
motion: p.velocity,
|
||||
rotation: [p.yaw, p.pitch],
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
put_projectile(&mut tag, p, owner);
|
||||
// Entity.saveWithoutId writes NoGravity only when set.
|
||||
if p.no_gravity {
|
||||
put(&mut tag, "NoGravity", Tag::Byte(1));
|
||||
}
|
||||
// the loaded item stays (a blue egg's, a renamed one's).
|
||||
if tag.get("Item").is_none() {
|
||||
put(
|
||||
&mut tag,
|
||||
"Item",
|
||||
item_to_nbt(&crate::items::ItemStack::new(item, 1)),
|
||||
);
|
||||
}
|
||||
Some(tag)
|
||||
}
|
||||
|
||||
/// `ThrowableItemProjectile.readAdditionalSaveData` for the kinds
|
||||
/// [`throwable_to_nbt`] writes; the owner is the player when it is theirs.
|
||||
pub fn throwable_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
|
||||
let kind = throwable_kind(&kind(tag)?)?;
|
||||
let owner = owner_uuid(tag);
|
||||
let from_player = player.is_some() && owner == player;
|
||||
let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player));
|
||||
p.velocity = motion(tag);
|
||||
[p.yaw, p.pitch] = rotation(tag);
|
||||
p.left_owner = flag(tag, "LeftOwner");
|
||||
p.owner_uuid = owner;
|
||||
p.no_gravity = flag(tag, "NoGravity");
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// `Projectile`'s `Owner`.
|
||||
pub fn owner_uuid(tag: &Tag) -> Option<[i32; 4]> {
|
||||
uuid_from_tag(tag.get("Owner"))
|
||||
}
|
||||
|
||||
pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
|
||||
let kind = match kind(tag)?.as_str() {
|
||||
"minecraft:fireball" => ProjectileKind::LargeFireball {
|
||||
@@ -2234,6 +2385,7 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
|
||||
p.velocity = motion(tag);
|
||||
[p.yaw, p.pitch] = rotation(tag);
|
||||
p.left_owner = flag(tag, "LeftOwner");
|
||||
p.owner_uuid = owner;
|
||||
p.acceleration = float(tag.get("acceleration_power")).unwrap_or(0.1);
|
||||
Some(p)
|
||||
}
|
||||
@@ -2498,6 +2650,7 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
} else {
|
||||
put(&mut tag, "equipment", Tag::compound(equipment));
|
||||
}
|
||||
put(&mut tag, "DisabledSlots", Tag::Int(a.disabled_slots));
|
||||
for (key, value) in [
|
||||
("Small", a.small),
|
||||
("ShowArms", a.show_arms),
|
||||
@@ -2572,6 +2725,7 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
|
||||
}
|
||||
a.small = flag(tag, "Small");
|
||||
a.show_arms = flag(tag, "ShowArms");
|
||||
a.disabled_slots = number(tag.get("DisabledSlots")).unwrap_or(0) as i32;
|
||||
a.no_base_plate = flag(tag, "NoBasePlate");
|
||||
a.invisible = flag(tag, "Invisible");
|
||||
a.marker = flag(tag, "Marker");
|
||||
@@ -3519,6 +3673,34 @@ mod sheep_nbt_tests {
|
||||
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
|
||||
}
|
||||
|
||||
/// `Rabbit.addAdditionalSaveData` and `readAdditionalSaveData`: `MoreCarrotTicks`.
|
||||
#[test]
|
||||
fn a_rabbit_keeps_how_full_of_carrots_it_is() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
Arc::new(data.types["minecraft:rabbit"].clone()),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
m.extra.more_carrot_ticks = 33;
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
assert_eq!(number(tag.get("MoreCarrotTicks")), Some(33));
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert_eq!(back.extra.more_carrot_ticks, 33);
|
||||
}
|
||||
|
||||
/// `Ocelot.addAdditionalSaveData` and `readAdditionalSaveData`: `Trusting`.
|
||||
#[test]
|
||||
fn an_ocelot_keeps_its_trust() {
|
||||
@@ -4084,6 +4266,85 @@ mod fireball_nbt_tests {
|
||||
assert_eq!(fireball_from_nbt(&tag, None).unwrap().kind, kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thrown_item_projectiles_round_trip_their_flight_item_and_owner() {
|
||||
let player = Some([5, 6, 7, 8]);
|
||||
for (kind, id) in [
|
||||
(ProjectileKind::Snowball, "minecraft:snowball"),
|
||||
(ProjectileKind::Egg, "minecraft:egg"),
|
||||
(ProjectileKind::EnderPearl, "minecraft:ender_pearl"),
|
||||
] {
|
||||
let mut p =
|
||||
Projectile::new(kind.clone(), Vec3::new(1.5, 70.0, -2.5), Some(Who::Player));
|
||||
(p.velocity, p.left_owner, p.age) = (Vec3::new(0.3, -0.1, 1.2), true, 4);
|
||||
[p.yaw, p.pitch] = [30.0, -10.0];
|
||||
let tag = throwable_to_nbt(&p, [1, 2, 3, 4], player, None).unwrap();
|
||||
assert_eq!(super::kind(&tag).as_deref(), Some(id));
|
||||
// ThrowableItemProjectile: the item it flies as, by its default.
|
||||
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
|
||||
assert_eq!((&*item.item, item.count), (id, 1));
|
||||
assert_eq!(number(tag.get("HasBeenShot")), Some(1));
|
||||
assert_eq!(uuid_from_tag(tag.get("Owner")), player);
|
||||
let back = throwable_from_nbt(&tag, player).unwrap();
|
||||
assert_eq!((back.kind, back.owner), (kind, Some(Who::Player)));
|
||||
assert_eq!((back.position, back.velocity), (p.position, p.velocity));
|
||||
assert_eq!(
|
||||
([back.yaw, back.pitch], back.left_owner),
|
||||
([30.0, -10.0], true)
|
||||
);
|
||||
// an owner who is not here keeps their UUID through a moved resave.
|
||||
let mut away = throwable_from_nbt(&tag, Some([9; 4])).unwrap();
|
||||
assert_eq!((away.owner, away.owner_uuid), (None, player));
|
||||
away.position.y += 3.0;
|
||||
let resaved = throwable_to_nbt(&away, [1; 4], away.owner_uuid, None).unwrap();
|
||||
assert_eq!(uuid_from_tag(resaved.get("Owner")), player);
|
||||
}
|
||||
// the chunk saves arrows, fireballs and these, and so takes them with it.
|
||||
let kinds = [
|
||||
(ProjectileKind::SmallFireball, true),
|
||||
(ProjectileKind::Snowball, true),
|
||||
(ProjectileKind::WindCharge, false),
|
||||
(ProjectileKind::Trident, false),
|
||||
];
|
||||
for (kind, saved) in kinds {
|
||||
let p = Projectile::new(kind, Vec3::ZERO, None);
|
||||
assert_eq!(saved_with_chunk(&p), saved, "{:?}", p.kind);
|
||||
}
|
||||
// a loaded item stays, and a new projectile leaves LeftOwner out.
|
||||
let raw = Tag::compound([
|
||||
("id", Tag::String("minecraft:egg".into())),
|
||||
("Pos", doubles(&[0.0, 64.0, 0.0])),
|
||||
(
|
||||
"Item",
|
||||
item_to_nbt(&crate::items::ItemStack::new("minecraft:blue_egg", 1)),
|
||||
),
|
||||
]);
|
||||
let egg = throwable_from_nbt(&raw, None).unwrap();
|
||||
let tag = throwable_to_nbt(&egg, [1; 4], None, Some(&raw)).unwrap();
|
||||
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
|
||||
assert_eq!(&*item.item, "minecraft:blue_egg");
|
||||
assert!(tag.get("LeftOwner").is_none() && tag.get("Owner").is_none());
|
||||
assert_eq!(number(tag.get("HasBeenShot")), Some(0));
|
||||
let wind = Projectile::new(ProjectileKind::WindCharge, Vec3::ZERO, None);
|
||||
assert!(throwable_to_nbt(&wind, [1; 4], None, None).is_none());
|
||||
// NoGravity is loaded and saved again (a summoned pearl that floats).
|
||||
let floating = Tag::compound([
|
||||
("id", Tag::String("minecraft:ender_pearl".into())),
|
||||
("Pos", doubles(&[0.0, 64.0, 0.0])),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
]);
|
||||
let pearl = throwable_from_nbt(&floating, None).unwrap();
|
||||
assert!(pearl.no_gravity);
|
||||
let tag = throwable_to_nbt(&pearl, [1; 4], None, None).unwrap();
|
||||
assert_eq!(number(tag.get("NoGravity")), Some(1));
|
||||
assert!(
|
||||
throwable_to_nbt(&egg, [1; 4], None, None)
|
||||
.unwrap()
|
||||
.get("NoGravity")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -4136,6 +4397,43 @@ mod neutral_anger_tests {
|
||||
mod held_item_name_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_worn_piece_keeps_its_damage_and_name_through_the_save() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<_, _> = data
|
||||
.types
|
||||
.into_iter()
|
||||
.map(|(id, kind)| (id, Arc::new(kind)))
|
||||
.collect();
|
||||
let mut mob = Mob::new(1, Arc::clone(&types["minecraft:zombie"]), Vec3::ZERO, 0.0);
|
||||
let mut helmet = crate::items::ItemStack::new("minecraft:iron_helmet", 1);
|
||||
helmet.damage = 40;
|
||||
helmet.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Hat".into()));
|
||||
crate::mobs::finalize::set_item_stack(&mut mob, crate::mobs::finalize::HEAD, helmet);
|
||||
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None);
|
||||
let loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
|
||||
let worn = crate::mobs::finalize::item_stack(&loaded, crate::mobs::finalize::HEAD).unwrap();
|
||||
assert_eq!(worn.damage, 40);
|
||||
assert_eq!(worn.custom_name().as_deref(), Some("Hat"));
|
||||
// Replacing the item forgets the old stack.
|
||||
let mut loaded = loaded;
|
||||
crate::mobs::finalize::set_item(
|
||||
&mut loaded,
|
||||
crate::mobs::finalize::HEAD,
|
||||
"minecraft:iron_helmet",
|
||||
);
|
||||
let fresh =
|
||||
crate::mobs::finalize::item_stack(&loaded, crate::mobs::finalize::HEAD).unwrap();
|
||||
assert_eq!(fresh.damage, 0);
|
||||
assert!(fresh.custom_name().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_a_named_item_does_not_restore_its_old_name_on_save() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -4184,3 +4482,33 @@ mod held_item_name_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mod_thrown_nbt_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_mod_thrown_type_saves_its_id_and_item_and_loads_back() {
|
||||
crate::projectiles::register_test_types();
|
||||
let bolt = crate::projectiles::thrown_type("ruby:bolt").unwrap();
|
||||
let mut p = Projectile::new(
|
||||
ProjectileKind::Thrown(bolt),
|
||||
Vec3::new(1.0, 80.0, 2.0),
|
||||
None,
|
||||
);
|
||||
p.velocity = Vec3::new(0.0, 0.5, 1.0);
|
||||
assert!(saved_with_chunk(&p));
|
||||
let tag = throwable_to_nbt(&p, [1; 4], None, None).unwrap();
|
||||
assert_eq!(super::kind(&tag).as_deref(), Some("ruby:bolt"));
|
||||
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
|
||||
assert_eq!(&*item.item, "ruby:gem");
|
||||
let back = throwable_from_nbt(&tag, None).unwrap();
|
||||
assert_eq!((&back.kind, back.velocity), (&p.kind, p.velocity));
|
||||
// NoGravity comes back too, as for vanilla's (a summoned bolt that floats).
|
||||
p.no_gravity = true;
|
||||
let tag = throwable_to_nbt(&p, [1; 4], None, None).unwrap();
|
||||
assert!(throwable_from_nbt(&tag, None).unwrap().no_gravity);
|
||||
// a type no mod declares now is not a thrown kind.
|
||||
assert!(throwable_kind("gone:bolt").is_none());
|
||||
}
|
||||
}
|
||||
+719
-134
File diff suppressed because it is too large.
Load diff
@@ -457,6 +457,8 @@ impl Game<'_> {
|
||||
EntityView {
|
||||
kind: "minecraft:player".into(),
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
// Entity.isOnFire on a server: remainingFireTicks (players aren't fire immune).
|
||||
on_fire: p.fire_ticks > 0,
|
||||
sneaking: p.pose == horizon_client::pose::Pose::Crouching,
|
||||
// Input.shift: the held key, as `BlockItem.place`'s sneaking reads it.
|
||||
input_sneak: self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft),
|
||||
|
||||
@@ -522,7 +522,9 @@ impl Game<'_> {
|
||||
let slot = match &held {
|
||||
Some(h) => {
|
||||
let slot = self.equipment_slot(h);
|
||||
if slot < 2 && !a.show_arms {
|
||||
// ArmorStand.interactAt: a disabled slot, or a hand without
|
||||
// arms, is FAIL.
|
||||
if a.is_disabled(slot) {
|
||||
return true;
|
||||
}
|
||||
slot
|
||||
@@ -550,6 +552,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
};
|
||||
// An empty hand takes from the main hand when the clicked slot is
|
||||
// disabled (`isDisabled(clickedSlot) ? slot : clickedSlot`).
|
||||
let slot = if held.is_none() && a.is_disabled(slot) {
|
||||
0
|
||||
} else {
|
||||
slot
|
||||
};
|
||||
// ArmorStand.swapItem: taking from a filled slot or putting into an
|
||||
// empty one can be disabled; the click then passes.
|
||||
if a.swap_locked(slot) {
|
||||
return false;
|
||||
}
|
||||
let current = self.armor_stands[i].equipment[slot].clone();
|
||||
if held.is_none() && current.is_none() {
|
||||
return false;
|
||||
@@ -722,6 +736,8 @@ impl Game<'_> {
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
let ticking = self.entity_ticking_area();
|
||||
for (i, a) in self.armor_stands.iter_mut().enumerate() {
|
||||
a.previous = a.position;
|
||||
a.previous_yaw = a.yaw;
|
||||
if !ticking(a.position.x.floor() as i32, a.position.z.floor() as i32) {
|
||||
continue;
|
||||
}
|
||||
@@ -785,11 +801,10 @@ impl Game<'_> {
|
||||
let dragon = a.equipment[5]
|
||||
.as_ref()
|
||||
.is_some_and(|i| &*i.item == "minecraft:dragon_head");
|
||||
let position = super::entity_culling::at(a.position);
|
||||
if !self.standing_entity_in_view(
|
||||
camera,
|
||||
position,
|
||||
position,
|
||||
super::entity_culling::at(a.previous),
|
||||
super::entity_culling::at(a.position),
|
||||
alpha,
|
||||
width,
|
||||
height,
|
||||
@@ -798,7 +813,9 @@ impl Game<'_> {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let p = glam::DVec3::new(a.position.x, a.position.y, a.position.z) - camera;
|
||||
// EntityRenderer: drawn between last tick's position and this one's.
|
||||
let at = a.render_position(alpha);
|
||||
let p = super::entity_culling::at(at) - camera;
|
||||
// setupRotations: 180 - body yaw, plus the wobble for 5 ticks after a hit.
|
||||
let wiggle = (self.world.game_time - a.last_hit) as f32 + alpha as f32;
|
||||
let wobble = if wiggle < 5.0 {
|
||||
@@ -807,13 +824,15 @@ impl Game<'_> {
|
||||
0.0
|
||||
};
|
||||
let base = glam::Mat4::from_translation(p.as_vec3())
|
||||
* glam::Mat4::from_rotation_y((180.0 - a.yaw + wobble).to_radians())
|
||||
* glam::Mat4::from_rotation_y(
|
||||
(180.0 - a.render_yaw(alpha as f32) + wobble).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0));
|
||||
let light = self.world.light(
|
||||
a.position.x.floor() as i32,
|
||||
(a.position.y + 0.5).floor() as i32,
|
||||
a.position.z.floor() as i32,
|
||||
at.x.floor() as i32,
|
||||
(at.y + 0.5).floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
self.armor_stand_vertices(a, base, packed, out);
|
||||
@@ -1322,6 +1341,26 @@ mod swap_tests {
|
||||
ItemStack::new(item, count)
|
||||
}
|
||||
#[test]
|
||||
fn disabled_slots_lock_by_filter_flag() {
|
||||
let mut a = ArmorStand::new(1, horizon_core::geometry::Vec3::ZERO, 0.0);
|
||||
// Hands need arms.
|
||||
assert!(a.is_disabled(0) && a.is_disabled(1));
|
||||
a.show_arms = true;
|
||||
assert!(!a.is_disabled(0) && !a.is_disabled(1));
|
||||
// Bit 4 locks the head (equipment index 5), bit 5 the off hand (1).
|
||||
a.disabled_slots = (1 << 4) | (1 << 5);
|
||||
assert!(a.is_disabled(5) && a.is_disabled(1));
|
||||
assert!(!a.is_disabled(4) && !a.is_disabled(0));
|
||||
// Taking (+8) from a filled slot and putting (+16) into an empty one.
|
||||
a.disabled_slots = (1 << (3 + 8)) | (1 << (1 + 16));
|
||||
assert!(!a.swap_locked(4), "an empty chest slot is not take-locked");
|
||||
a.equipment[4] = Some(stack("minecraft:iron_chestplate", 1));
|
||||
assert!(a.swap_locked(4));
|
||||
assert!(a.swap_locked(2), "an empty feet slot is put-locked");
|
||||
a.equipment[2] = Some(stack("minecraft:iron_boots", 1));
|
||||
assert!(!a.swap_locked(2));
|
||||
}
|
||||
#[test]
|
||||
fn creative_swap_gives_the_worn_piece_to_the_hand() {
|
||||
let swap = swap_with_equipment_slot(
|
||||
&stack("minecraft:diamond_chestplate", 1),
|
||||
|
||||
@@ -778,6 +778,15 @@ impl Game<'_> {
|
||||
return None;
|
||||
};
|
||||
let skin = skull.skin.as_ref()?;
|
||||
// PlayerSkin.Patch: a `texture` override replaces the profile's skin.
|
||||
if let Some(texture) = &skin.texture
|
||||
&& let Some(sprite) =
|
||||
skins::patch_skin(texture, &mut self.skin_downloads.borrow_mut(), |location| {
|
||||
self.gui.atlas.sprites.contains_key(location)
|
||||
})
|
||||
{
|
||||
return Some(sprite);
|
||||
}
|
||||
// The texture a packed or resolved `textures` property fetches: the
|
||||
// download of a static profile, or the one the services answer for a
|
||||
// `Dynamic` (name- or id-only) profile.
|
||||
|
||||
@@ -1308,6 +1308,8 @@ impl Game<'_> {
|
||||
let destroyed = creative || b.damage > 40.0;
|
||||
if destroyed {
|
||||
self.eject_boat_mobs(i, true);
|
||||
let id = self.boats[i].id;
|
||||
self.release_vehicle_container(id);
|
||||
let b = self.boats.remove(i);
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
// AbstractBoat.remove: a destroyed boat drops its lead.
|
||||
|
||||
@@ -71,7 +71,7 @@ impl Game<'_> {
|
||||
}
|
||||
let at = hit.pos.map(|v| f64::from(v) + 0.5);
|
||||
self.audio.play(sound, Some(at), 1.0, 1.0);
|
||||
if brushable && self.brush_block(hit.pos, hit.face) {
|
||||
if brushable && self.brush_block(hit.pos, hit.face, slot) {
|
||||
self.damage_held(1);
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// `BrushableBlockEntity.brush`: whether the block changed (the brush wears).
|
||||
fn brush_block(&mut self, pos: [i32; 3], face: [i32; 3]) -> bool {
|
||||
fn brush_block(&mut self, pos: [i32; 3], face: [i32; 3], slot: usize) -> bool {
|
||||
let time = self.world.game_time;
|
||||
let mut brush = self.brushing.remove(&pos).unwrap_or_default();
|
||||
brush.hit_face.get_or_insert(face);
|
||||
@@ -141,7 +141,7 @@ impl Game<'_> {
|
||||
let before = completion_state(brush.count);
|
||||
brush.count += 1;
|
||||
if brush.count >= 10 {
|
||||
self.brushing_completed(pos, brush.hit_face);
|
||||
self.brushing_completed(pos, brush.hit_face, slot);
|
||||
return true;
|
||||
}
|
||||
self.schedule_brushable_tick(pos);
|
||||
@@ -191,7 +191,7 @@ impl Game<'_> {
|
||||
|
||||
/// `BrushableBlockEntity.brushingCompleted`: the item drops out of the
|
||||
/// brushed face and the block turns into its sand or gravel.
|
||||
fn brushing_completed(&mut self, pos: [i32; 3], face: Option<[i32; 3]>) {
|
||||
fn brushing_completed(&mut self, pos: [i32; 3], face: Option<[i32; 3]>, slot: usize) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let turns_into = match &*self.res.facts().get(state).name {
|
||||
"minecraft:suspicious_sand" => "minecraft:sand",
|
||||
@@ -212,7 +212,20 @@ impl Game<'_> {
|
||||
f64::from(pos[1] + face[1]) + 0.625,
|
||||
f64::from(pos[2] + face[2]) + 0.5,
|
||||
];
|
||||
self.spawn_item(item, at, [0.0; 3], 0);
|
||||
let mut drops = vec![item];
|
||||
let tool = self.stack_in(slot).cloned();
|
||||
let origin = pos.map(|c| f64::from(c) + 0.5);
|
||||
self.native_use_drops(
|
||||
"",
|
||||
origin,
|
||||
Some((pos, state)),
|
||||
None,
|
||||
tool.as_ref(),
|
||||
&mut drops,
|
||||
);
|
||||
for item in drops {
|
||||
self.spawn_item(item, at, [0.0; 3], 0);
|
||||
}
|
||||
}
|
||||
// Level event 3008 for every player, then the block change.
|
||||
self.level_event_at(3008, pos, state.0 as i32);
|
||||
|
||||
@@ -435,6 +435,9 @@ impl Game<'_> {
|
||||
]
|
||||
.map(String::from),
|
||||
);
|
||||
// content mods' thrown types.
|
||||
let thrown = horizon_client::projectiles::thrown_types().iter();
|
||||
summonable.extend(thrown.map(|t| t.id.to_owned()));
|
||||
summonable.sort();
|
||||
summonable.dedup();
|
||||
reg.summonable = summonable;
|
||||
@@ -1432,6 +1435,7 @@ impl Game<'_> {
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: true,
|
||||
});
|
||||
self.ui.screenshot_message = Some(path);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@ pub(super) struct CombatTracker {
|
||||
pending: Vec<(String, (u32, Text), Option<Text>)>,
|
||||
/// `pending` for a direct entity with no causing entity: only its name.
|
||||
pending_direct: Vec<(String, Text)>,
|
||||
/// The lengths of `pending` and `pending_direct` before the first
|
||||
/// attacker noted for the hurt being dealt now (`forget_noted`).
|
||||
noted_since: Option<(usize, usize)>,
|
||||
/// `LivingEntity.lastHurtByMob` and its timestamp (the kill credit).
|
||||
last_hurt_by: Option<((u32, Text), i64)>,
|
||||
/// The mob the player last hurt (`getLastHurtMob`) and when.
|
||||
@@ -145,6 +148,23 @@ pub(super) struct CombatTracker {
|
||||
/// end of the last one).
|
||||
fall_before: f64,
|
||||
}
|
||||
impl CombatTracker {
|
||||
fn mark(&mut self) {
|
||||
let marks = (self.pending.len(), self.pending_direct.len());
|
||||
self.noted_since.get_or_insert(marks);
|
||||
}
|
||||
/// The player's hurt is over: when a native mod canceled it, the
|
||||
/// attackers noted for it go, as no combat entry takes them and the next
|
||||
/// hit of the same damage type must not be credited to them.
|
||||
pub(super) fn forget_noted(&mut self, canceled: bool) {
|
||||
if let Some((attackers, directs)) = self.noted_since.take()
|
||||
&& canceled
|
||||
{
|
||||
self.pending.truncate(attackers);
|
||||
self.pending_direct.truncate(directs);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `LivingEntity.makeDrownParticles`: eight bubbles around the entity,
|
||||
/// moving with it.
|
||||
@@ -197,18 +217,6 @@ impl Game<'_> {
|
||||
self.note_named_attacker(damage_type, mob, name);
|
||||
}
|
||||
}
|
||||
/// How many attackers wait for the damage they caused, for
|
||||
/// [`Game::drop_pending_since`].
|
||||
pub(super) fn pending_marks(&self) -> (usize, usize) {
|
||||
(self.combat.pending.len(), self.combat.pending_direct.len())
|
||||
}
|
||||
/// Forgets the attackers noted since `marks`: their hit didn't land (a
|
||||
/// native mod canceled it), so no combat entry takes them, and the next
|
||||
/// hit of the same damage type must not be credited to them.
|
||||
pub(super) fn drop_pending_since(&mut self, marks: (usize, usize)) {
|
||||
self.combat.pending.truncate(marks.0);
|
||||
self.combat.pending_direct.truncate(marks.1);
|
||||
}
|
||||
/// `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) {
|
||||
@@ -223,6 +231,7 @@ impl Game<'_> {
|
||||
name: Text,
|
||||
item: Option<Text>,
|
||||
) {
|
||||
self.combat.mark();
|
||||
self.combat
|
||||
.pending
|
||||
.push((damage_type.to_owned(), (id, name), item));
|
||||
@@ -231,6 +240,7 @@ impl Game<'_> {
|
||||
/// nothing caused it (a splash potion nobody threw): the death message
|
||||
/// names it instead (`getLocalizedDeathMessage`). It is no kill credit.
|
||||
pub(super) fn note_direct_entity(&mut self, damage_type: &str, name: Text) {
|
||||
self.combat.mark();
|
||||
self.combat
|
||||
.pending_direct
|
||||
.push((damage_type.to_owned(), name));
|
||||
@@ -314,6 +324,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.combat.pending.clear();
|
||||
self.combat.pending_direct.clear();
|
||||
self.combat.noted_since = None;
|
||||
self.combat.projectile_direct = None;
|
||||
self.combat.projectile_attacker = None;
|
||||
self.combat.fall_before = self.player.as_ref().map_or(0.0, |p| p.fall_distance);
|
||||
@@ -741,7 +752,43 @@ mod held_item_tests {
|
||||
assert!(!has_custom_name(&gone));
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod noted_tests {
|
||||
use super::CombatTracker;
|
||||
use horizon_client::text::Text;
|
||||
|
||||
/// A canceled hit forgets what was noted for it, also before its hurt
|
||||
/// started (a PvP blow, `/damage`), and nothing a landed hit noted.
|
||||
#[test]
|
||||
fn a_canceled_hit_forgets_only_its_own_attackers() {
|
||||
let mut c = CombatTracker::default();
|
||||
let arrow = |c: &mut CombatTracker, id| {
|
||||
c.mark();
|
||||
c.pending.push((
|
||||
"minecraft:arrow".into(),
|
||||
(id, Text::literal("skeleton")),
|
||||
None,
|
||||
));
|
||||
};
|
||||
arrow(&mut c, 1);
|
||||
c.forget_noted(false);
|
||||
arrow(&mut c, 2);
|
||||
c.mark();
|
||||
c.pending_direct
|
||||
.push(("minecraft:arrow".into(), Text::literal("arrow")));
|
||||
c.forget_noted(true);
|
||||
assert_eq!(c.pending.iter().map(|p| p.1.0).collect::<Vec<_>>(), [1]);
|
||||
assert!(c.pending_direct.is_empty());
|
||||
// A hit noted and landed, then one noted and canceled: the landed
|
||||
// one keeps its attacker.
|
||||
c.forget_noted(false);
|
||||
arrow(&mut c, 3);
|
||||
c.forget_noted(false);
|
||||
arrow(&mut c, 4);
|
||||
c.forget_noted(true);
|
||||
assert_eq!(c.pending.iter().map(|p| p.1.0).collect::<Vec<_>>(), [1, 3]);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod stuck_tests {
|
||||
use super::Stuck;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
mod admin;
|
||||
pub(super) mod compute;
|
||||
mod data;
|
||||
pub(super) use data::place_summoned;
|
||||
mod effects;
|
||||
pub(super) mod execute;
|
||||
pub(super) mod extra;
|
||||
@@ -76,6 +77,11 @@ pub(in crate::game) struct OtherEntity {
|
||||
pub(super) fn err(key: &str, args: &[&dyn std::fmt::Display]) -> CommandError {
|
||||
CommandError::tr(key, args)
|
||||
}
|
||||
/// `EntityType.getDescriptionId`: `entity.<namespace>.<path>`.
|
||||
fn entity_name_key(id: &str) -> String {
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
format!("entity.{namespace}.{}", path.replace('/', "."))
|
||||
}
|
||||
fn texts(args: &[Text]) -> Vec<Text> {
|
||||
args.to_vec()
|
||||
}
|
||||
@@ -1227,21 +1233,11 @@ impl Game<'_> {
|
||||
static RARITY: std::sync::OnceLock<std::collections::HashMap<String, String>> =
|
||||
std::sync::OnceLock::new();
|
||||
let map = RARITY.get_or_init(|| {
|
||||
let v: serde_json::Value = serde_json::from_slice(
|
||||
&std::fs::read(self.options.resources.join("items.json")).unwrap_or_default(),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
v.as_object()
|
||||
.map(|o| {
|
||||
o.iter()
|
||||
.filter_map(|(k, c)| {
|
||||
c["minecraft:rarity"]
|
||||
.as_str()
|
||||
.map(|r| (k.clone(), r.to_owned()))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
horizon_world::item_defaults::read(&self.options.resources)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|(k, c)| c["minecraft:rarity"].as_str().map(|r| (k, r.to_owned())))
|
||||
.collect()
|
||||
});
|
||||
map.get(item).cloned().unwrap_or_else(|| "common".into())
|
||||
}
|
||||
@@ -1951,11 +1947,15 @@ impl Game<'_> {
|
||||
Target::Boat(id) => {
|
||||
// AbstractBoat.remove: a killed boat drops its lead.
|
||||
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
self.release_vehicle_container(id);
|
||||
let b = self.boats.remove(i);
|
||||
self.boat_removed(&b);
|
||||
}
|
||||
}
|
||||
Target::Cart(id) => self.carts.retain(|c| c.id != id),
|
||||
Target::Cart(id) => {
|
||||
self.release_vehicle_container(id);
|
||||
self.carts.retain(|c| c.id != id);
|
||||
}
|
||||
Target::Other(id) => self.others.retain(|o| o.id != id),
|
||||
Target::Crystal(id) => self.kill_crystal(id),
|
||||
Target::Dragon(id) => self.kill_dragon(id),
|
||||
@@ -2019,18 +2019,18 @@ impl Game<'_> {
|
||||
&& self
|
||||
.res
|
||||
.mob_types()
|
||||
.get(&format!("minecraft:{short}"))
|
||||
.get(&kind)
|
||||
.is_some_and(|t| t.category == "monster")
|
||||
{
|
||||
return Err(err("commands.summon.failed.peaceful", &[]));
|
||||
}
|
||||
let before = self.spawned_mark();
|
||||
let name = match self.summon_with_nbt(&format!("minecraft:{short}"), pos, &nbt)? {
|
||||
let name = match self.summon_with_nbt(&kind, pos, &nbt)? {
|
||||
Some(target) => self.target_name(target),
|
||||
// Spawned as a kind no command target stands for (primed
|
||||
// TNT, falling blocks, projectiles): its type's name.
|
||||
None if self.spawned_mark() != before => {
|
||||
Text::translate(&format!("entity.minecraft.{short}"), &[])
|
||||
Text::translate(&entity_name_key(&kind), &[])
|
||||
}
|
||||
None => return Err(err("commands.summon.failed", &[])),
|
||||
};
|
||||
@@ -2038,17 +2038,12 @@ impl Game<'_> {
|
||||
return Ok(1);
|
||||
}
|
||||
let name;
|
||||
if let Some(t) = self
|
||||
.res
|
||||
.mob_types()
|
||||
.get(&format!("minecraft:{short}"))
|
||||
.cloned()
|
||||
{
|
||||
if let Some(t) = self.res.mob_types().get(&kind).cloned() {
|
||||
if self.ui.difficulty == 0 && t.category == "monster" {
|
||||
return Err(err("commands.summon.failed.peaceful", &[]));
|
||||
}
|
||||
self.spawn_mob_spawned(&short, pos);
|
||||
name = Text::translate(&format!("entity.minecraft.{short}"), &[]);
|
||||
name = Text::translate(&entity_name_key(&kind), &[]);
|
||||
} else {
|
||||
match short.as_str() {
|
||||
"item" => {
|
||||
@@ -2106,11 +2101,19 @@ impl Game<'_> {
|
||||
let cart_kind = super::minecarts::CartKind::from_id(&kind)
|
||||
.unwrap_or(super::minecarts::CartKind::Rideable);
|
||||
self.next_entity += 1;
|
||||
let cart = super::minecarts::Minecart::new(
|
||||
let mut cart = super::minecarts::Minecart::new(
|
||||
self.next_entity,
|
||||
cart_kind,
|
||||
Vec3::new(pos[0], pos[1], pos[2]),
|
||||
);
|
||||
// SummonCommand.createEntity loads even an empty compound, so a
|
||||
// spawner minecart's BaseSpawner.load runs: SpawnPotentials becomes
|
||||
// WeightedList.of(new SpawnData()).
|
||||
if cart.spawner.is_some() {
|
||||
use horizon_client::mobs::spawner::{Spawner, SpawnerExt};
|
||||
let empty = horizon_world::nbt::Tag::Compound(Default::default());
|
||||
cart.spawner = Some(Box::new(Spawner::load(&empty)));
|
||||
}
|
||||
self.carts.push(cart);
|
||||
name = Text::translate(&format!("entity.minecraft.{k}"), &[]);
|
||||
}
|
||||
@@ -2122,7 +2125,7 @@ impl Game<'_> {
|
||||
// Spawned as a kind no command target stands for
|
||||
// (primed TNT, falling blocks, projectiles).
|
||||
None if self.spawned_mark() != before => {
|
||||
Text::translate(&format!("entity.minecraft.{short}"), &[])
|
||||
Text::translate(&entity_name_key(&kind), &[])
|
||||
}
|
||||
None => return Err(err("commands.summon.failed", &[])),
|
||||
};
|
||||
@@ -2236,6 +2239,10 @@ impl Game<'_> {
|
||||
return Ok(0);
|
||||
};
|
||||
let state = self.block_state(&b)?;
|
||||
// SetBlockCommand.setBlock: `ServerLevel.isDebug` fails it.
|
||||
if self.world_debug {
|
||||
return Err(err("commands.setblock.failed", &[]));
|
||||
}
|
||||
let current = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if ctx.has("keep") && current != self.world.air {
|
||||
return Err(err("commands.setblock.failed", &[]));
|
||||
@@ -2438,6 +2445,10 @@ impl Game<'_> {
|
||||
if area > i64::from(limit) {
|
||||
return Err(err("commands.fill.toobig", &[&limit, &area]));
|
||||
}
|
||||
// FillCommand.fillBlocks: `ServerLevel.isDebug` fails it.
|
||||
if self.world_debug {
|
||||
return Err(err("commands.fill.failed", &[]));
|
||||
}
|
||||
let state = self.block_state(&block)?;
|
||||
let air = self.world.air;
|
||||
let (hollow, outline, destroy, strict, keep) = (
|
||||
@@ -2518,6 +2529,10 @@ impl Game<'_> {
|
||||
return Err(err("argument.pos.unloaded", &[]));
|
||||
}
|
||||
}
|
||||
// CloneCommands.clone: the destination level being a Debug world fails it.
|
||||
if self.world_debug {
|
||||
return Err(err("commands.clone.failed", &[]));
|
||||
}
|
||||
let filter = match ctx.get("filter") {
|
||||
Some(Arg::Block(f)) => Some(f.clone()),
|
||||
_ => None,
|
||||
@@ -3554,6 +3569,20 @@ fn split_top_level(s: &str, sep: char) -> Vec<&str> {
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod summon_tests {
|
||||
#[test]
|
||||
fn a_summoned_entity_is_named_from_its_own_namespace() {
|
||||
use super::entity_name_key;
|
||||
assert_eq!(
|
||||
entity_name_key("minecraft:snowball"),
|
||||
"entity.minecraft.snowball"
|
||||
);
|
||||
assert_eq!(entity_name_key("ruby:bolt"), "entity.ruby.bolt");
|
||||
assert_eq!(entity_name_key("ruby:spell/bolt"), "entity.ruby.spell.bolt");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod look_at_tests {
|
||||
use super::{Target, non_player_entity_id};
|
||||
|
||||
@@ -28,6 +28,36 @@ fn set_key(tag: &mut Tag, key: &str, value: Tag) {
|
||||
c.insert(JavaString::from(key), value);
|
||||
}
|
||||
}
|
||||
/// `EntityType.loadEntityRecursive` snaps every entity of the stack,
|
||||
/// passengers included, to the position (`/summon`, `native_summon`).
|
||||
pub(in crate::game) fn place_summoned(tag: &mut Tag, pos: [f64; 3]) {
|
||||
let p = pos.map(|v| Tag::Double(v.to_bits()));
|
||||
set_key(tag, "Pos", Tag::List(p.to_vec()));
|
||||
// Entity.load: an absent Fire reads as 0 (until the entity moves).
|
||||
if tag.get("Fire").is_none() {
|
||||
set_key(tag, "Fire", Tag::Short(0));
|
||||
}
|
||||
if tag.get("Rotation").is_none() {
|
||||
set_key(
|
||||
tag,
|
||||
"Rotation",
|
||||
Tag::List(vec![
|
||||
Tag::Float(0.0f32.to_bits()),
|
||||
Tag::Float(0.0f32.to_bits()),
|
||||
]),
|
||||
);
|
||||
}
|
||||
if let Tag::Compound(c) = tag
|
||||
&& let Some(Tag::List(riders)) = c.get_mut(&"Passengers".into())
|
||||
{
|
||||
for r in riders {
|
||||
if matches!(r, Tag::Compound(_)) {
|
||||
place_summoned(r, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
// ---- entity data ---------------------------------------------------
|
||||
|
||||
@@ -762,36 +792,7 @@ impl Game<'_> {
|
||||
return Err(err("argument.nbt.expected.compound", &[]));
|
||||
}
|
||||
set_key(&mut tag, "id", Tag::String(kind.into()));
|
||||
// EntityType.loadEntityRecursive snaps every entity of the stack,
|
||||
// passengers included, to the position.
|
||||
fn place(tag: &mut Tag, pos: [f64; 3]) {
|
||||
let p = pos.map(|v| Tag::Double(v.to_bits()));
|
||||
set_key(tag, "Pos", Tag::List(p.to_vec()));
|
||||
// Entity.load: an absent Fire reads as 0 (until the entity moves).
|
||||
if tag.get("Fire").is_none() {
|
||||
set_key(tag, "Fire", Tag::Short(0));
|
||||
}
|
||||
if tag.get("Rotation").is_none() {
|
||||
set_key(
|
||||
tag,
|
||||
"Rotation",
|
||||
Tag::List(vec![
|
||||
Tag::Float(0.0f32.to_bits()),
|
||||
Tag::Float(0.0f32.to_bits()),
|
||||
]),
|
||||
);
|
||||
}
|
||||
if let Tag::Compound(c) = tag
|
||||
&& let Some(Tag::List(riders)) = c.get_mut(&"Passengers".into())
|
||||
{
|
||||
for r in riders {
|
||||
if matches!(r, Tag::Compound(_)) {
|
||||
place(r, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
place(&mut tag, pos);
|
||||
place_summoned(&mut tag, pos);
|
||||
let from = self.mobs.len();
|
||||
let canceled = self.persist.joins_canceled;
|
||||
let target = self.spawn_entity_from_nbt(tag);
|
||||
|
||||
@@ -186,10 +186,13 @@ impl Game<'_> {
|
||||
_ => Some(None),
|
||||
};
|
||||
self.for_player(target, |g| {
|
||||
if g.spectator() {
|
||||
return false;
|
||||
}
|
||||
if let Some(name) = named {
|
||||
g.note_direct_entity(&ty, name);
|
||||
}
|
||||
!g.spectator() && g.hurt_player_from(amount, from, &ty, attacker)
|
||||
g.hurt_player_from(amount, from, &ty, attacker)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -150,6 +150,69 @@ impl Game<'_> {
|
||||
let _ = request.reply.send(reply.to_string());
|
||||
}
|
||||
|
||||
/// Text typed at the open screen's text box by the control socket's
|
||||
/// `type`: its `\b` is a backspace sentinel for the boxes that have always
|
||||
/// taken one, not the characters `\` and `b`. `Err` when no text box of
|
||||
/// the screen takes it.
|
||||
pub(super) fn type_into_screen(&mut self, text: &str) -> Result<()> {
|
||||
if text == "\\b" {
|
||||
match self.screen {
|
||||
Screen::WorldOptions => {
|
||||
self.world_options_text(None);
|
||||
return Ok(());
|
||||
}
|
||||
Screen::Inventory if self.mod_browser_typing() => {
|
||||
self.mod_browser_text(None, true);
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.type_chars_into_screen(text)
|
||||
}
|
||||
|
||||
/// Characters the open screen's focused text box takes: the control
|
||||
/// socket's `type` and a key press's surviving characters (`charTyped`).
|
||||
/// Nothing here is a key — the caller drops control characters. `Err` when
|
||||
/// no text box of the screen takes them.
|
||||
pub(super) fn type_chars_into_screen(&mut self, text: &str) -> Result<()> {
|
||||
if self.book.is_some() {
|
||||
self.book_type(text);
|
||||
return Ok(());
|
||||
}
|
||||
if self.sign_edit.is_some() {
|
||||
self.sign_input(None, Some(text));
|
||||
return Ok(());
|
||||
}
|
||||
match self.screen {
|
||||
Screen::Chat => self.chat_type(text),
|
||||
Screen::CommandBlock => self.command_block_type(text),
|
||||
Screen::BlockEditor => self.block_editor_type(text),
|
||||
Screen::DebugOptions => self.debug_options_type(text),
|
||||
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
|
||||
// The port, or the game rules' search and number boxes.
|
||||
Screen::WorldOptions => self.world_options_text(Some(text)),
|
||||
Screen::Inventory if self.mod_browser_typing() => {
|
||||
self.mod_browser_text(Some(text), false)
|
||||
}
|
||||
Screen::Inventory if self.recipe_search_focused() => {
|
||||
self.recipe_text(Some(text), false)
|
||||
}
|
||||
// The anvil's rename box, while it has an input to name.
|
||||
Screen::Inventory if self.station_typing() => self.station_text(Some(text), false),
|
||||
Screen::Creative if self.creative_screen.tab().search => {
|
||||
// As typed: replaces a selected query, keeps the length limit.
|
||||
self.creative_search_edit(Some(text))
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"type needs the chat screen (key t tap), a sign or book, the language search, the creative search, the anvil or World Options".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn control_command(
|
||||
&mut self,
|
||||
line: &str,
|
||||
@@ -371,6 +434,17 @@ impl Game<'_> {
|
||||
}
|
||||
// Native mods see the key as the keyboard hook does.
|
||||
let action = arg(2)?;
|
||||
// ScreenEvent.KeyPressed.Pre: the open screen hears of the
|
||||
// press before it acts on it, as the window's key event hands
|
||||
// it over. A cancelled press goes no further — not to the
|
||||
// screens, not to `native_key` — and its release still comes.
|
||||
if action != "up" && self.screen_key_pressed(physical, action == "repeat") {
|
||||
if action == "tap" {
|
||||
self.physical_keys.remove(&physical);
|
||||
self.native_key_event(physical, false, false);
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
self.native_key_event(physical, action != "up", action == "repeat");
|
||||
if action == "tap" {
|
||||
self.native_key_event(physical, false, false);
|
||||
@@ -644,6 +718,7 @@ impl Game<'_> {
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path: path.trim().into(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
});
|
||||
self.pending_screenshot = Some(reply.clone());
|
||||
Ok(None)
|
||||
@@ -674,48 +749,20 @@ impl Game<'_> {
|
||||
self.require_world()?;
|
||||
// Typed keys: the open screen's narration hears them.
|
||||
self.narration.keyboard_action();
|
||||
if self.book.is_some() {
|
||||
self.book_type(text);
|
||||
return ok();
|
||||
}
|
||||
if self.sign_edit.is_some() {
|
||||
self.sign_input(None, Some(text));
|
||||
return ok();
|
||||
}
|
||||
match self.screen {
|
||||
Screen::Chat => self.chat_type(text),
|
||||
Screen::CommandBlock => self.command_block_type(text),
|
||||
Screen::BlockEditor => self.block_editor_type(text),
|
||||
Screen::DebugOptions => self.debug_options_type(text),
|
||||
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
|
||||
// The port, or the game rules' search and number boxes.
|
||||
Screen::WorldOptions if text == "\\b" => self.world_options_text(None),
|
||||
Screen::WorldOptions => self.world_options_text(Some(text)),
|
||||
Screen::Inventory if self.mod_browser_typing() => {
|
||||
self.mod_browser_text((text != "\\b").then_some(text), text == "\\b")
|
||||
}
|
||||
Screen::Inventory if self.recipe_search_focused() => {
|
||||
self.recipe_text(Some(text), false)
|
||||
}
|
||||
// The anvil's rename box.
|
||||
Screen::Inventory
|
||||
if self
|
||||
.station
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.kind == super::stations::Kind::Anvil) =>
|
||||
{
|
||||
self.station_text(Some(text), false)
|
||||
}
|
||||
Screen::Creative if self.creative_screen.tab().search => {
|
||||
// As typed: replaces a selected query, keeps the length limit.
|
||||
self.creative_search_edit(Some(text))
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"type needs the chat screen (key t tap), a sign or book, the language search, the creative search, the anvil or World Options".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
// ScreenEvent.CharacterTyped.Pre: the open screen hears of
|
||||
// each character before a text box takes it, as the window's
|
||||
// key event fires it. A cancelled character is dropped, so
|
||||
// `type` types the survivors only.
|
||||
// `\b` is the socket's backspace sentinel, not two characters.
|
||||
let typed = if text == "\\b" {
|
||||
text.to_owned()
|
||||
} else {
|
||||
let Some(typed) = self.screen_text_typed(text) else {
|
||||
return ok();
|
||||
};
|
||||
typed
|
||||
};
|
||||
self.type_into_screen(&typed)?;
|
||||
ok()
|
||||
}
|
||||
"options" => {
|
||||
@@ -895,6 +942,15 @@ impl Game<'_> {
|
||||
);
|
||||
ok()
|
||||
}
|
||||
// A splitscreen player leaves splitscreen; only P1's game ends the process.
|
||||
"quit" if self.options.split_guest => {
|
||||
if self.remote.is_some() {
|
||||
self.leave_server(None)?;
|
||||
} else if let Some(title) = self.title.as_mut() {
|
||||
title.escape();
|
||||
}
|
||||
ok()
|
||||
}
|
||||
"quit" => {
|
||||
self.begin_closing_save(true)?;
|
||||
ok()
|
||||
|
||||
@@ -89,6 +89,12 @@ pub(super) struct ProjWorld<'a, 'w> {
|
||||
pub targets: Vec<(Who, Aabb)>,
|
||||
}
|
||||
impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
fn block_contacts(&self, entity: i32, kind: &str, from: Vec3, to: Vec3, bounds: Aabb) {
|
||||
horizon_client::block_contact::swept_inside(entity, kind, from, to, bounds, |p| {
|
||||
let state = self.w.world.block(p.x, p.y, p.z);
|
||||
(state, &self.w.facts.get(state).name)
|
||||
});
|
||||
}
|
||||
fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
|
||||
let mut scene = SceneCollisions {
|
||||
world: self.w.world,
|
||||
@@ -1060,7 +1066,14 @@ impl Game<'_> {
|
||||
bindings.retain(|id, _| live.contains(id));
|
||||
host.mob_players = bindings;
|
||||
}
|
||||
self.mobs.append(&mut mobs);
|
||||
// The mobs added during the tick (into the emptied `self.mobs`) come
|
||||
// first, as `append` put them; with none, the ticked list goes back
|
||||
// as it is instead of being copied into a new one, every mob a tick.
|
||||
if self.mobs.is_empty() {
|
||||
self.mobs = mobs;
|
||||
} else {
|
||||
self.mobs.append(&mut mobs);
|
||||
}
|
||||
self.died = died;
|
||||
// ... and entity event 60 goes out as it is: the poof.
|
||||
for (id, at, width, height) in gone {
|
||||
@@ -1201,7 +1214,16 @@ impl Game<'_> {
|
||||
self.villager_murdered(m, slain_by_player, killer.flatten());
|
||||
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
|
||||
let killer_entity = self.killer_loot_entity(killer.flatten());
|
||||
self.drop_death_loot(&mut dying, by_player, killer_entity, consumed, pos);
|
||||
// LAST_DAMAGE_PLAYER only while the player-hurt memory lasts
|
||||
// (LivingEntity.dropFromLootTable's playerKilled): a mob that dies to
|
||||
// the environment long after a player's hit names no player.
|
||||
let player = killer
|
||||
.filter(|_| m.ai.last_hurt_by_player > 0)
|
||||
.and_then(|conn| self.killer_uuid(conn));
|
||||
if let Some(player) = player.filter(|_| m.extra.leash.is_some()) {
|
||||
self.lead_killers.insert(m.id, (player, self.dim));
|
||||
}
|
||||
self.drop_death_loot(&mut dying, player, by_player, killer_entity, consumed, pos);
|
||||
self.create_wither_rose(m);
|
||||
if m.species() == Species::CopperGolem {
|
||||
// CopperGolem.dropEquipment: the preserved equipment too.
|
||||
@@ -1318,9 +1340,26 @@ impl Game<'_> {
|
||||
}
|
||||
own(self)
|
||||
}
|
||||
/// The UUID of the player `last_hurt_by_player` keeps: the guest on
|
||||
/// connection `conn` (None once they left), else the player in place
|
||||
/// (None on a server, which has none of its own).
|
||||
fn killer_uuid(&mut self, conn: Option<u32>) -> Option<u128> {
|
||||
match conn {
|
||||
// During that guest's own turn, the hub is out of `lan`.
|
||||
Some(conn) if self.lan_acting != Some(conn) => {
|
||||
Some(self.lan.as_ref()?.hub.guest(conn)?.uuid)
|
||||
}
|
||||
Some(_) => Some(self.native_acting_id()),
|
||||
None if self.lan_acting.is_none() && self.player.is_some() => {
|
||||
Some(self.native_acting_id())
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
fn drop_death_loot(
|
||||
&mut self,
|
||||
m: &mut Mob,
|
||||
player_uuid: Option<u128>,
|
||||
by_player: bool,
|
||||
killer: horizon_world::loot::LootEntity,
|
||||
consumed: bool,
|
||||
@@ -1403,22 +1442,49 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let raider = horizon_client::mobs::raider::is_raider(m.species());
|
||||
// The item drops come before the carried block and the orbs, so the
|
||||
// mods get them all at once and they still spawn in that order.
|
||||
let mut stacks = Vec::new();
|
||||
let mut rest = Vec::new();
|
||||
for d in items {
|
||||
match d {
|
||||
drops::Drop::Item(stack) => {
|
||||
let item = if raider && stack.item == horizon_client::mobs::raider::BANNER {
|
||||
drops::Drop::Item(stack) => stacks.push(
|
||||
if raider && stack.item == horizon_client::mobs::raider::BANNER {
|
||||
horizon_client::mobs::raider::ominous_banner()
|
||||
} else {
|
||||
from_loot(&stack)
|
||||
};
|
||||
// Every death drop is its own ItemEntity, and the drop
|
||||
// entity's own random (not the level's) gives it the
|
||||
// constructor's horizontal push (its motion otherwise
|
||||
// stays [0, 0.2, 0] and the drops land stacked at the
|
||||
// mob's centre, without disturbing the level random that
|
||||
// the orbs' merge ids and block ticks read next).
|
||||
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
|
||||
}
|
||||
},
|
||||
),
|
||||
drops::Drop::Stack(stack) => stacks.push(stack),
|
||||
other => rest.push(other),
|
||||
}
|
||||
}
|
||||
let kind = super::native_hooks::mob_kind(m);
|
||||
let table = drops::death_loot_table(m, death.mob_drops).filter(|t| loot.has_table(t));
|
||||
let context = super::native_hooks::NativeLootDrops {
|
||||
table: table.as_deref().unwrap_or_default(),
|
||||
dimension: self.dim.id(),
|
||||
origin: [m.position.x, m.position.y, m.position.z],
|
||||
block: None,
|
||||
entity: Some((m.id, &kind)),
|
||||
player: player_uuid,
|
||||
tool: None,
|
||||
explosion: None,
|
||||
used: false,
|
||||
};
|
||||
self.native_loot_drops(&context, &mut stacks);
|
||||
stacks.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
|
||||
for item in stacks {
|
||||
// Every death drop is its own ItemEntity, and the drop entity's
|
||||
// own random (not the level's) gives it the constructor's
|
||||
// horizontal push (its motion otherwise stays [0, 0.2, 0] and
|
||||
// the drops land stacked at the mob's centre, without disturbing
|
||||
// the level random that the orbs' merge ids and block ticks read
|
||||
// next).
|
||||
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
|
||||
}
|
||||
for d in rest {
|
||||
match d {
|
||||
drops::Drop::CarriedBlock(state) => {
|
||||
// Enderman.dropCustomDeathLoot: spawnAtLocation for each
|
||||
// of the carried block's silk-touch drops.
|
||||
@@ -1434,7 +1500,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
|
||||
drops::Drop::Orb(_) => {}
|
||||
drops::Drop::Item(_) | drops::Drop::Stack(_) | drops::Drop::Orb(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1599,6 +1665,15 @@ impl Game<'_> {
|
||||
self.next_entity += 1;
|
||||
p.id = self.next_entity;
|
||||
}
|
||||
// Projectile.setOwner: the acting player's or the mob's UUID, which
|
||||
// its save keeps even once the owner is gone.
|
||||
if p.owner_uuid.is_none() {
|
||||
p.owner_uuid = match p.owner {
|
||||
Some(Who::Player) => Some(self.acting_player_uuid()),
|
||||
Some(Who::Mob(id)) => Some(self.uuid_for(id)),
|
||||
None => None,
|
||||
};
|
||||
}
|
||||
self.creatures.projectiles.push(p);
|
||||
}
|
||||
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
|
||||
@@ -2081,6 +2156,27 @@ impl Game<'_> {
|
||||
// `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop
|
||||
// above trampled farmland breaks and drops.
|
||||
MobEvent::SetBlock { pos, state } => self.level_set(pos, state)?,
|
||||
MobEvent::CropRaided {
|
||||
pos,
|
||||
state,
|
||||
old,
|
||||
mob,
|
||||
} => {
|
||||
self.level_set_with_flags(
|
||||
pos,
|
||||
state,
|
||||
horizon_world::level::flags::UPDATE_CLIENTS,
|
||||
)?;
|
||||
if let Some(old) = old {
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_change",
|
||||
pos,
|
||||
Some(Who::Mob(mob)),
|
||||
None,
|
||||
);
|
||||
self.level_event_at(2001, pos, old.0 as i32);
|
||||
}
|
||||
}
|
||||
MobEvent::CrackTurtleEgg { pos } => {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let eggs = self
|
||||
@@ -2218,13 +2314,14 @@ impl Game<'_> {
|
||||
self.destroy_particles(pos, old);
|
||||
}
|
||||
if drop {
|
||||
let drops = world_items!(self).block_drops_in(
|
||||
let mut drops = world_items!(self).block_drops_in(
|
||||
self.pack.assets.registry,
|
||||
old,
|
||||
None,
|
||||
&mut self.world.random,
|
||||
Some((&mut self.rand_sequences, self.level.seed)),
|
||||
);
|
||||
self.native_block_drops(pos, old, None, None, None, &mut drops);
|
||||
for d in drops {
|
||||
self.pop_resource(pos, d);
|
||||
}
|
||||
@@ -2587,7 +2684,7 @@ impl Game<'_> {
|
||||
&mut self,
|
||||
feet: [f64; 3],
|
||||
conn: Option<u32>,
|
||||
bindings: &std::collections::HashMap<u32, u32>,
|
||||
bindings: &horizon_client::fast_hash::FxHashMap<u32, u32>,
|
||||
) {
|
||||
// A client of someone else's world has the server's mobs only to show.
|
||||
if self.remote.is_some() || !self.ui.rules.bool("forgive_dead_players") {
|
||||
@@ -2735,6 +2832,15 @@ impl Game<'_> {
|
||||
if p.species() == Species::Fox {
|
||||
child.extra.main_hand = None;
|
||||
}
|
||||
// Fox.FoxBreedGoal.breed: the baby trusts the players who fed its
|
||||
// parents into love.
|
||||
if p.species() == Species::Fox {
|
||||
horizon_client::mobs::fox::trust_breeders(
|
||||
child,
|
||||
p.extra.love_cause,
|
||||
q.as_ref().and_then(|q| q.extra.love_cause),
|
||||
);
|
||||
}
|
||||
// Goat.getBreedOffspring: GoatAi.initMemories, then the baby
|
||||
// screams if the parent a coin toss picks does, or on a 2% roll.
|
||||
if p.species() == Species::Goat {
|
||||
@@ -2966,6 +3072,7 @@ impl Game<'_> {
|
||||
let rocket = std::mem::take(&mut self.combat.rocket_hit);
|
||||
// ServerPlayer.canHarmPlayer refused another player's projectile.
|
||||
if self.creative || self.combat.refuse_player_hit {
|
||||
self.combat.forget_noted(false);
|
||||
return false;
|
||||
}
|
||||
// A projectile entity dealt it: `#is_projectile`, or a firework
|
||||
@@ -2999,7 +3106,6 @@ impl Game<'_> {
|
||||
}),
|
||||
};
|
||||
// The causing entity for the death message.
|
||||
let marks = self.pending_marks();
|
||||
let owner = self.combat.projectile_owner.take();
|
||||
if let Some(a) = attacker.or(owner) {
|
||||
self.note_attacker(damage_type, a);
|
||||
@@ -3067,9 +3173,7 @@ impl Game<'_> {
|
||||
// LivingEntity.hurtServer: ENTITY_HURT_PLAYER, unless it returned
|
||||
// early (invulnerable, or inside the hurt cooldown).
|
||||
// A hit a native mod canceled records no damage (hurtServer never ran).
|
||||
if outcome.canceled {
|
||||
self.drop_pending_since(marks);
|
||||
}
|
||||
self.combat.forget_noted(outcome.canceled);
|
||||
if outcome.hurt || outcome.sound {
|
||||
let source = attacker
|
||||
.or(owner)
|
||||
@@ -3152,14 +3256,21 @@ impl Game<'_> {
|
||||
self.give_player_effect("minecraft:wither", 40, 0);
|
||||
}
|
||||
pub(super) fn hurt_player_by(&mut self, damage_type: &str, amount: f32) {
|
||||
if self.creative {
|
||||
return;
|
||||
}
|
||||
if let Some(player) = self.player.as_mut()
|
||||
let mut canceled = false;
|
||||
if !self.creative
|
||||
&& let Some(player) = self.player.as_mut()
|
||||
&& let Some(id) = self.motion.health.damage.id(damage_type)
|
||||
&& let Ok(o) = player.hurt_blocking(
|
||||
&self.motion.health,
|
||||
self.world.health_rules,
|
||||
id,
|
||||
amount,
|
||||
None,
|
||||
)
|
||||
{
|
||||
let _ = player.hurt(&self.motion.health, self.world.health_rules, id, amount);
|
||||
canceled = o.canceled;
|
||||
}
|
||||
self.combat.forget_noted(canceled);
|
||||
}
|
||||
/// `Player.aiStep` touching arrows in its box grown by (1, 0.5, 1):
|
||||
/// `AbstractArrow.playerTouch`, then `Player.take`'s pickup sound at the
|
||||
@@ -3411,9 +3522,10 @@ impl Game<'_> {
|
||||
} = hit
|
||||
&& let Some(j) = wind_charge_hit(id)
|
||||
{
|
||||
let (owner, guest) = (p.owner, p.guest);
|
||||
let (owner, owner_uuid, guest) = (p.owner, p.owner_uuid, p.guest);
|
||||
let look = self.owner_look(owner, guest);
|
||||
super::deflect::aim_deflect(&mut projectiles[j], look, owner, guest);
|
||||
let wind = &mut projectiles[j];
|
||||
super::deflect::aim_deflect(wind, look, owner, owner_uuid, guest);
|
||||
}
|
||||
}
|
||||
let mut idx = 0;
|
||||
@@ -4783,6 +4895,9 @@ impl Game<'_> {
|
||||
let feet = [m.position.x, m.position.y, m.position.z];
|
||||
let mut handled = true;
|
||||
let mut drops: Vec<(String, u32)> = Vec::new();
|
||||
// The shearing table rolled, for the mods (none for a mooshroom).
|
||||
let mut drops_table = String::new();
|
||||
let mob = (m.id, super::native_hooks::mob_kind(m));
|
||||
// `Sheep.shear`'s drops add a spread on top of the ItemEntity's own
|
||||
// motion; `MushroomCow.shear`'s do not.
|
||||
let mut wool = false;
|
||||
@@ -4872,8 +4987,9 @@ impl Game<'_> {
|
||||
Species::Sheep if item == "minecraft:shears" && !m.extra.sheared && !m.is_baby() => {
|
||||
m.extra.sheared = true;
|
||||
let color = horizon_client::mobs::render::DYE_NAMES[m.extra.color as usize & 15];
|
||||
drops_table = format!("minecraft:shearing/sheep/{color}");
|
||||
let stacks = world_items!(self).loot_table(
|
||||
&format!("minecraft:shearing/sheep/{color}"),
|
||||
&drops_table,
|
||||
false,
|
||||
None,
|
||||
&mut self.world.random,
|
||||
@@ -5258,7 +5374,7 @@ impl Game<'_> {
|
||||
{
|
||||
// Animal.mobInteract: breed adults, speed up babies, then
|
||||
// playEatingSound (silent unless the species overrides it).
|
||||
match feed_animal(m, &mut self.world.random) {
|
||||
match feed_animal(m, &mut self.world.random, Some(acting)) {
|
||||
Some(sound) => {
|
||||
consume = true;
|
||||
eat = sound;
|
||||
@@ -5312,6 +5428,7 @@ impl Game<'_> {
|
||||
spread,
|
||||
speed: 0.2,
|
||||
count: count as i32,
|
||||
randomization: 0,
|
||||
});
|
||||
}
|
||||
// PlayerInteractTrigger: the item used on the mob, once the interaction took.
|
||||
@@ -5373,8 +5490,22 @@ impl Game<'_> {
|
||||
self.trigger_item_entity("minecraft:player_sheared_equipment", &stack, &target);
|
||||
self.spawn_item(stack, [center[0], center[1] + 0.5, center[2]], [0.0; 3], 10);
|
||||
}
|
||||
for (item, count) in drops {
|
||||
for _ in 0..count {
|
||||
let mut drops: Vec<ItemStack> = drops
|
||||
.into_iter()
|
||||
.map(|(item, count)| ItemStack::new(&item, count))
|
||||
.collect();
|
||||
if !drops.is_empty() {
|
||||
self.native_use_drops(
|
||||
&drops_table,
|
||||
feet,
|
||||
None,
|
||||
Some((mob.0, &mob.1)),
|
||||
held.as_ref(),
|
||||
&mut drops,
|
||||
);
|
||||
}
|
||||
for stack in drops {
|
||||
for _ in 0..stack.count {
|
||||
// `spawnAtLocation(stack, 1.0)`: an ItemEntity a block above
|
||||
// the feet that starts with the constructor's own motion, and
|
||||
// `setDefaultPickUpDelay`. Sheep.shear adds its spread on top;
|
||||
@@ -5392,10 +5523,8 @@ impl Game<'_> {
|
||||
}
|
||||
self.spawn_item(
|
||||
ItemStack {
|
||||
item: item.as_str().into(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
..stack.clone()
|
||||
},
|
||||
[feet[0], feet[1] + 1.0, feet[2]],
|
||||
v,
|
||||
@@ -5427,10 +5556,21 @@ impl Game<'_> {
|
||||
_ => return false,
|
||||
};
|
||||
let at = m.position;
|
||||
let mob = (m.id, super::native_hooks::mob_kind(m));
|
||||
let tool = self.held().cloned();
|
||||
self.audio.play(sound, Some([at.x, at.y, at.z]), 1.0, 1.0);
|
||||
self.mobs[index].extra.sheared = true;
|
||||
for stack in self.sequence_loot(table, table, tool.as_ref()) {
|
||||
let mut stacks = self.sequence_loot(table, table, tool.as_ref());
|
||||
let origin = [at.x, at.y, at.z];
|
||||
self.native_use_drops(
|
||||
table,
|
||||
origin,
|
||||
None,
|
||||
Some((mob.0, &mob.1)),
|
||||
tool.as_ref(),
|
||||
&mut stacks,
|
||||
);
|
||||
for stack in stacks {
|
||||
self.spawn_at_location(at, height, stack);
|
||||
}
|
||||
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
|
||||
@@ -5453,7 +5593,17 @@ impl Game<'_> {
|
||||
let feet = [at.x, at.y, at.z];
|
||||
let tool = self.held().cloned();
|
||||
let table = "minecraft:brush/armadillo";
|
||||
for d in self.sequence_loot(table, table, tool.as_ref()) {
|
||||
let mut stacks = self.sequence_loot(table, table, tool.as_ref());
|
||||
let kind = super::native_hooks::mob_kind(&self.mobs[index]);
|
||||
self.native_use_drops(
|
||||
table,
|
||||
feet,
|
||||
None,
|
||||
Some((id, &kind)),
|
||||
tool.as_ref(),
|
||||
&mut stacks,
|
||||
);
|
||||
for d in stacks {
|
||||
self.spawn_at_location(at, 0.0, d);
|
||||
}
|
||||
self.audio
|
||||
@@ -5577,6 +5727,15 @@ impl Game<'_> {
|
||||
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
|
||||
}
|
||||
// `Mob.checkAndHandleImportantInteractions` then calls
|
||||
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
|
||||
// who used the egg (a fox egg used on a block trusts nobody).
|
||||
if species == Species::Fox {
|
||||
let spawner = self.acting_player_uuid();
|
||||
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::mobs::fox::add_trusted_entity(child, spawner);
|
||||
}
|
||||
}
|
||||
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
|
||||
self.consume_held();
|
||||
true
|
||||
|
||||
@@ -89,10 +89,8 @@ impl Game<'_> {
|
||||
}
|
||||
if stack.data().is_some_and(|d| !d.charged.is_empty()) {
|
||||
self.shoot_crossbow();
|
||||
} else if (self.projectile_slot(CROSSBOW).is_some() || self.creative)
|
||||
&& self.using.is_none()
|
||||
{
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
} else if self.projectile_slot(CROSSBOW).is_some() || self.creative {
|
||||
self.start_using_item();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -976,8 +976,13 @@ impl Game<'_> {
|
||||
(_, true) => ((0.25, 0.9875), 0.9875),
|
||||
_ => ((0.5, 1.975), 1.7775),
|
||||
};
|
||||
let p = a.position;
|
||||
g.hitbox([p.x, p.y, p.z], size, eye, true, (0.0, a.yaw));
|
||||
g.hitbox(
|
||||
at(a.previous, a.position),
|
||||
size,
|
||||
eye,
|
||||
true,
|
||||
(0.0, a.render_yaw(alpha)),
|
||||
);
|
||||
}
|
||||
for b in &self.boats {
|
||||
let h = super::boats::HEIGHT as f32;
|
||||
|
||||
@@ -1583,7 +1583,11 @@ impl Game<'_> {
|
||||
_ => {
|
||||
// The game's dispatcher, captured for the console.
|
||||
self.chat_capture = Some(Vec::new());
|
||||
self.as_console(|g| g.run_command(line));
|
||||
// A command a native mod registered goes to the mods first,
|
||||
// looked up by the same first word the match above read.
|
||||
if !self.perform_native_console_command(line.trim()) {
|
||||
self.as_console(|g| g.run_command(line));
|
||||
}
|
||||
for text in self.chat_capture.take().unwrap_or_default() {
|
||||
self.console_reply(text);
|
||||
}
|
||||
|
||||
@@ -34,14 +34,14 @@ pub(super) fn aim_deflect(
|
||||
p: &mut Projectile,
|
||||
look: Option<Vec3>,
|
||||
owner: Option<Who>,
|
||||
owner_uuid: Option<[i32; 4]>,
|
||||
guest: Option<u32>,
|
||||
) {
|
||||
if let Some(look) = look {
|
||||
p.velocity = look;
|
||||
p.needs_sync = true;
|
||||
}
|
||||
p.owner = owner;
|
||||
p.guest = guest;
|
||||
(p.owner, p.owner_uuid, p.guest) = (owner, owner_uuid, guest);
|
||||
p.acceleration = 0.1;
|
||||
}
|
||||
|
||||
@@ -196,14 +196,14 @@ impl Game<'_> {
|
||||
/// `Projectile.deflect(AIM_DEFLECT, player, player, true)`.
|
||||
pub(super) fn deflect(&mut self, i: usize) {
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let guest = self.lan_acting;
|
||||
let (guest, owner_uuid) = (self.lan_acting, Some(self.acting_player_uuid()));
|
||||
let p = &mut self.creatures.projectiles[i];
|
||||
p.velocity = Vec3::new(dir[0], dir[1], dir[2]);
|
||||
p.yaw = dir[0].atan2(dir[2]).to_degrees() as f32;
|
||||
p.pitch = dir[1].atan2(dir[0].hypot(dir[2])).to_degrees() as f32;
|
||||
p.owner = Some(Who::Player);
|
||||
// On a server, the guest who struck it (their projectile now).
|
||||
p.guest = guest;
|
||||
(p.guest, p.owner_uuid) = (guest, owner_uuid);
|
||||
// Not into the one who turned it on its way out.
|
||||
p.left_owner = false;
|
||||
// `AbstractHurtingProjectile.onDeflection` by an attack, and the
|
||||
@@ -274,9 +274,9 @@ mod tests {
|
||||
fn a_redirected_wind_charge_flies_along_the_new_owners_look() {
|
||||
let mut p = wind_charge();
|
||||
let look = Vec3::new(0.0, -1.0, 0.0);
|
||||
aim_deflect(&mut p, Some(look), Some(Who::Player), Some(3));
|
||||
aim_deflect(&mut p, Some(look), Some(Who::Player), Some([4; 4]), Some(3));
|
||||
assert_eq!(p.velocity, look);
|
||||
assert_eq!(p.owner, Some(Who::Player));
|
||||
assert_eq!((p.owner, p.owner_uuid), (Some(Who::Player), Some([4; 4])));
|
||||
assert_eq!(p.guest, Some(3));
|
||||
assert_eq!(p.acceleration, 0.1);
|
||||
assert!(p.needs_sync);
|
||||
@@ -285,9 +285,10 @@ mod tests {
|
||||
#[test]
|
||||
fn with_no_owner_here_it_keeps_its_movement_but_changes_hands() {
|
||||
let mut p = wind_charge();
|
||||
aim_deflect(&mut p, None, None, None);
|
||||
p.owner_uuid = Some([7; 4]);
|
||||
aim_deflect(&mut p, None, None, None, None);
|
||||
assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0));
|
||||
assert_eq!(p.owner, None);
|
||||
assert_eq!((p.owner, p.owner_uuid), (None, None));
|
||||
assert_eq!(p.acceleration, 0.1);
|
||||
assert!(!p.needs_sync);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ use horizon_client::discord_presence::{self as presence, Dimension, Play, Status
|
||||
impl Game<'_> {
|
||||
/// Each frame: only a change reaches the presence thread.
|
||||
pub(super) fn update_discord_presence(&self) {
|
||||
// One Discord status per computer: P1's, not a splitscreen player's.
|
||||
if self.options.split_guest {
|
||||
return;
|
||||
}
|
||||
presence::set(self.discord_status());
|
||||
}
|
||||
fn discord_status(&self) -> Status {
|
||||
|
||||
@@ -67,6 +67,15 @@ fn y_rot(d: Direction) -> f32 {
|
||||
_ => 270.0,
|
||||
}
|
||||
}
|
||||
/// `DirectionalPlaceContext.getRotation`: `get2DDataValue() * 90`, so a
|
||||
/// vertical direction (`data2d = -1`) gives -90 where `toYRot` gives 270: the
|
||||
/// same horizontal facing, but the raw value a mod's placement hook reads.
|
||||
fn place_rotation(d: Direction) -> f32 {
|
||||
match d {
|
||||
Direction::Up | Direction::Down => -90.0,
|
||||
_ => y_rot(d),
|
||||
}
|
||||
}
|
||||
fn block_box(p: Pos) -> Aabb {
|
||||
Aabb::new(
|
||||
Vec3::new(f64::from(p.0), f64::from(p.1), f64::from(p.2)),
|
||||
@@ -507,7 +516,7 @@ impl Game<'_> {
|
||||
/// Drop one item as DefaultDispenseItemBehavior does. For a dispenser hook:
|
||||
/// return this remainder in its answer with `CLICK`; the caller plays the
|
||||
/// answer's event and smoke once. A client, another dimension or an unloaded
|
||||
/// chunk keeps the input stack unchanged. API @API:dispenser_behaviour@.
|
||||
/// chunk keeps the input stack unchanged. API 67.
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn native_dispense_default(
|
||||
&mut self,
|
||||
@@ -1252,22 +1261,7 @@ impl Game<'_> {
|
||||
BucketContent::Fluid(FluidKind::Water) | BucketContent::MobWater
|
||||
)
|
||||
{
|
||||
// The water evaporates in a cloud of smoke.
|
||||
let c = Self::center(target);
|
||||
for _ in 0..8 {
|
||||
let r = self.particles.random();
|
||||
let p = [
|
||||
f64::from(target.0) + (r.next_double() * 2.0 - 1.0),
|
||||
f64::from(target.1) + (r.next_double() * 2.0 - 1.0),
|
||||
f64::from(target.2) + (r.next_double() * 2.0 - 1.0),
|
||||
];
|
||||
let _ = c;
|
||||
self.particles.add(
|
||||
&ParticleOptions::simple(ParticleType::LargeSmoke),
|
||||
p,
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
self.evaporation_smoke([target.0, target.1, target.2]);
|
||||
}
|
||||
if let Some(kind) = mob {
|
||||
// MobBucketItem.checkExtraContent: the mob (from the bucket, so
|
||||
@@ -1581,7 +1575,7 @@ impl Game<'_> {
|
||||
f64::from(front.2) + 0.5,
|
||||
),
|
||||
inside: false,
|
||||
yaw: y_rot(facing),
|
||||
yaw: place_rotation(facing),
|
||||
pitch: 0.0,
|
||||
sneaking: false,
|
||||
item: stack.item.to_string(),
|
||||
|
||||
@@ -2436,13 +2436,17 @@ impl Game<'_> {
|
||||
}
|
||||
let (health, rules) = (&self.motion.health, self.world.health_rules);
|
||||
let id = health.damage.id("minecraft:mob_attack");
|
||||
let mut canceled = false;
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
push_seen_with_hurt(p, push, |p| {
|
||||
if let Some(id) = id.filter(|_| hurts) {
|
||||
let _ = p.hurt(health, rules, id, 5.0);
|
||||
if let Some(id) = id.filter(|_| hurts)
|
||||
&& let Ok(o) = p.hurt_blocking(health, rules, id, 5.0, None)
|
||||
{
|
||||
canceled = o.canceled;
|
||||
}
|
||||
});
|
||||
}
|
||||
self.combat.forget_noted(canceled);
|
||||
}
|
||||
/// Damage from the dragon, which is its causing entity
|
||||
/// (`DamageSources.mobAttack(dragon)`, `indirectMagic(cloud, dragon)`): the
|
||||
|
||||
@@ -175,6 +175,7 @@ impl Game<'_> {
|
||||
};
|
||||
let Some(m) = self.mobs.get(mob) else { return };
|
||||
let kind = m.kind.id.clone();
|
||||
let victim = m.id;
|
||||
let ctx = EffectContext {
|
||||
this: Some(&kind),
|
||||
attacker: Some("minecraft:player"),
|
||||
@@ -187,6 +188,7 @@ impl Game<'_> {
|
||||
// ignite (fire aspect) and apply_mob_effect (bane of arthropods).
|
||||
let mut ignite = 0.0f32;
|
||||
let mut mob_effects = Vec::new();
|
||||
let mut hook_calls: Vec<(String, String, Value, u32)> = Vec::new();
|
||||
for (id, level) in held.enchantments() {
|
||||
let Some(def) = world_stations!(self).enchantments.get(id) else {
|
||||
continue;
|
||||
@@ -197,14 +199,17 @@ impl Game<'_> {
|
||||
.flatten()
|
||||
{
|
||||
let e = &effect["effect"];
|
||||
let kind_of = e["type"].as_str();
|
||||
if effect["enchanted"] != "attacker"
|
||||
|| effect["affected"] != "victim"
|
||||
|| !matches!(
|
||||
kind_of,
|
||||
Some("minecraft:ignite" | "minecraft:apply_mob_effect")
|
||||
)
|
||||
{
|
||||
if effect["enchanted"] != "attacker" || effect["affected"] != "victim" {
|
||||
continue;
|
||||
}
|
||||
// A bare type name is minecraft's own, as a bare item id is.
|
||||
let kind_of = match e["type"].as_str() {
|
||||
Some(k) if k.contains(':') => k.to_owned(),
|
||||
Some(k) => format!("minecraft:{k}"),
|
||||
None => continue,
|
||||
};
|
||||
let own = !kind_of.starts_with("minecraft:");
|
||||
if !own && !matches!(&*kind_of, "minecraft:ignite" | "minecraft:apply_mob_effect") {
|
||||
continue;
|
||||
}
|
||||
let mut random = self.world.random.clone();
|
||||
@@ -218,7 +223,11 @@ impl Game<'_> {
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
if kind_of == Some("minecraft:ignite") {
|
||||
// A mod's own kind: its hook answers, after this loop (the
|
||||
// stations borrow is held here).
|
||||
if own {
|
||||
hook_calls.push((id.to_string(), kind_of, e.clone(), *level));
|
||||
} else if kind_of == "minecraft:ignite" {
|
||||
ignite =
|
||||
ignite.max(horizon_client::enchant::level_value(&e["duration"], *level));
|
||||
} else {
|
||||
@@ -235,6 +244,9 @@ impl Game<'_> {
|
||||
for (effect, level) in mob_effects {
|
||||
self.apply_mob_effect(mob, &effect, level);
|
||||
}
|
||||
for (id, kind_of, data, level) in hook_calls {
|
||||
self.native_enchant_effect(&id, &kind_of, &data, level, victim);
|
||||
}
|
||||
self.post_attack_bursts(&held, &kind);
|
||||
}
|
||||
/// `EnchantmentHelper.doPostPiercingAttackEffects`: the
|
||||
|
||||
@@ -392,9 +392,7 @@ impl Game<'_> {
|
||||
// EnderEyeItem.use: startUsingItem (getUseDuration 0, so the use
|
||||
// lasts until the button lets go), then the eye is thrown towards
|
||||
// the nearest stronghold if there is one (CONSUME without).
|
||||
if self.using.is_none() {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
}
|
||||
self.start_using_item();
|
||||
self.throw_ender_eye();
|
||||
return Ok(true);
|
||||
};
|
||||
|
||||
@@ -17,6 +17,9 @@ impl Game<'_> {
|
||||
// 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(),
|
||||
// A native pack's shadow pass has no spans to hide entities with: it only needs
|
||||
// the first-person model.
|
||||
(Some(r), None) => (false, r.native_shadow()),
|
||||
_ => (false, false),
|
||||
};
|
||||
let at = camera.to_array().map(|v| v.floor() as i32);
|
||||
|
||||
@@ -17,13 +17,14 @@ const WOODS: [&str; 11] = [
|
||||
"pale_oak",
|
||||
"test_wood",
|
||||
];
|
||||
const CARTS: [&str; 6] = [
|
||||
const CARTS: [&str; 7] = [
|
||||
"minecart",
|
||||
"chest_minecart",
|
||||
"hopper_minecart",
|
||||
"furnace_minecart",
|
||||
"tnt_minecart",
|
||||
"command_block_minecart",
|
||||
"spawner_minecart",
|
||||
];
|
||||
|
||||
fn fixture() -> Value {
|
||||
@@ -178,7 +179,7 @@ fn entity_model_batches_preserve_parts_missing_layers_and_load_failures() {
|
||||
let mut all = fixture();
|
||||
fs::write(&file, serde_json::to_vec(&all).unwrap()).unwrap();
|
||||
assert_equivalent(&root.0);
|
||||
assert_eq!(optimized(&root.0).len(), 42);
|
||||
assert_eq!(optimized(&root.0).len(), 43);
|
||||
all.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("minecraft:armor_stand_small#main");
|
||||
@@ -187,7 +188,7 @@ fn entity_model_batches_preserve_parts_missing_layers_and_load_failures() {
|
||||
.remove("minecraft:boat/oak#main");
|
||||
fs::write(&file, serde_json::to_vec(&all).unwrap()).unwrap();
|
||||
assert_equivalent(&root.0);
|
||||
assert_eq!(optimized(&root.0).len(), 40);
|
||||
assert_eq!(optimized(&root.0).len(), 41);
|
||||
let document = EntityModelDocument::load(&root.0).unwrap();
|
||||
let mut first = document.model("minecraft:armor_stand#main").unwrap();
|
||||
first.children.clear();
|
||||
|
||||
@@ -434,6 +434,7 @@ impl Game<'_> {
|
||||
pushed = (!p.health.sync_velocity).then_some(push);
|
||||
}
|
||||
}
|
||||
self.combat.forget_noted(outcome.canceled);
|
||||
if raised {
|
||||
self.after_block(outcome, None, false);
|
||||
}
|
||||
@@ -941,7 +942,7 @@ impl Game<'_> {
|
||||
// tables' survives_explosion and explosion_decay draw from their
|
||||
// random sequence.
|
||||
let decay = (interaction == BlockInteraction::DestroyWithDecay).then_some(radius);
|
||||
let drops = world_items!(self).block_drops_exploded(
|
||||
let mut drops = world_items!(self).block_drops_exploded(
|
||||
self.pack.assets.registry,
|
||||
state,
|
||||
None,
|
||||
@@ -951,6 +952,7 @@ impl Game<'_> {
|
||||
decay,
|
||||
false,
|
||||
);
|
||||
self.native_block_drops(pos, state, None, None, Some(radius), &mut drops);
|
||||
for mut stack in drops {
|
||||
if &*stack.item == "minecraft:air" || stack.count == 0 {
|
||||
continue;
|
||||
|
||||
@@ -490,6 +490,7 @@ impl Game<'_> {
|
||||
spread,
|
||||
speed,
|
||||
count: count as i32,
|
||||
randomization: 0,
|
||||
});
|
||||
}
|
||||
/// `FishingHook.tick`.
|
||||
|
||||
@@ -156,6 +156,7 @@ impl Game<'_> {
|
||||
};
|
||||
let tilt = self.settings.file.double("damageTiltStrength");
|
||||
glam::Mat4::from_cols_array(&bob_matrix(&state, tilt, self.settings.view_bobbing).m)
|
||||
* self.native_shake_matrix(partial)
|
||||
}
|
||||
/// `AbstractClientPlayer.tick` and `aiStep` (`ClientAvatarState.tick`,
|
||||
/// `updateBob`), `rideTick`'s `resetBob` and `LocalPlayer.move`'s walked
|
||||
@@ -183,8 +184,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `SpyglassItem.use`: start scoping.
|
||||
pub(super) fn use_spyglass(&mut self) {
|
||||
if self.using.is_none() {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
if self.start_using_item() {
|
||||
let at = self.eye_position();
|
||||
self.audio.play("minecraft:item.spyglass.use", at, 1.0, 1.0);
|
||||
}
|
||||
|
||||
@@ -300,19 +300,33 @@ impl Game<'_> {
|
||||
let s = f.step().map(f64::from);
|
||||
let c = center(f);
|
||||
let at = [c[0] + s[0] * 0.15, c[1], c[2] + s[2] * 0.15];
|
||||
let mut drops = Vec::new();
|
||||
if with_frame {
|
||||
let frame = ItemStack {
|
||||
drops.push(ItemStack {
|
||||
item: Arc::from(f.kind()),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
self.spawn_item(frame, at, [0.0; 3], 10);
|
||||
});
|
||||
}
|
||||
if let Some(item) = f.item.clone()
|
||||
&& self.world.random.next_float() < f.drop_chance
|
||||
{
|
||||
self.spawn_item(item, at, [0.0; 3], 10);
|
||||
drops.push(item);
|
||||
}
|
||||
if by_player && !drops.is_empty() {
|
||||
let tool = self.held().cloned();
|
||||
self.native_use_drops(
|
||||
"",
|
||||
c,
|
||||
None,
|
||||
Some((f.id, f.kind())),
|
||||
tool.as_ref(),
|
||||
&mut drops,
|
||||
);
|
||||
}
|
||||
for stack in drops {
|
||||
self.spawn_item(stack, at, [0.0; 3], 10);
|
||||
}
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` for item frames: a frame within the blast
|
||||
|
||||
@@ -94,6 +94,13 @@ impl Game<'_> {
|
||||
pub(super) fn ime_commit(&mut self, text: &str) {
|
||||
preedit::set_preedit(None);
|
||||
let text: String = text.chars().filter(|c| !c.is_control()).collect();
|
||||
// ScreenEvent.CharacterTyped.Pre: the input method's committed text
|
||||
// reaches a text box by characters, as a key's own text does, so the
|
||||
// open screen hears of each of them first and a cancelled one never
|
||||
// reaches the box.
|
||||
let Some(text) = self.screen_text_typed(&text) else {
|
||||
return;
|
||||
};
|
||||
if text.is_empty() || !self.text_field_focused() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@ struct Flags {
|
||||
entity_distance: AtomicU32,
|
||||
/// `MinecraftServer.isCurrentlySaving`: the server is autosaving.
|
||||
saving: AtomicBool,
|
||||
/// Splitscreen players joined through memory (offline uuids): let in although the world is
|
||||
/// not open to the LAN, and the game no longer pauses while they play.
|
||||
split_players: Mutex<Vec<u128>>,
|
||||
/// The generators of the levels the server has run, by dimension: what
|
||||
/// F3's chunk generation entry asks `ServerChunkCache.getGenerator`.
|
||||
terrain: Mutex<
|
||||
@@ -120,7 +123,42 @@ impl Integrated {
|
||||
}
|
||||
/// `IntegratedServer.tickServer` skips the world while the game is paused.
|
||||
pub fn set_paused(&self, paused: bool) {
|
||||
self.flags.paused.store(paused, Ordering::Relaxed);
|
||||
let split = !self.flags.split_players.lock().unwrap().is_empty();
|
||||
self.flags.paused.store(paused && !split, Ordering::Relaxed);
|
||||
}
|
||||
/// Splitscreen: `name` left; the game pauses again once every such player has. The owner's
|
||||
/// permissions it was given go with it (a LAN player could otherwise join under that name).
|
||||
pub fn leave_split(&self, name: &str) {
|
||||
let uuid = horizon_protocol::crypto::offline_uuid(name);
|
||||
self.flags
|
||||
.split_players
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|&u| u != uuid);
|
||||
self.call(move |g| {
|
||||
if let Some(host) = g.lan.as_mut() {
|
||||
host.hub.ops.remove(&uuid);
|
||||
}
|
||||
});
|
||||
}
|
||||
/// Splitscreen: a connection through memory for another player on this machine, who gets
|
||||
/// the owner's permissions. `None` once the server has stopped.
|
||||
pub fn join_split(&self, name: &str) -> Option<horizon_protocol::connection::Pipe> {
|
||||
let uuid = horizon_protocol::crypto::offline_uuid(name);
|
||||
self.flags.split_players.lock().unwrap().push(uuid);
|
||||
let pipe = self
|
||||
.call(move |g| {
|
||||
let host = g.lan.as_mut()?;
|
||||
let owner = host.owner.as_ref()?.uuid;
|
||||
let level = host.hub.ops.get(&owner).copied().unwrap_or(0);
|
||||
host.hub.ops.insert(uuid, level);
|
||||
host.hub.lan.connect_local().ok()
|
||||
})
|
||||
.flatten();
|
||||
if pipe.is_none() {
|
||||
self.leave_split(name);
|
||||
}
|
||||
pipe
|
||||
}
|
||||
/// The window's render and simulation distances and its Entity Distance
|
||||
/// option, for the server to follow.
|
||||
@@ -679,14 +717,25 @@ impl Game<'_> {
|
||||
};
|
||||
let owner = launch.owner;
|
||||
let published = flags.clone();
|
||||
let local_players = flags.clone();
|
||||
let net = NetConfig {
|
||||
// No network until it is opened to the LAN; the memory connection
|
||||
// is not compressed (`Connection.isMemoryConnection`).
|
||||
listen: false,
|
||||
online: horizon_client::lan::OnlineMode::lan(),
|
||||
gate: Some(Arc::new(move |_, uuid, _| {
|
||||
(uuid != owner && published.published.load(Ordering::Relaxed) == 0)
|
||||
.then(|| translatable("multiplayer.disconnect.not_whitelisted", &[]))
|
||||
// Registered local seats share this computer; the vanilla LAN capacity still
|
||||
// applies to other connections when the world is published.
|
||||
bypasses_limit: Some(Arc::new(move |uuid| {
|
||||
local_players.split_players.lock().unwrap().contains(&uuid)
|
||||
})),
|
||||
gate: Some(Arc::new(move |address, uuid, _| {
|
||||
refused(
|
||||
uuid == owner,
|
||||
published.published.load(Ordering::Relaxed) != 0,
|
||||
published.split_players.lock().unwrap().contains(&uuid),
|
||||
address == horizon_client::lan::conn::MEMORY_ADDRESS,
|
||||
)
|
||||
.then(|| translatable("multiplayer.disconnect.not_whitelisted", &[]))
|
||||
})),
|
||||
levels: self.level_shapes,
|
||||
owner_profile: launch.owner_profile.clone(),
|
||||
@@ -1033,12 +1082,17 @@ impl Game<'_> {
|
||||
/// `IntegratedServer.setWorldAllowCommands`: the owner is an operator
|
||||
/// while the world allows commands; everyone's level is sent again.
|
||||
pub(super) fn owner_allow_commands(&mut self, allow: bool) {
|
||||
let Some(owner) = self.serving.as_ref().map(|s| s.owner) else {
|
||||
let Some((owner, flags)) = self.serving.as_ref().map(|s| (s.owner, s.flags.clone())) else {
|
||||
return;
|
||||
};
|
||||
let published = self.published_port().is_some();
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.hub.ops.insert(owner, if allow { 4 } else { 0 });
|
||||
let level = if allow { 4 } else { 0 };
|
||||
host.hub.ops.insert(owner, level);
|
||||
// Splitscreen seats have the owner's permissions (`join_split`): they follow it.
|
||||
for &seat in flags.split_players.lock().unwrap().iter() {
|
||||
host.hub.ops.insert(seat, level);
|
||||
}
|
||||
if published {
|
||||
host.hub.config.guest_commands = allow && host.settings.guest_commands;
|
||||
}
|
||||
@@ -1144,10 +1198,38 @@ fn server_game(
|
||||
Game::new(res, pack, gui, options, settings, audio, None)
|
||||
}
|
||||
|
||||
/// Who the integrated server turns away: anyone but the owner until it is opened to the LAN,
|
||||
/// except a splitscreen seat. A seat's name is let in only through memory, so a LAN player
|
||||
/// logging in under it never gets its place or the owner's permissions it holds.
|
||||
fn refused(owner: bool, published: bool, split_seat: bool, memory: bool) -> bool {
|
||||
if split_seat {
|
||||
!memory
|
||||
} else {
|
||||
!owner && !published
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_splitscreen_seat_is_let_in_only_through_memory() {
|
||||
// (owner, published, seat, memory)
|
||||
assert!(!refused(false, false, true, true), "a local seat joins");
|
||||
assert!(
|
||||
refused(false, true, true, false),
|
||||
"a LAN player under a seat's name"
|
||||
);
|
||||
assert!(refused(false, false, true, false));
|
||||
assert!(
|
||||
!refused(false, true, false, false),
|
||||
"anyone once open to the LAN"
|
||||
);
|
||||
assert!(refused(false, false, false, false), "nobody else before");
|
||||
assert!(!refused(true, false, false, true), "the owner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_request_does_not_join_a_server_still_saving() {
|
||||
let flags = Arc::new(Flags::default());
|
||||
|
||||
@@ -268,10 +268,13 @@ impl Game<'_> {
|
||||
&& self.stack_in(slot) == Some(stack)
|
||||
{
|
||||
let view = item_render::stack_view(stack);
|
||||
let duration = use_duration(&self.pack.item_renderer.models.components(&view));
|
||||
let mut duration = use_duration(&self.pack.item_renderer.models.components(&view));
|
||||
self.native_use_duration(stack, &mut duration);
|
||||
owner.using_this_item = true;
|
||||
owner.use_duration = duration;
|
||||
owner.use_item_remaining_ticks = duration - ticks as i32;
|
||||
// getUseItemRemainingTicks: the duration the use started with,
|
||||
// as LivingEntityUseItemEvent.Start left it (`start_using_item`).
|
||||
owner.use_item_remaining_ticks = self.use_duration - ticks as i32;
|
||||
owner.crossbow_charge_duration = Self::charge_ticks(stack).max(1) as i32;
|
||||
}
|
||||
owner.fishing_cast_with_this_item = stack
|
||||
@@ -407,22 +410,33 @@ impl Game<'_> {
|
||||
p.y + (q.y - p.y) * alpha,
|
||||
p.z + (q.z - p.z) * alpha,
|
||||
];
|
||||
(item, at, alpha as f32)
|
||||
(item, at, alpha as f32, true)
|
||||
});
|
||||
// ItemPickupParticle: the render state extracted at partial tick 1.
|
||||
let taken = self.pickups.live.iter().filter_map(|p| match &p.taken {
|
||||
super::pickups::Taken::Item(item) => {
|
||||
Some((&**item, super::pickups::position(p, alpha), 1.0))
|
||||
Some((&**item, super::pickups::position(p, alpha), 1.0, false))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
for (item, at, partial) in live.chain(taken) {
|
||||
for (item, at, partial, entity) in live.chain(taken) {
|
||||
let entity_start = out.len();
|
||||
let q = item.position;
|
||||
let pos = glam::DVec3::from_array(at) - camera;
|
||||
if pos.length_squared() > 64.0 * 64.0 {
|
||||
continue;
|
||||
}
|
||||
// EntityRenderer.shouldRender: the item's 0.25 box, grown by 0.5,
|
||||
// in the view frustum. Checked before its model is resolved.
|
||||
if entity && let Some(view) = &self.view_frustum {
|
||||
let feet = (glam::DVec3::new(q.x, q.y, q.z) - camera).as_vec3();
|
||||
let half = glam::Vec3::new(0.125 + 0.5, 0.5, 0.125 + 0.5);
|
||||
let lo = feet - half;
|
||||
let hi = feet + glam::Vec3::new(half.x, 0.25 + 0.5, half.z);
|
||||
if !view.intersects(lo, hi) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// `extractItemGroupRenderState`: GROUND, seeded by the entity id.
|
||||
let owner = owner_at([q.x, q.y, q.z], 0.0, "minecraft:item");
|
||||
let state = self.resolve_item(
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! `LivingEntity.startUsingItem` and the use over time of items vanilla's
|
||||
//! `Item` subclasses don't know: what `Item.getUseDuration` answers, a
|
||||
//! native mod's own item counted down to its `finishUsingItem`, and the hand
|
||||
//! pose of a `getUseAnimation` (`native_use_duration`, `native_use_animation`,
|
||||
//! `native_living_entity_use_item_start`, `native_living_entity_use_item_finish`).
|
||||
use super::Game;
|
||||
use horizon_client::{items::ItemStack, spear::Kinetic};
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `LivingEntity.startUsingItem` for the acting player's hand: nothing
|
||||
/// when a use runs already or the hand is empty. The use lasts
|
||||
/// `getUseDuration` ticks, which mods may change or refuse
|
||||
/// (`LivingEntityUseItemEvent.Start`), on both sides as in vanilla. True
|
||||
/// when the use started.
|
||||
pub(super) fn start_using_item(&mut self) -> bool {
|
||||
let Some(stack) = self.held().cloned().filter(|_| self.using.is_none()) else {
|
||||
return false;
|
||||
};
|
||||
let mut duration = self.use_duration(&stack);
|
||||
let mut canceled = false;
|
||||
let hand = self.hand;
|
||||
self.native_living_entity_use_item_start(&stack, None, hand, &mut duration, &mut canceled);
|
||||
if canceled {
|
||||
return false;
|
||||
}
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
self.use_duration = duration;
|
||||
true
|
||||
}
|
||||
/// `ItemStack.getUseDuration`: a consumable's time, 72000 for what is
|
||||
/// held until let go, the fixed times of brushes, bundles, spyglasses and
|
||||
/// goat horns, 0 for the rest, then a mod's answer.
|
||||
pub(super) fn use_duration(&self, stack: &ItemStack) -> i32 {
|
||||
let info = self.items().get(&stack.item);
|
||||
let mut duration = match &*stack.item {
|
||||
"minecraft:bow" | "minecraft:crossbow" | "minecraft:trident" => 72_000,
|
||||
"minecraft:spyglass" => 1200,
|
||||
"minecraft:brush" => 200,
|
||||
_ if info.is_some_and(|i| i.bundle) => 200,
|
||||
_ if let Some(i) = self.res.items().instrument(stack) => i.use_ticks(),
|
||||
_ => match info {
|
||||
Some(i) if let Some(c) = &i.consumable => c.ticks(),
|
||||
Some(i) if i.blocks_attacks.is_some() => 72_000,
|
||||
_ if Kinetic::of(&stack.item).is_some() => 72_000,
|
||||
_ => 0,
|
||||
},
|
||||
};
|
||||
self.native_use_duration(stack, &mut duration);
|
||||
duration
|
||||
}
|
||||
/// Whether `stack` is a mod's item that only a mod uses over time: no
|
||||
/// `consumable`, `blocks_attacks` or kinetic weapon of vanilla's own, and
|
||||
/// a `native_use_duration` above 0. Its use is its own `Item.use`.
|
||||
pub(super) fn mod_use(&self, stack: &ItemStack) -> bool {
|
||||
!stack.item.starts_with("minecraft:")
|
||||
&& self
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.is_some_and(|i| i.consumable.is_none() && i.blocks_attacks.is_none())
|
||||
&& Kinetic::of(&stack.item).is_none()
|
||||
&& self.use_duration(stack) > 0
|
||||
}
|
||||
/// A right click with a `mod_use` item: `startUsingItem` (CONSUME).
|
||||
pub(super) fn start_mod_use(&mut self, stack: &ItemStack) -> bool {
|
||||
if !self.mod_use(stack) {
|
||||
return false;
|
||||
}
|
||||
self.start_using_item();
|
||||
true
|
||||
}
|
||||
/// `LivingEntity.updateUsingItem` for a `mod_use` item used `ticks` so far:
|
||||
/// `completeUsingItem` once its duration ran out.
|
||||
pub(super) fn tick_mod_use(&mut self, slot: usize, ticks: u32, stack: &ItemStack) {
|
||||
if let Some(ticks) = count_down(ticks, self.use_duration) {
|
||||
self.using = Some((slot, ticks));
|
||||
return;
|
||||
}
|
||||
self.using = None;
|
||||
self.send_use_item_complete();
|
||||
// in ItemStack.finishUsingItem, Item's own gives the stack back.
|
||||
self.apply_use_cooldown(stack);
|
||||
self.use_item_finished(stack);
|
||||
}
|
||||
/// `LivingEntityUseItemEvent.Finish` after `finishUsingItem` of `stack`
|
||||
/// (no ticks left): the hand holds what a mod made the result.
|
||||
pub(super) fn use_item_finished(&mut self, stack: &ItemStack) {
|
||||
let mut result = self.held().cloned();
|
||||
let before = result.clone();
|
||||
let hand = self.hand;
|
||||
self.native_living_entity_use_item_finish(stack, None, hand, 0, &mut result);
|
||||
if result != before {
|
||||
*self.held_mut() = result.filter(|s| s.count > 0);
|
||||
}
|
||||
}
|
||||
/// `Player.canEat(canAlwaysEat)` for `stack`, food that is eaten only
|
||||
/// when hungry, then a mod's answer.
|
||||
pub(super) fn can_eat(&self, stack: &ItemStack, can_always_eat: bool) -> bool {
|
||||
let mut can_eat = can_always_eat
|
||||
|| self.creative
|
||||
|| self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.health.food.food < 20);
|
||||
self.native_can_eat(stack, &mut can_eat);
|
||||
can_eat
|
||||
}
|
||||
}
|
||||
|
||||
/// `Consumable.startConsuming`: a time of 0 or less is no use, the item is
|
||||
/// consumed at once (`onConsume`).
|
||||
pub(super) fn consumes_at_once(consume_ticks: i32) -> bool {
|
||||
consume_ticks <= 0
|
||||
}
|
||||
|
||||
/// `updateUsingItem`'s `--useItemRemaining == 0` for a use `ticks` old of
|
||||
/// `duration` ticks: the ticks used after this one, none on the tick it runs
|
||||
/// out. A time of 0 or less, or one a mod cut below what was used, never
|
||||
/// does: the use lasts until let go.
|
||||
pub(super) fn count_down(ticks: u32, duration: i32) -> Option<u32> {
|
||||
let ticks = ticks + 1;
|
||||
(i64::from(duration) - i64::from(ticks) != 0).then_some(ticks)
|
||||
}
|
||||
|
||||
/// The first person pose of a hand using an item of `animation`
|
||||
/// (`ItemInHandRenderer.renderArmWithItem`'s switch), for an item of no
|
||||
/// pose of its own.
|
||||
pub(super) fn use_pose(
|
||||
animation: UseAnimation,
|
||||
remaining: f32,
|
||||
duration: f32,
|
||||
partial: f32,
|
||||
) -> horizon_client::first_person::ItemPose {
|
||||
use horizon_client::first_person::ItemPose;
|
||||
let ticks_used = duration - (remaining - partial + 1.0);
|
||||
match animation {
|
||||
UseAnimation::Eat | UseAnimation::Drink => ItemPose::Eat {
|
||||
remaining,
|
||||
duration,
|
||||
partial,
|
||||
},
|
||||
UseAnimation::Bow => ItemPose::Bow { ticks_used },
|
||||
UseAnimation::Trident => ItemPose::Trident { ticks_used },
|
||||
UseAnimation::Brush => ItemPose::Brush { remaining, partial },
|
||||
_ => ItemPose::Using,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{consumes_at_once, count_down, use_pose};
|
||||
use horizon_client::first_person::ItemPose;
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
|
||||
#[test]
|
||||
fn a_use_runs_out_on_its_last_tick() {
|
||||
// a 32 tick food: ticks 0 to 30 go on, the 32nd tick finishes it.
|
||||
assert_eq!(count_down(0, 32), Some(1));
|
||||
assert_eq!(count_down(30, 32), Some(31));
|
||||
assert_eq!(count_down(31, 32), None);
|
||||
// a one tick use finishes on its first tick.
|
||||
assert_eq!(count_down(0, 1), None);
|
||||
// no time, or one below what was used, waits for the release.
|
||||
assert_eq!(count_down(0, 0), Some(1));
|
||||
assert_eq!(count_down(10, 4), Some(11));
|
||||
assert_eq!(count_down(5, 72_000), Some(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_started_use_finishes_after_its_duration() {
|
||||
// start at 0 ticks, count down each tick, finish when it runs out.
|
||||
for duration in [1, 16, 32, 40] {
|
||||
let (mut ticks, mut ticked) = (0, 0);
|
||||
while let Some(next) = count_down(ticks, duration) {
|
||||
ticks = next;
|
||||
ticked += 1;
|
||||
}
|
||||
assert_eq!(ticked + 1, duration);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_food_of_no_time_is_eaten_at_once() {
|
||||
assert!(consumes_at_once(0));
|
||||
assert!(consumes_at_once(-3));
|
||||
assert!(!consumes_at_once(1));
|
||||
assert!(!consumes_at_once(32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_use_animation_picks_the_hand_pose() {
|
||||
let pose = |animation| use_pose(animation, 12.0, 32.0, 0.5);
|
||||
assert!(matches!(
|
||||
pose(UseAnimation::Drink),
|
||||
ItemPose::Eat { remaining, duration, partial }
|
||||
if (remaining, duration, partial) == (12.0, 32.0, 0.5)
|
||||
));
|
||||
// ticks used: 32 - (12 - 0.5 + 1).
|
||||
assert!(
|
||||
matches!(pose(UseAnimation::Bow), ItemPose::Bow { ticks_used } if ticks_used == 19.5)
|
||||
);
|
||||
assert!(matches!(
|
||||
pose(UseAnimation::Trident),
|
||||
ItemPose::Trident { ticks_used } if ticks_used == 19.5
|
||||
));
|
||||
assert!(matches!(
|
||||
pose(UseAnimation::Brush),
|
||||
ItemPose::Brush { remaining, partial } if (remaining, partial) == (12.0, 0.5)
|
||||
));
|
||||
for animation in [
|
||||
UseAnimation::None,
|
||||
UseAnimation::Block,
|
||||
UseAnimation::Spyglass,
|
||||
] {
|
||||
assert!(matches!(pose(animation), ItemPose::Using));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,8 @@ pub(super) struct LanHost {
|
||||
/// see; the players' hurt time by entity id.
|
||||
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool, Option<i32>)>>,
|
||||
/// The guest each mob sees as its player, when not the host (game/lan_mobs.rs).
|
||||
pub mob_players: HashMap<u32, u32>,
|
||||
/// Looked up per mob each tick: a fast hasher (never iterated).
|
||||
pub mob_players: horizon_client::fast_hash::FxHashMap<u32, u32>,
|
||||
/// An integrated server's owner (game/integrated.rs).
|
||||
pub owner: Option<Owner>,
|
||||
/// The players' locator bars, one waypoint manager per level by
|
||||
@@ -1403,6 +1404,7 @@ impl Game<'_> {
|
||||
let host_entity = self.next_entity as i32;
|
||||
let mut hub = Hub::new(lan, sync, encoder, config);
|
||||
hub.flat_world = self.world_flat;
|
||||
hub.debug_world = self.world_debug;
|
||||
let port = hub.lan.port;
|
||||
self.world.block_log = Some(Vec::new());
|
||||
self.world.light_log = Some(Default::default());
|
||||
@@ -1420,7 +1422,7 @@ impl Game<'_> {
|
||||
play: HashMap::new(),
|
||||
broken: Vec::new(),
|
||||
mob_state: HashMap::new(),
|
||||
mob_players: HashMap::new(),
|
||||
mob_players: Default::default(),
|
||||
owner: None,
|
||||
waypoints: Default::default(),
|
||||
waypoint_dead: Default::default(),
|
||||
@@ -1482,6 +1484,25 @@ impl Game<'_> {
|
||||
/// `PlayerList.removeAll`: every guest leaves in turn, the vehicle it
|
||||
/// alone rides with it (`PlayerList.remove`), and is saved.
|
||||
pub(super) fn save_leaving_guests(&mut self, host: &mut LanHost) {
|
||||
// Every open menu closes first, in its guest's level
|
||||
// (`ServerPlayer.disconnect` -> `doCloseContainer`), so the cursor,
|
||||
// the trade and the inputs are back in the file that is saved.
|
||||
let back = self.dim;
|
||||
let open: Vec<(u32, super::Dim)> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed)
|
||||
.map(|g| (g.conn, super::Dim::from_id(&g.dimension)))
|
||||
.collect();
|
||||
for (conn, dim) in open {
|
||||
if self.enter_level(Some(host), dim).is_ok() {
|
||||
self.guest_close_all(host, conn);
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.enter_level(Some(host), back) {
|
||||
log::error!("could not reopen {}: {e}", back.id());
|
||||
}
|
||||
for i in 0..host.hub.guests.len() {
|
||||
let mut g = host.hub.guests.remove(i);
|
||||
self.take_parent_vehicle(host, &mut g);
|
||||
@@ -1490,15 +1511,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// Save a guest when they leave or the world closes (after
|
||||
/// `take_parent_vehicle`): an integrated server's owner with the world
|
||||
/// (`save_game`), anyone else in their file.
|
||||
/// `take_parent_vehicle`, `PlayerList.save`): an integrated server's owner
|
||||
/// into the level (`/data`) and both its files (`playerdata::save_owner`),
|
||||
/// anyone else into their own file.
|
||||
pub(super) fn save_guest(&mut self, host: &LanHost, guest: &horizon_client::lan::guest::Guest) {
|
||||
if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) && guest.placed {
|
||||
// The world's own player state rides in the level (`/data`).
|
||||
playerdata::store(guest, &mut self.level, None);
|
||||
}
|
||||
if guest.placed {
|
||||
// PlayerList.save/remove: every player saves to their own file.
|
||||
if guest.placed && host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
|
||||
playerdata::save_owner(guest, &mut self.level, &self.world_root);
|
||||
} else if guest.placed {
|
||||
playerdata::save(guest, &self.world_root);
|
||||
}
|
||||
self.save_guest_records(host, guest);
|
||||
@@ -1815,7 +1834,7 @@ impl Game<'_> {
|
||||
fn lan_entities(
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
mob_players: &HashMap<u32, u32>,
|
||||
mob_players: &horizon_client::fast_hash::FxHashMap<u32, u32>,
|
||||
hub: &mut Hub,
|
||||
out: &mut States,
|
||||
) {
|
||||
@@ -1979,15 +1998,17 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
HostEvent::Left(mut guest) => {
|
||||
// The container they had open loses an opener.
|
||||
if let Some(pos) = guest.menu.as_ref().and_then(|m| m.pos)
|
||||
&& !guest.spectator()
|
||||
{
|
||||
// In the level they were in.
|
||||
// The menu they had open closes, in the level they were in
|
||||
// (`ServerPlayer.disconnect` -> `doCloseContainer`).
|
||||
if guest.placed {
|
||||
let back = self.dim;
|
||||
let theirs = super::Dim::from_id(&guest.dimension);
|
||||
if self.enter_level(Some(&mut host), theirs).is_ok() {
|
||||
self.container_openers_changed(pos, false);
|
||||
let conn = guest.conn;
|
||||
let at = host.hub.guests.len();
|
||||
host.hub.guests.push(*guest);
|
||||
self.guest_close_all(&mut host, conn);
|
||||
*guest = host.hub.guests.remove(at);
|
||||
}
|
||||
if let Err(e) = self.enter_level(Some(&mut host), back) {
|
||||
log::error!("could not reopen {}: {e}", back.id());
|
||||
@@ -2352,28 +2373,15 @@ impl Game<'_> {
|
||||
let force = host.hub.config.force_game_mode;
|
||||
let root = self.world_root.clone();
|
||||
let in_place = host.hub.config.dimension.clone();
|
||||
let mut owner = host.owner.as_mut();
|
||||
let owner = host.owner.as_mut();
|
||||
let Some(g) = host.hub.guest_mut(conn) else {
|
||||
return;
|
||||
};
|
||||
let is_owner = owner.as_ref().is_some_and(|o| o.uuid == g.uuid);
|
||||
// PlayerDataStorage.load: the player's own file first; the world's
|
||||
// own player state is the fallback for a first join.
|
||||
let known = if is_owner && playerdata::load(g, &root) {
|
||||
true
|
||||
} else {
|
||||
let saved = if is_owner {
|
||||
owner.as_mut().and_then(|o| o.saved.take())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match saved {
|
||||
Some(level) => {
|
||||
playerdata::apply(g, &level);
|
||||
level.position.is_some()
|
||||
}
|
||||
None => playerdata::load(g, &root),
|
||||
}
|
||||
// PlayerList.loadPlayerData: the owner gets the world's player file,
|
||||
// everyone else their own.
|
||||
let known = match owner.filter(|o| o.uuid == g.uuid) {
|
||||
Some(o) => playerdata::load_owner(g, &root, o.saved.take().as_deref()),
|
||||
None => playerdata::load(g, &root),
|
||||
};
|
||||
if !known || force {
|
||||
g.game_mode = default_mode;
|
||||
|
||||
@@ -42,6 +42,7 @@ pub(super) struct GuestPlay {
|
||||
mining: Option<Mining>,
|
||||
destroy_delay: u32,
|
||||
using: Option<(usize, u32)>,
|
||||
use_duration: i32,
|
||||
use_delay: u32,
|
||||
/// The shield's ItemCooldowns entry: an axe disabled it for this many ticks.
|
||||
pub(super) shield_cooldown: u32,
|
||||
@@ -172,6 +173,7 @@ pub(super) struct GuestSwap {
|
||||
mining: Option<Mining>,
|
||||
destroy_delay: u32,
|
||||
using: Option<(usize, u32)>,
|
||||
use_duration: i32,
|
||||
use_delay: u32,
|
||||
pub(super) shield_cooldown: u32,
|
||||
/// The guest's item cooldowns (`Game::cooldowns` for its turn).
|
||||
@@ -295,7 +297,9 @@ impl Game<'_> {
|
||||
f: impl FnOnce(&mut Self) -> R,
|
||||
) -> Option<R> {
|
||||
let swap = self.enter_guest(host, conn)?;
|
||||
let result = f(self);
|
||||
let dimension =
|
||||
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(self.dim.id());
|
||||
let result = horizon_client::block_contact::in_level(dimension, || f(self));
|
||||
self.leave_guest(host, swap);
|
||||
Some(result)
|
||||
}
|
||||
@@ -410,6 +414,7 @@ impl Game<'_> {
|
||||
motion.sprinting = g.sprinting;
|
||||
motion.may_fly = g.creative() || g.game_mode == SPECTATOR;
|
||||
motion.flying = g.flying;
|
||||
motion.flying_speed = g.flying_speed;
|
||||
// FireworkRocketItem.use boosts only a gliding player.
|
||||
motion.fall_flying = g.fall_flying;
|
||||
motion.invulnerable = motion.may_fly;
|
||||
@@ -464,6 +469,7 @@ impl Game<'_> {
|
||||
let mining = std::mem::replace(&mut self.mining, play.mining.take());
|
||||
let destroy_delay = std::mem::replace(&mut self.destroy_delay, play.destroy_delay);
|
||||
let using = std::mem::replace(&mut self.using, play.using.take());
|
||||
let use_duration = std::mem::replace(&mut self.use_duration, play.use_duration);
|
||||
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));
|
||||
@@ -577,6 +583,7 @@ impl Game<'_> {
|
||||
mining,
|
||||
destroy_delay,
|
||||
using,
|
||||
use_duration,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
cooldowns,
|
||||
@@ -652,6 +659,7 @@ impl Game<'_> {
|
||||
mining,
|
||||
destroy_delay,
|
||||
using,
|
||||
use_duration,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
cooldowns,
|
||||
@@ -769,6 +777,7 @@ impl Game<'_> {
|
||||
play.mining = std::mem::replace(&mut self.mining, mining);
|
||||
play.destroy_delay = std::mem::replace(&mut self.destroy_delay, destroy_delay);
|
||||
play.using = std::mem::replace(&mut self.using, using);
|
||||
play.use_duration = std::mem::replace(&mut self.use_duration, use_duration);
|
||||
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);
|
||||
@@ -1181,9 +1190,14 @@ impl Game<'_> {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// A mod menu type only for a client that has it: as
|
||||
// `ServerPlayer.openMenu` with no menu, nothing opens.
|
||||
let brand = host.hub.guest(conn).map_or("", |g| g.brand.as_str());
|
||||
if !horizon_client::containers::menu_reaches(brand, menu) {
|
||||
return false;
|
||||
}
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_container_closed(host, conn);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
self.guest_close_all(host, conn);
|
||||
let hidden = self.native_hidden_slots(pos, items.len());
|
||||
host.hub
|
||||
.open_container(conn, menu, title, pos, slots, items);
|
||||
@@ -1231,8 +1245,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
};
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_container_closed(host, conn);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
self.guest_close_all(host, conn);
|
||||
host.hub
|
||||
.open_container(conn, menu, title, pos, slots, items);
|
||||
true
|
||||
@@ -1253,6 +1266,38 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes whatever menu the guest has open, the way each `ContainerClose`
|
||||
/// does (`ServerPlayer.doCloseContainer`, `AbstractContainerMenu.removed`):
|
||||
/// the container's lid, a vehicle's stand-in (its items go back to the
|
||||
/// vehicle), the trade (written back to the villager), the crafting table,
|
||||
/// station and mount menus, and the cursor stack back to the inventory.
|
||||
/// Nothing is sent to the client: this is for a guest who is leaving or
|
||||
/// whose server is closing. The guest must be in `host.hub.guests` and the
|
||||
/// level they are in must be the one in place.
|
||||
pub(super) fn guest_close_all(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
if let Some((window, slots)) = host.hub.guests[gi]
|
||||
.menu
|
||||
.as_ref()
|
||||
.map(|m| (m.window, m.slots))
|
||||
{
|
||||
self.guest_container_closed(host, conn);
|
||||
if let Slots::Block(key) = slots
|
||||
&& key.1 == super::minecarts::CART_KEY_Y
|
||||
{
|
||||
self.close_guest_vehicle_menu(host, conn, key);
|
||||
}
|
||||
host.hub.container_closed(conn, window);
|
||||
}
|
||||
self.lan_merchant_closed(host, conn, None);
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
self.lan_mount_closed(host, conn, None);
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
|
||||
/// `ContainerEntity`'s `Container.stopOpen` when a vehicle menu closes
|
||||
/// (`AbstractChestBoat.stopOpen` / `MinecartChest.stopOpen`): the menu's
|
||||
/// slots go back to the vehicle, the shared placeholder leaves
|
||||
@@ -1297,15 +1342,7 @@ impl Game<'_> {
|
||||
.guests
|
||||
.iter()
|
||||
.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.pos.is_some_and(|k| {
|
||||
k.1 == super::minecarts::CART_KEY_Y
|
||||
&& !self.chest_vehicle_still_valid(k.2, g)
|
||||
})
|
||||
})
|
||||
})
|
||||
.filter(|g| !self.container_menu_valid(g))
|
||||
.map(|g| {
|
||||
let vehicle = g.menu.as_ref().and_then(|m| match m.slots {
|
||||
Slots::Block(key) if key.1 == super::minecarts::CART_KEY_Y => Some(key),
|
||||
@@ -1326,6 +1363,78 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `stillValid` of the guest's open container: its blocks are still there
|
||||
/// and in reach, or its vehicle exists and is in reach. True with none open.
|
||||
fn container_menu_valid(&self, g: &horizon_client::lan::guest::Guest) -> bool {
|
||||
g.menu.as_ref().is_none_or(|m| {
|
||||
!(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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.stillValid` of whatever menu the guest has open
|
||||
/// (a container or vehicle, a trade, the crafting table, a station, a
|
||||
/// lectern, a mount's inventory). True when none is open.
|
||||
fn guest_menu_still_valid(&self, host: &LanHost, conn: u32) -> bool {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return true;
|
||||
};
|
||||
if !self.container_menu_valid(g) {
|
||||
return false;
|
||||
}
|
||||
let Some(p) = host.play.get(&conn) else {
|
||||
return true;
|
||||
};
|
||||
p.crafting
|
||||
.table
|
||||
.as_ref()
|
||||
.is_none_or(|t| self.lan_table_valid(g, t.3))
|
||||
&& p.station
|
||||
.as_ref()
|
||||
.is_none_or(|s| self.lan_station_valid(g, s))
|
||||
&& p.lectern
|
||||
.as_ref()
|
||||
.is_none_or(|l| self.lan_lectern_valid(g, l.key))
|
||||
&& p.merchant
|
||||
.as_ref()
|
||||
.and_then(|m| m.merchant.mob)
|
||||
.is_none_or(|id| self.lan_merchant_valid(g, id))
|
||||
&& p.mount_window
|
||||
.as_ref()
|
||||
.is_none_or(|m| self.lan_mount_valid(g, m.mob))
|
||||
}
|
||||
|
||||
/// The check before a menu click: a menu that is no longer valid closes
|
||||
/// (returning what it held, as `ContainerClose` does) and the client's
|
||||
/// window with it, so the click lands on nothing
|
||||
/// (`ServerGamePacketListenerImpl.handleContainerClick` and the others
|
||||
/// ask `stillValid` first).
|
||||
fn guest_validate_menu(&mut self, host: &mut LanHost, conn: u32) {
|
||||
if self.guest_menu_still_valid(host, conn) {
|
||||
return;
|
||||
}
|
||||
let mut windows: Vec<i32> = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.and_then(|g| g.menu.as_ref().map(|m| m.window))
|
||||
.into_iter()
|
||||
.collect();
|
||||
if let Some(p) = host.play.get(&conn) {
|
||||
windows.extend(p.crafting.table.as_ref().map(|t| t.0));
|
||||
windows.extend(p.station.as_ref().map(|s| s.window));
|
||||
windows.extend(p.lectern.as_ref().map(|l| l.window));
|
||||
windows.extend(p.merchant.as_ref().map(|m| m.window));
|
||||
windows.extend(p.mount_window.as_ref().map(|m| m.window));
|
||||
}
|
||||
self.guest_close_all(host, conn);
|
||||
for container in windows {
|
||||
host.hub
|
||||
.send_to(conn, &C::ContainerClose(play::ContainerClose { container }));
|
||||
}
|
||||
}
|
||||
|
||||
/// `ContainerEntity.isChestVehicleStillValid`: the chest minecart or chest
|
||||
/// boat is still there and within the guest's entity interaction range
|
||||
/// plus 4 of its box.
|
||||
@@ -1437,6 +1546,19 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
if matches!(
|
||||
packet,
|
||||
S::ContainerClick(_)
|
||||
| S::ContainerButtonClick(_)
|
||||
| S::ContainerSlotStateChanged(_)
|
||||
| S::RenameItem(_)
|
||||
| S::SetBeacon(_)
|
||||
| S::SelectTrade(_)
|
||||
| S::SelectBundleItem(_)
|
||||
| S::PlaceRecipe(_)
|
||||
) {
|
||||
self.guest_validate_menu(host, conn);
|
||||
}
|
||||
let spectator = host.hub.guests[gi].spectator();
|
||||
match packet {
|
||||
S::PlayerAction(a) => match a.action {
|
||||
@@ -2641,6 +2763,30 @@ impl Game<'_> {
|
||||
command: &str,
|
||||
signed: Option<Vec<(String, horizon_client::lan::hub::chat::ChatMessage)>>,
|
||||
) {
|
||||
// The mods hear every command first, and may stop it.
|
||||
let mut canceled = false;
|
||||
let mut replies = Vec::new();
|
||||
if let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) {
|
||||
self.native_player_command(
|
||||
uuid,
|
||||
&name,
|
||||
command.trim_start_matches('/'),
|
||||
&mut canceled,
|
||||
&mut replies,
|
||||
);
|
||||
}
|
||||
for line in replies {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: horizon_client::lan::data::component_nbt(&line.component()),
|
||||
overlay: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if canceled {
|
||||
return;
|
||||
}
|
||||
if self.guest_message_command(host, conn, command, signed.as_ref()) {
|
||||
return;
|
||||
}
|
||||
@@ -2977,6 +3123,7 @@ impl Game<'_> {
|
||||
// restartClientLoadTimerAfterRespawn.
|
||||
g.dead = false;
|
||||
g.load_timeout = horizon_client::lan::guest::CLIENT_LOADED_TIMEOUT;
|
||||
g.loading = true;
|
||||
g.death_time = 0;
|
||||
g.health = 20.0;
|
||||
g.food = 20;
|
||||
|
||||
@@ -116,20 +116,22 @@ impl Game<'_> {
|
||||
/// `/attribute` changed.
|
||||
pub(super) fn mob_attributes(&self, m: &Mob, out: &mut Vec<AttributeSnapshot>) {
|
||||
let kind = m.kind.id.as_str();
|
||||
if let Some(a) = self.synced_attribute(kind, "minecraft:movement_speed") {
|
||||
instance(out, a, finalize::base_value(m, Attr::MovementSpeed));
|
||||
// Movement speed is synced for nearly every mob, and read up to three
|
||||
// times below: looked up once per mob and tick.
|
||||
let speed = self
|
||||
.synced_attribute(kind, Attr::MovementSpeed.id())
|
||||
.map(|a| (a, finalize::base_value(m, Attr::MovementSpeed)));
|
||||
if let Some((a, base)) = speed {
|
||||
instance(out, a, base);
|
||||
}
|
||||
if let Some(s) = m.extra.attributes.as_deref() {
|
||||
for (attr, v) in &s.bases {
|
||||
if let Some(a) = self.synced_attribute(kind, &format!("minecraft:{}", attr.name()))
|
||||
{
|
||||
if let Some(a) = self.synced_attribute(kind, attr.id()) {
|
||||
instance(out, a, *v).base = *v;
|
||||
}
|
||||
}
|
||||
for x in &s.modifiers {
|
||||
if let Some(a) =
|
||||
self.synced_attribute(kind, &format!("minecraft:{}", x.attr.name()))
|
||||
{
|
||||
if let Some(a) = self.synced_attribute(kind, x.attr.id()) {
|
||||
add_modifier(
|
||||
out,
|
||||
a,
|
||||
@@ -162,18 +164,17 @@ impl Game<'_> {
|
||||
if rolled
|
||||
&& !jump_base
|
||||
&& m.extra.vector[0] > 0.0
|
||||
&& let Some(a) = self.synced_attribute(kind, "minecraft:jump_strength")
|
||||
&& let Some(a) = self.synced_attribute(kind, Attr::JumpStrength.id())
|
||||
{
|
||||
instance(out, a, m.extra.vector[0]).base = m.extra.vector[0];
|
||||
}
|
||||
if let (Some(a), Some((amount, Op::AddMultipliedBase))) = (
|
||||
self.synced_attribute(kind, "minecraft:movement_speed"),
|
||||
finalize::transient(m, Attr::MovementSpeed),
|
||||
) {
|
||||
if let (Some((a, base)), Some((amount, Op::AddMultipliedBase))) =
|
||||
(speed, finalize::transient(m, Attr::MovementSpeed))
|
||||
{
|
||||
add_modifier(
|
||||
out,
|
||||
a,
|
||||
finalize::base_value(m, Attr::MovementSpeed),
|
||||
base,
|
||||
AttributeModifier {
|
||||
id: "minecraft:baby".into(),
|
||||
amount,
|
||||
@@ -182,14 +183,11 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
// LivingEntity.setSprinting: SPEED_MODIFIER_SPRINTING (a dashing cat or ocelot).
|
||||
if let (true, Some(a)) = (
|
||||
m.extra.sprinting,
|
||||
self.synced_attribute(kind, "minecraft:movement_speed"),
|
||||
) {
|
||||
if let (true, Some((a, base))) = (m.extra.sprinting, speed) {
|
||||
add_modifier(
|
||||
out,
|
||||
a,
|
||||
finalize::base_value(m, Attr::MovementSpeed),
|
||||
base,
|
||||
AttributeModifier {
|
||||
id: "minecraft:sprinting".into(),
|
||||
amount: f64::from(0.3f32),
|
||||
|
||||
@@ -21,7 +21,7 @@ pub(super) struct GuestCrafting {
|
||||
/// The inventory menu; its grid lives in the hub's guest.
|
||||
inventory: Option<Box<Menu>>,
|
||||
/// The open crafting table: its window, state id, menu and block.
|
||||
table: Option<(i32, i32, Box<Menu>, [i32; 3])>,
|
||||
pub(super) table: Option<(i32, i32, Box<Menu>, [i32; 3])>,
|
||||
/// The open furnace's or brewing stand's window and menu (`AbstractFurnaceMenu`,
|
||||
/// `BrewingStandMenu`); its slots live in the block entity and the hub's
|
||||
/// container menu keeps the client in sync.
|
||||
@@ -436,10 +436,8 @@ impl Game<'_> {
|
||||
|
||||
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
|
||||
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
// The container it replaces loses its opener.
|
||||
self.guest_container_closed(host, conn);
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let title = horizon_protocol::text::translatable("container.crafting", &[]);
|
||||
let Some(window) = host.hub.open_window(conn, "minecraft:crafting", title) else {
|
||||
return;
|
||||
@@ -509,7 +507,7 @@ impl Game<'_> {
|
||||
|
||||
/// `CraftingMenu.stillValid`: the block is still a crafting table and the
|
||||
/// guest is within reach of it.
|
||||
fn lan_table_valid(&self, g: &Guest, pos: [i32; 3]) -> bool {
|
||||
pub(super) fn lan_table_valid(&self, g: &Guest, pos: [i32; 3]) -> bool {
|
||||
let name = &self
|
||||
.res
|
||||
.facts()
|
||||
|
||||
@@ -15,8 +15,8 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
|
||||
/// A guest's open mount menu.
|
||||
pub(super) struct GuestMount {
|
||||
window: i32,
|
||||
mob: u32,
|
||||
pub(super) window: i32,
|
||||
pub(super) mob: u32,
|
||||
columns: usize,
|
||||
state: i32,
|
||||
/// The menu's slots and the carried stack the client was last sent.
|
||||
@@ -31,10 +31,8 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let columns = super::mount_menu::mount_columns(&self.mobs[index]);
|
||||
self.lan_mount_closed(host, conn, None);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
self.guest_container_closed(host, conn);
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let Some(window) = host.hub.open_mount_window(conn, columns as i32, mob as i32) else {
|
||||
return;
|
||||
};
|
||||
@@ -182,10 +180,28 @@ impl Game<'_> {
|
||||
closed
|
||||
}
|
||||
|
||||
/// `AbstractMountInventoryMenu.stillValid`: the mount is alive and within
|
||||
/// the entity interaction range (3) plus 4 of the guest's eyes.
|
||||
pub(super) fn lan_mount_valid(&self, g: &horizon_client::lan::guest::Guest, mob: u32) -> bool {
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
self.mobs.iter().find(|m| m.id == mob).is_some_and(|m| {
|
||||
let (w, h) = (f64::from(m.width()) / 2.0, f64::from(m.height()));
|
||||
let p = [m.position.x, m.position.y, m.position.z];
|
||||
let lo = [p[0] - w, p[1], p[2] - w];
|
||||
let hi = [p[0] + w, p[1] + h, p[2] + w];
|
||||
let d: f64 = (0..3)
|
||||
.map(|k| (eye[k] - eye[k].clamp(lo[k], hi[k])).powi(2))
|
||||
.sum();
|
||||
m.alive() && d < 7.0 * 7.0
|
||||
})
|
||||
}
|
||||
|
||||
/// Each tick: a mount menu whose mount died or is out of reach closes
|
||||
/// (`AbstractMountInventoryMenu.stillValid`: alive, within the entity
|
||||
/// interaction range plus 4 of the eyes), and the others follow the
|
||||
/// mount's and the inventory's slots.
|
||||
/// mount's and the inventory's slots. Only the level the player is in
|
||||
/// checks its menu (`ServerPlayer.tick`): another level ticking has
|
||||
/// none of its mobs (#5331).
|
||||
pub(super) fn tick_lan_mount_menus(&mut self, host: &mut LanHost) {
|
||||
let open: Vec<(u32, u32, i32)> = host
|
||||
.play
|
||||
@@ -196,19 +212,10 @@ impl Game<'_> {
|
||||
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
|
||||
continue;
|
||||
};
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let valid = self.mobs.iter().find(|m| m.id == mob).is_some_and(|m| {
|
||||
let (w, h) = (f64::from(m.width()) / 2.0, f64::from(m.height()));
|
||||
let p = [m.position.x, m.position.y, m.position.z];
|
||||
let lo = [p[0] - w, p[1], p[2] - w];
|
||||
let hi = [p[0] + w, p[1] + h, p[2] + w];
|
||||
let d: f64 = (0..3)
|
||||
.map(|k| (eye[k] - eye[k].clamp(lo[k], hi[k])).powi(2))
|
||||
.sum();
|
||||
// Player.ENTITY_INTERACTION_RANGE 3, plus 4.
|
||||
m.alive() && d < 7.0 * 7.0
|
||||
});
|
||||
if valid {
|
||||
if !host.hub.here(g) {
|
||||
continue;
|
||||
}
|
||||
if self.lan_mount_valid(g, mob) {
|
||||
self.lan_send_mount(host, conn);
|
||||
} else {
|
||||
self.lan_mount_closed(host, conn, Some(window));
|
||||
|
||||
@@ -6,7 +6,7 @@ use horizon_client::lan::hub::Hub;
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_protocol::{
|
||||
entity_data::{Particle, ParticleData},
|
||||
entity_data::{MOD_PARTICLE_ID, Particle, ParticleData},
|
||||
packets::play::{self, Clientbound as C},
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
@@ -20,12 +20,15 @@ pub(super) struct QueuedParticle {
|
||||
pub spread: [f64; 3],
|
||||
pub speed: f64,
|
||||
pub count: i32,
|
||||
/// The packet's `RandomizationType` (0 DEFAULT, 1 ALTERNATIVE).
|
||||
pub randomization: i32,
|
||||
}
|
||||
|
||||
fn send_native_nearby(
|
||||
host: &LanHost,
|
||||
dimension: &str,
|
||||
packet: Vec<u8>,
|
||||
horizon_only: bool,
|
||||
within_range: impl Fn([f64; 3]) -> bool,
|
||||
) {
|
||||
for guest in host
|
||||
@@ -34,10 +37,47 @@ fn send_native_nearby(
|
||||
.iter()
|
||||
.filter(|g| g.placed && g.dimension == dimension && within_range(g.position))
|
||||
{
|
||||
// A vanilla client cannot decode a native mod's particle id; see
|
||||
// `mod_particle_only_for_horizon`.
|
||||
if horizon_only && guest.brand != "horizon" {
|
||||
continue;
|
||||
}
|
||||
guest.sender.send(packet.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// A native mod's particle travels as `MOD_PARTICLE_ID` (-1) plus its name.
|
||||
/// Vanilla reads the id with `IdMap.byIdOrThrow`, which throws on -1 and
|
||||
/// drops the connection, so such a particle may only go to Horizon clients.
|
||||
fn mod_particle_only_for_horizon(particle: &Particle) -> bool {
|
||||
matches!(particle.data, Some(ParticleData::Mod(_)))
|
||||
}
|
||||
|
||||
/// One of a packet's `count` particles (`handleParticleEvent`): its position
|
||||
/// and speed. DEFAULT (0) draws both from a Gaussian; ALTERNATIVE (1) and
|
||||
/// ALTERNATIVE_WITH_SPEED (2) spread over `[0, dist)`, the first keeping
|
||||
/// the speeds as they are, the second scaling each by `nextDouble`.
|
||||
fn spread(
|
||||
r: &mut impl RandomSource,
|
||||
randomization: i32,
|
||||
pos: [f64; 3],
|
||||
dist: [f64; 3],
|
||||
speed: [f64; 3],
|
||||
) -> ([f64; 3], [f64; 3]) {
|
||||
if matches!(randomization, 1 | 2) {
|
||||
let at = [0, 1, 2].map(|k| pos[k] + r.next_double() * dist[k]);
|
||||
let v = if randomization == 2 {
|
||||
[0, 1, 2].map(|k| speed[k] * r.next_double())
|
||||
} else {
|
||||
speed
|
||||
};
|
||||
(at, v)
|
||||
} else {
|
||||
let at = [0, 1, 2].map(|k| pos[k] + r.next_gaussian() * dist[k]);
|
||||
(at, [0, 1, 2].map(|k| r.next_gaussian() * speed[k]))
|
||||
}
|
||||
}
|
||||
|
||||
/// An ARGB colour as the options' RGBA.
|
||||
fn rgba(argb: i32) -> [f32; 4] {
|
||||
let c = |shift: i32| ((argb >> shift) & 255) as f32 / 255.0;
|
||||
@@ -125,6 +165,7 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let horizon_only = mod_particle_only_for_horizon(&particle);
|
||||
let packet = C::LevelParticles(play::LevelParticles {
|
||||
particle,
|
||||
override_limiter: false,
|
||||
@@ -136,6 +177,9 @@ impl Game<'_> {
|
||||
randomization: 0,
|
||||
});
|
||||
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
|
||||
if horizon_only && g.brand != "horizon" {
|
||||
continue;
|
||||
}
|
||||
// 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))
|
||||
@@ -225,6 +269,13 @@ impl Game<'_> {
|
||||
options: &ParticleOptions,
|
||||
state: Option<BlockStateId>,
|
||||
) -> Option<Particle> {
|
||||
// A native mod's own type: no registry id, so the sentinel and the name.
|
||||
if let Some(name) = &options.mod_particle {
|
||||
return Some(Particle {
|
||||
id: MOD_PARTICLE_ID,
|
||||
data: Some(ParticleData::Mod(name.clone())),
|
||||
});
|
||||
}
|
||||
let name = format!("minecraft:{}", options.ty.name());
|
||||
let id = hub.registry_id("minecraft:particle_type", &name)?;
|
||||
use ParticleType as T;
|
||||
@@ -265,6 +316,11 @@ impl Game<'_> {
|
||||
p: &Particle,
|
||||
at: [f64; 3],
|
||||
) -> Option<ParticleOptions> {
|
||||
if p.id == MOD_PARTICLE_ID
|
||||
&& let Some(ParticleData::Mod(name)) = &p.data
|
||||
{
|
||||
return Some(ParticleOptions::named(name.clone()));
|
||||
}
|
||||
let name = self
|
||||
.remote
|
||||
.as_ref()?
|
||||
@@ -334,17 +390,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
for _ in 0..p.count {
|
||||
let r = self.particles.random();
|
||||
let at = [
|
||||
p.pos[0] + r.next_gaussian() * d[0],
|
||||
p.pos[1] + r.next_gaussian() * d[1],
|
||||
p.pos[2] + r.next_gaussian() * d[2],
|
||||
];
|
||||
let v = [
|
||||
r.next_gaussian() * s[0],
|
||||
r.next_gaussian() * s[1],
|
||||
r.next_gaussian() * s[2],
|
||||
];
|
||||
let (at, v) = spread(self.particles.random(), p.randomization, p.pos, d, s);
|
||||
add(&mut self.particles, at, v);
|
||||
}
|
||||
}
|
||||
@@ -409,6 +455,7 @@ impl Game<'_> {
|
||||
spread,
|
||||
speed,
|
||||
count,
|
||||
randomization,
|
||||
} = request;
|
||||
let Some(particle) = self.network_particle(&options, state) else {
|
||||
return;
|
||||
@@ -416,6 +463,7 @@ impl Game<'_> {
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let horizon_only = mod_particle_only_for_horizon(&particle);
|
||||
let body = C::LevelParticles(play::LevelParticles {
|
||||
particle,
|
||||
override_limiter: false,
|
||||
@@ -424,12 +472,12 @@ impl Game<'_> {
|
||||
dist: spread.map(|d| d as f32),
|
||||
max_speed: [speed as f32; 3],
|
||||
count,
|
||||
randomization: 0,
|
||||
randomization,
|
||||
})
|
||||
.encode();
|
||||
// BlockPos.closerToCenterThan(at, 32): from the centre of the
|
||||
// player's block.
|
||||
send_native_nearby(host, dimension, body, |position| {
|
||||
send_native_nearby(host, dimension, body, horizon_only, |position| {
|
||||
let d: f64 = (0..3)
|
||||
.map(|k| (position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
.sum();
|
||||
@@ -462,13 +510,40 @@ impl Game<'_> {
|
||||
)
|
||||
.encode();
|
||||
let range = f64::from(sound.volume.max(1.0)) * 16.0;
|
||||
send_native_nearby(host, sound.dimension, packet, |position| {
|
||||
send_native_nearby(host, sound.dimension, packet, false, |position| {
|
||||
let distance: f64 = (0..3).map(|k| (position[k] - sound.at[k]).powi(2)).sum();
|
||||
distance <= range * range
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `BucketItem.emptyContents` where water evaporates: `sendParticles(
|
||||
/// LARGE_SMOKE, x, y, z, 8, 1, 1, 1, 0, ALTERNATIVE)` from the block's
|
||||
/// corner, so each puff lands inside the block. The window draws its own.
|
||||
pub(super) fn evaporation_smoke(&mut self, pos: [i32; 3]) {
|
||||
let options = ParticleOptions::simple(ParticleType::LargeSmoke);
|
||||
let at = pos.map(f64::from);
|
||||
for _ in 0..8 {
|
||||
let r = self.particles.random();
|
||||
let p = [
|
||||
at[0] + r.next_double(),
|
||||
at[1] + r.next_double(),
|
||||
at[2] + r.next_double(),
|
||||
];
|
||||
self.particles.add(&options, p, [0.0; 3]);
|
||||
}
|
||||
self.lan_send_particles(QueuedParticle {
|
||||
dimension: self.dim.id(),
|
||||
options,
|
||||
state: None,
|
||||
at,
|
||||
spread: [1.0; 3],
|
||||
speed: 0.0,
|
||||
count: 8,
|
||||
randomization: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/// The particles guests' turns put off.
|
||||
pub(super) fn flush_lan_particles(&mut self) {
|
||||
for request in std::mem::take(&mut self.creatures.lan_particles) {
|
||||
@@ -616,3 +691,30 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::spread;
|
||||
use horizon_core::random::LegacyRandom;
|
||||
|
||||
/// The Nether's evaporation smoke (ALTERNATIVE, dist 1, speed 0) lands
|
||||
/// inside the block from its corner and stands still; DEFAULT spreads
|
||||
/// both ways.
|
||||
#[test]
|
||||
fn alternative_particles_spread_from_the_corner_over_dist() {
|
||||
let mut r = LegacyRandom::new(1);
|
||||
let mut below = false;
|
||||
for _ in 0..200 {
|
||||
let (at, v) = spread(&mut r, 1, [3.0, 64.0, -7.0], [1.0; 3], [0.0; 3]);
|
||||
assert!((3.0..4.0).contains(&at[0]), "{at:?}");
|
||||
assert!((64.0..65.0).contains(&at[1]), "{at:?}");
|
||||
assert!((-7.0..-6.0).contains(&at[2]), "{at:?}");
|
||||
assert_eq!(v, [0.0; 3]);
|
||||
let (_, v) = spread(&mut r, 2, [0.0; 3], [0.0; 3], [0.5, 0.0, 2.0]);
|
||||
assert!((0.0..0.5).contains(&v[0]) && v[1] == 0.0 && (0.0..2.0).contains(&v[2]));
|
||||
let (at, _) = spread(&mut r, 0, [0.0; 3], [1.0; 3], [0.0; 3]);
|
||||
below |= at.iter().any(|&c| c < 0.0);
|
||||
}
|
||||
assert!(below, "DEFAULT is a Gaussian around the position");
|
||||
}
|
||||
}
|
||||
@@ -548,11 +548,10 @@ impl Game<'_> {
|
||||
let (xd, zd) = sweep.knockback;
|
||||
let attacker = self.player_attacker(host, sweep.attacker);
|
||||
let land = |g: &mut Self| {
|
||||
let hurt = g.mob_hits_player(sweep.damage, sweep.from, "minecraft:player_attack");
|
||||
if hurt {
|
||||
if let Some((id, name)) = &attacker {
|
||||
g.note_named_attacker("minecraft:player_attack", *id, name.clone());
|
||||
}
|
||||
if let Some((id, name)) = &attacker {
|
||||
g.note_named_attacker("minecraft:player_attack", *id, name.clone());
|
||||
}
|
||||
if g.mob_hits_player(sweep.damage, sweep.from, "minecraft:player_attack") {
|
||||
g.knock_back_player(0.4, xd, zd);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -112,13 +112,24 @@ impl Game<'_> {
|
||||
if !integrated && (!host.settings.guest_commands || !allowed) {
|
||||
return false;
|
||||
}
|
||||
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
|
||||
let Some((permission, uuid, name)) = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.map(|g| (g.permission, g.uuid, g.name.clone()))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let command = line.trim_start_matches('/');
|
||||
if !self.signable_arguments(permission, command).is_empty() {
|
||||
return false;
|
||||
}
|
||||
// The mods hear it as they hear a typed command; the batch is quiet,
|
||||
// so their replies go nowhere.
|
||||
let mut canceled = false;
|
||||
self.native_player_command(uuid, &name, command, &mut canceled, &mut Vec::new());
|
||||
if canceled {
|
||||
return false;
|
||||
}
|
||||
self.run_guest_command(GuestCommand {
|
||||
conn,
|
||||
command: command.to_owned(),
|
||||
|
||||
@@ -1205,8 +1205,14 @@ impl Game<'_> {
|
||||
None => {
|
||||
let mut handled = false;
|
||||
rules.edit(|b| handled = b.key(key, ctrl, false));
|
||||
if !handled && types_text {
|
||||
self.world_options_text(event.text.as_deref());
|
||||
// Only a key with text types: a `None` text is a
|
||||
// backspace to `world_options_text`, which is the
|
||||
// control socket's `\b`, not a key's.
|
||||
if !handled
|
||||
&& types_text
|
||||
&& let Some(text) = event.text.as_deref()
|
||||
{
|
||||
self.world_options_text(Some(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1312,8 +1318,13 @@ impl Game<'_> {
|
||||
o.port.shortcut(shortcut, &mut self.clipboard);
|
||||
return;
|
||||
}
|
||||
if !o.port.key(key, ctrl, false) && types_text {
|
||||
self.world_options_text(event.text.as_deref());
|
||||
// Only a key with text types (a `None` is a backspace to
|
||||
// `world_options_text`, the control socket's `\b`).
|
||||
if !o.port.key(key, ctrl, false)
|
||||
&& types_text
|
||||
&& let Some(text) = event.text.as_deref()
|
||||
{
|
||||
self.world_options_text(Some(text));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
|
||||
/// A guest's open workstation menu.
|
||||
pub(super) struct GuestStation {
|
||||
window: i32,
|
||||
pub(super) window: i32,
|
||||
state: i32,
|
||||
kind: StationKind,
|
||||
pos: [i32; 3],
|
||||
@@ -34,9 +34,9 @@ pub(super) struct GuestStation {
|
||||
/// A guest's open lectern: `LecternMenu` over the block entity, made on the
|
||||
/// menu engine for each click (`Game::lectern_menu`).
|
||||
pub(super) struct GuestLectern {
|
||||
window: i32,
|
||||
pub(super) window: i32,
|
||||
state: i32,
|
||||
key: (i32, i32, i32),
|
||||
pub(super) key: (i32, i32, i32),
|
||||
/// The book and page the client was last sent (`broadcastChanges`).
|
||||
sent: Option<(Option<ItemStack>, i32)>,
|
||||
}
|
||||
@@ -69,10 +69,8 @@ impl Game<'_> {
|
||||
let Some((menu_type, kind, _)) = menu_of(station.kind) else {
|
||||
return false;
|
||||
};
|
||||
self.lan_station_closed(host, conn, None);
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
// The container it replaces loses its opener.
|
||||
self.guest_container_closed(host, conn);
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let title = horizon_protocol::text::translatable(station.kind.title_key(), &[]);
|
||||
let Some(window) = host.hub.open_window(conn, menu_type, title) else {
|
||||
return true;
|
||||
@@ -151,9 +149,8 @@ impl Game<'_> {
|
||||
/// `LecternBlock.openScreen` for a guest: `minecraft:lectern` over the
|
||||
/// block entity, with its book and page.
|
||||
pub(super) fn lan_open_lectern(&mut self, host: &mut LanHost, conn: u32, key: (i32, i32, i32)) {
|
||||
self.lan_station_closed(host, conn, None);
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
self.guest_container_closed(host, conn);
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let title = horizon_protocol::text::translatable("container.lectern", &[]);
|
||||
let Some(window) = host.hub.open_window(conn, "minecraft:lectern", title) else {
|
||||
return;
|
||||
@@ -644,10 +641,7 @@ impl Game<'_> {
|
||||
let Some(gs) = host.play.get(&conn).and_then(|p| p.station.as_ref()) else {
|
||||
continue;
|
||||
};
|
||||
let [x, y, z] = gs.pos;
|
||||
let name = &self.res.facts().get(self.world.block(x, y, z)).name;
|
||||
let block = StationKind::from_block(name) == Some(gs.kind);
|
||||
if !(block && g.within_block_range(gs.pos, 4.0)) {
|
||||
if !self.lan_station_valid(g, gs) {
|
||||
let window = gs.window;
|
||||
self.lan_station_closed(host, conn, Some(window));
|
||||
host.hub.send_to(
|
||||
@@ -669,6 +663,32 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A station menu's `stillValid`: its block is still that station and the
|
||||
/// guest is within `isWithinBlockInteractionRange(pos, 4)`.
|
||||
pub(super) fn lan_station_valid(
|
||||
&self,
|
||||
g: &horizon_client::lan::guest::Guest,
|
||||
gs: &GuestStation,
|
||||
) -> bool {
|
||||
let [x, y, z] = gs.pos;
|
||||
let name = &self.res.facts().get(self.world.block(x, y, z)).name;
|
||||
StationKind::from_block(name) == Some(gs.kind) && g.within_block_range(gs.pos, 4.0)
|
||||
}
|
||||
|
||||
/// `LecternMenu.stillValid` (`Container.stillValidBlockEntity`): the block
|
||||
/// entity still holds a book and the guest is within the interaction
|
||||
/// range and its buffer.
|
||||
pub(super) fn lan_lectern_valid(
|
||||
&self,
|
||||
g: &horizon_client::lan::guest::Guest,
|
||||
key: (i32, i32, i32),
|
||||
) -> bool {
|
||||
matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
Some(BlockEntity::Lectern(l)) if l.book.is_some()
|
||||
) && g.within_block_range([key.0, key.1, key.2], 4.0)
|
||||
}
|
||||
|
||||
/// The guests' lecterns each tick: `stillValid` (the block entity, its
|
||||
/// book, the reach) closes them, and `broadcastChanges` sends a book or
|
||||
/// page another player changed.
|
||||
@@ -685,13 +705,7 @@ impl Game<'_> {
|
||||
if !host.hub.here(g) {
|
||||
continue;
|
||||
}
|
||||
let has_book = matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
Some(BlockEntity::Lectern(l)) if l.book.is_some()
|
||||
);
|
||||
// Container.stillValidBlockEntity: the interaction range and its buffer.
|
||||
let near = g.within_block_range([key.0, key.1, key.2], 4.0);
|
||||
if has_book && near {
|
||||
if self.lan_lectern_valid(g, key) {
|
||||
self.lan_send_lectern(host, conn, false);
|
||||
} else {
|
||||
self.lan_station_closed(host, conn, Some(window));
|
||||
|
||||
@@ -57,9 +57,8 @@ impl Game<'_> {
|
||||
state.session.update_special_prices();
|
||||
}
|
||||
let title = self.merchant_display_name(&state);
|
||||
// The container or workstation the trading screen replaces is closed.
|
||||
self.guest_container_closed(host, conn);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
let Some(window) = host.hub.open_merchant_window(conn, title) else {
|
||||
self.merchant_closed(&mut state);
|
||||
return;
|
||||
@@ -350,28 +349,37 @@ impl Game<'_> {
|
||||
self.merchant_closed(&mut gm.merchant);
|
||||
true
|
||||
}
|
||||
/// `Merchant.stillValid` for a trader: it is alive and the guest is within
|
||||
/// the entity interaction range plus 4 of its box.
|
||||
pub(super) fn lan_merchant_valid(
|
||||
&self,
|
||||
g: &horizon_client::lan::guest::Guest,
|
||||
id: u32,
|
||||
) -> bool {
|
||||
self.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.is_some_and(|m| m.alive() && guest_within_entity_range(g, &m.bounds(), 4.0))
|
||||
}
|
||||
/// `Villager.customServerAiStep` for the guests' trading villagers: the
|
||||
/// TRADE reputation event, then the prices follow.
|
||||
pub(super) fn lan_merchant_tick(&mut self, host: &mut LanHost) {
|
||||
// `AbstractVillager.stillValid`: the trade screen closes once the trader
|
||||
// is gone or dead, or the guest is more than 4 blocks past its reach.
|
||||
let far: Vec<(u32, i32)> =
|
||||
host.play
|
||||
.iter()
|
||||
.filter_map(|(conn, p)| {
|
||||
let gm = p.merchant.as_ref()?;
|
||||
let id = gm.merchant.mob?;
|
||||
let g = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == *conn && host.hub.here(g))?;
|
||||
let valid = self.mobs.iter().find(|m| m.id == id).is_some_and(|m| {
|
||||
m.alive() && guest_within_entity_range(g, &m.bounds(), 4.0)
|
||||
});
|
||||
(!valid).then_some((*conn, gm.window))
|
||||
})
|
||||
.collect();
|
||||
let far: Vec<(u32, i32)> = host
|
||||
.play
|
||||
.iter()
|
||||
.filter_map(|(conn, p)| {
|
||||
let gm = p.merchant.as_ref()?;
|
||||
let id = gm.merchant.mob?;
|
||||
let g = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == *conn && host.hub.here(g))?;
|
||||
(!self.lan_merchant_valid(g, id)).then_some((*conn, gm.window))
|
||||
})
|
||||
.collect();
|
||||
for (conn, window) in far {
|
||||
if self.lan_merchant_closed(host, conn, Some(window)) {
|
||||
host.hub.send_to(
|
||||
|
||||
@@ -127,6 +127,14 @@ impl Game<'_> {
|
||||
}
|
||||
/// `Leashable.dropLeash` / `removeLeash`: the lead falls as an item.
|
||||
pub(super) fn break_leash(&mut self, id: u32, drop: bool, sound: bool) {
|
||||
self.drop_leash(id, drop, sound, false);
|
||||
}
|
||||
/// `break_leash` by the acting player's use (untying it, shears).
|
||||
fn break_leash_used(&mut self, id: u32, drop: bool) {
|
||||
self.drop_leash(id, drop, false, true);
|
||||
}
|
||||
fn drop_leash(&mut self, id: u32, drop: bool, sound: bool, used: bool) {
|
||||
let killer = self.lead_killers.remove(&id).map(|(killer, _)| killer);
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
@@ -136,16 +144,54 @@ impl Game<'_> {
|
||||
// Leashable.tickLeash gates a dying entity's lead drop on entity_drops.
|
||||
let drop = drop && (m.alive() || self.ui.rules.bool("entity_drops"));
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
let kind = super::native_hooks::mob_kind(m);
|
||||
if sound {
|
||||
self.lead_sound("break", at);
|
||||
}
|
||||
if drop {
|
||||
if !drop {
|
||||
return;
|
||||
}
|
||||
// The mods' say when a player is behind it: the user, else the
|
||||
// killer of a mob that died leashed.
|
||||
let player = if used {
|
||||
Some(self.native_acting_id())
|
||||
} else {
|
||||
killer
|
||||
};
|
||||
let Some(player) = player else {
|
||||
self.drop_lead_item(at);
|
||||
return;
|
||||
};
|
||||
let mut drops = vec![ItemStack::new("minecraft:lead", 1)];
|
||||
let context = super::native_hooks::NativeLootDrops {
|
||||
table: "",
|
||||
dimension: self.dim.id(),
|
||||
origin: at,
|
||||
block: None,
|
||||
entity: Some((id, &kind)),
|
||||
player: Some(player),
|
||||
tool: None,
|
||||
explosion: None,
|
||||
used,
|
||||
};
|
||||
self.native_loot_drops(&context, &mut drops);
|
||||
for stack in drops {
|
||||
if stack.count > 0 && &*stack.item != "minecraft:air" {
|
||||
let v = super::dispenser::new_item_motion();
|
||||
self.spawn_item(stack, at, v, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Holders read from a save become real once the entity is found; a
|
||||
/// holder that never shows up drops the lead after 100 ticks.
|
||||
pub(super) fn tick_leads(&mut self) {
|
||||
// A corpse gone without dropping its lead takes its killer along.
|
||||
// Only this level's: the others' mobs aren't in place.
|
||||
if !self.lead_killers.is_empty() {
|
||||
let (mobs, dim) = (&self.mobs, self.dim);
|
||||
self.lead_killers
|
||||
.retain(|id, (_, at)| *at != dim || mobs.iter().any(|m| m.id == *id && !m.removed));
|
||||
}
|
||||
let player_uuid = self.level.player_uuid.unwrap_or([0, 0, 0, 1]);
|
||||
for i in 0..self.mobs.len() {
|
||||
let Some(pending) = self.mobs[i].extra.leash_pending else {
|
||||
@@ -222,7 +268,7 @@ impl Game<'_> {
|
||||
if item == "minecraft:shears" {
|
||||
let mut dropped = false;
|
||||
if self.mobs[index].extra.leash.is_some() {
|
||||
self.break_leash(id, true, false);
|
||||
self.break_leash_used(id, true);
|
||||
dropped = true;
|
||||
}
|
||||
let others: Vec<u32> = self
|
||||
@@ -232,7 +278,7 @@ impl Game<'_> {
|
||||
.map(|m| m.id)
|
||||
.collect();
|
||||
for o in others {
|
||||
self.break_leash(o, true, false);
|
||||
self.break_leash_used(o, true);
|
||||
dropped = true;
|
||||
}
|
||||
let boats = self.boats_on(Holder::Mob(id));
|
||||
@@ -246,9 +292,16 @@ impl Game<'_> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if self.mobs[index].extra.leash == Some(Holder::Player) {
|
||||
// Mob.checkAndHandleImportantInteractions: only the player the lead
|
||||
// is tied to unties it (`getLeashHolder() == player`).
|
||||
if self.mobs[index].extra.leash == Some(Holder::Player)
|
||||
&& self.mobs[index]
|
||||
.extra
|
||||
.leash_player
|
||||
.is_none_or(|p| p == self.acting_player_uuid())
|
||||
{
|
||||
let creative = self.creative;
|
||||
self.break_leash(id, !creative, false);
|
||||
self.break_leash_used(id, !creative);
|
||||
self.lead_game_event("minecraft:entity_interact", at);
|
||||
self.lead_sound("untied", at);
|
||||
return true;
|
||||
@@ -257,7 +310,7 @@ impl Game<'_> {
|
||||
let center = self.player_center();
|
||||
if self.can_attach_lead(index, center) {
|
||||
if self.mobs[index].extra.leash.is_some() {
|
||||
self.break_leash(id, true, false);
|
||||
self.break_leash_used(id, true);
|
||||
}
|
||||
let holder = self.acting_player_uuid();
|
||||
self.mobs[index].extra.leash = Some(Holder::Player);
|
||||
@@ -301,15 +354,34 @@ impl Game<'_> {
|
||||
.filter(|&i| self.mobs[i].extra.leash == Some(Holder::Knot(knot_pos)))
|
||||
.collect();
|
||||
let existing_boats = self.boats_on(Holder::Knot(knot_pos));
|
||||
// `LeashFenceKnotEntity.interact` runs only when the ray meets the knot
|
||||
// before the post: the knot's box (`recalculateBoundingBox`, 0.375 wide,
|
||||
// y + 0.375 to y + 0.875) is wider than the post, so a hit point on the
|
||||
// post inside that box went through the knot first. Any other click on
|
||||
// the fence is `FenceBlock.useWithoutItem` / `LeadItem.useOn`, which only
|
||||
// ties the player's own mobs (`LeadItem.bindPlayerMobs`).
|
||||
let knot_hit = !(existing.is_empty() && existing_boats.is_empty()) && {
|
||||
let box_ = leash::knot_state(knot_pos);
|
||||
let half = box_.width / 2.0 + 1.0e-6;
|
||||
(hit.point[0] - box_.position.x).abs() <= half
|
||||
&& (hit.point[2] - box_.position.z).abs() <= half
|
||||
&& hit.point[1] >= box_.position.y - 1.0e-6
|
||||
&& hit.point[1] <= box_.position.y + box_.height + 1.0e-6
|
||||
// The knot is an entity: a hit on it counts only within the
|
||||
// entity interaction range (`filterHitResult`), shorter than
|
||||
// the block range the fence was hit at.
|
||||
&& self.eye_position().is_some_and(|eye| {
|
||||
let d = [0, 1, 2].map(|i| hit.point[i] - eye[i]);
|
||||
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] <= self.entity_reach * self.entity_reach
|
||||
})
|
||||
};
|
||||
let held_item = self.held().map(|s| s.item.to_string());
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
|
||||
// Shears snip every lead on the knot.
|
||||
if held_item.as_deref() == Some("minecraft:shears")
|
||||
&& !(existing.is_empty() && existing_boats.is_empty())
|
||||
{
|
||||
if knot_hit && held_item.as_deref() == Some("minecraft:shears") {
|
||||
for i in existing {
|
||||
let id = self.mobs[i].id;
|
||||
self.break_leash(id, true, false);
|
||||
self.break_leash_used(id, true);
|
||||
}
|
||||
self.drop_boat_leashes(&existing_boats);
|
||||
self.audio
|
||||
@@ -335,7 +407,7 @@ impl Game<'_> {
|
||||
attached = true;
|
||||
}
|
||||
}
|
||||
if !attached && !sneaking {
|
||||
if !attached && !sneaking && knot_hit {
|
||||
let player = self.player_center();
|
||||
let holder = self.acting_player_uuid();
|
||||
for i in existing {
|
||||
|
||||
@@ -82,6 +82,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
None if &*facts.get(state).name == "minecraft:water" => (p, count * 3, 3.0, 1.0, false),
|
||||
// A mod block the level answered bone meal for: the port knows no
|
||||
// `BonemealableBlock.Type` for it, and vanilla's `GROWER` is the
|
||||
// one a block growing itself has, so the sparks stay inside it
|
||||
// rather than spreading over its neighbours.
|
||||
None if !facts.get(state).name.starts_with("minecraft:") => {
|
||||
(p, count, 0.5, self.block_shape_top(p), true)
|
||||
}
|
||||
None => return,
|
||||
};
|
||||
self.spawn_particles(at, count, xz, height, floating, ParticleType::HappyVillager);
|
||||
|
||||
@@ -411,18 +411,23 @@ impl Game<'_> {
|
||||
}
|
||||
self.update_dedicated_streaming();
|
||||
self.update_world();
|
||||
result = if paused {
|
||||
self.tick_lan(true);
|
||||
Ok(())
|
||||
} else if k == 0 {
|
||||
self.tick()
|
||||
} else {
|
||||
let running = running.unwrap_or_else(|| self.clock.world_runs());
|
||||
if running {
|
||||
self.tick_weather_effects();
|
||||
}
|
||||
self.tick_world(running)
|
||||
};
|
||||
result = horizon_client::block_contact::in_level(
|
||||
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(dim.id()),
|
||||
|| {
|
||||
if paused {
|
||||
self.tick_lan(true);
|
||||
Ok(())
|
||||
} else if k == 0 {
|
||||
self.tick()
|
||||
} else {
|
||||
let running = running.unwrap_or_else(|| self.clock.world_runs());
|
||||
if running {
|
||||
self.tick_weather_effects();
|
||||
}
|
||||
self.tick_world(running)
|
||||
}
|
||||
},
|
||||
);
|
||||
if result.is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ impl Game<'_> {
|
||||
pub(super) fn tick_thunder(&mut self) {
|
||||
// isRaining (rain level > 0.2) and isThundering (> 0.9, sky light, no ceiling).
|
||||
let (rain, thunder) = self.world.weather.levels(1.0);
|
||||
if rain <= 0.2 || thunder <= 0.9 || self.dim != super::Dim::Overworld {
|
||||
if rain <= 0.2 || thunder <= 0.9 || self.dim != super::Dim::Overworld || self.world_debug {
|
||||
return;
|
||||
}
|
||||
let mut scratch = std::mem::take(&mut self.spawn_scratch);
|
||||
|
||||
@@ -33,6 +33,9 @@ pub(super) enum CartKind {
|
||||
/// `MinecartCommandBlock`: its `BaseCommandBlock` is `Minecart::command`,
|
||||
/// run by a powered activator rail (`game/command_minecarts.rs`).
|
||||
CommandBlock,
|
||||
/// `MinecartSpawner`: a `BaseSpawner` that ticks where the cart is
|
||||
/// (`Minecart::spawner`). Only `/summon` makes one: there is no item.
|
||||
Spawner,
|
||||
}
|
||||
impl CartKind {
|
||||
pub fn from_id(id: &str) -> Option<Self> {
|
||||
@@ -43,6 +46,7 @@ impl CartKind {
|
||||
"furnace_minecart" => Self::Furnace,
|
||||
"tnt_minecart" => Self::Tnt,
|
||||
"command_block_minecart" => Self::CommandBlock,
|
||||
"spawner_minecart" => Self::Spawner,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -54,6 +58,15 @@ impl CartKind {
|
||||
Self::Furnace => "minecraft:furnace_minecart",
|
||||
Self::Tnt => "minecraft:tnt_minecart",
|
||||
Self::CommandBlock => "minecraft:command_block_minecart",
|
||||
Self::Spawner => "minecraft:spawner_minecart",
|
||||
}
|
||||
}
|
||||
/// `AbstractMinecart.getDropItem` and `getPickResult`: the item a cart drops and
|
||||
/// is picked as (a spawner minecart's is a plain minecart: there is no item of its own).
|
||||
pub fn item(self) -> &'static str {
|
||||
match self {
|
||||
Self::Spawner => "minecraft:minecart",
|
||||
kind => kind.id(),
|
||||
}
|
||||
}
|
||||
pub(super) fn slots(self) -> usize {
|
||||
@@ -70,7 +83,7 @@ impl CartKind {
|
||||
/// an empty cart.
|
||||
pub fn interact_passes(self, sneaking: bool, ridden: bool, game_master: bool) -> bool {
|
||||
match self {
|
||||
Self::Tnt => true,
|
||||
Self::Tnt | Self::Spawner => true,
|
||||
Self::CommandBlock => !game_master,
|
||||
Self::Rideable => sneaking || ridden,
|
||||
Self::Chest | Self::Hopper | Self::Furnace => false,
|
||||
@@ -119,6 +132,9 @@ pub(super) struct Minecart {
|
||||
/// An activator rail asked for the command during this tick; it runs once
|
||||
/// the cart is back in the level (`command_minecarts.rs`).
|
||||
pub command_due: bool,
|
||||
/// Spawner minecarts: the cart's `BaseSpawner` (`Delay`, `SpawnData`,
|
||||
/// `SpawnPotentials` and the rest as a spawner block saves them).
|
||||
pub spawner: Option<Box<horizon_client::mobs::spawner::Spawner>>,
|
||||
/// The synched `DATA_ID_COMMAND_NAME` and `DATA_ID_LAST_OUTPUT`, which
|
||||
/// follow the command block only at `onUpdated`.
|
||||
pub synced_command: String,
|
||||
@@ -255,6 +271,8 @@ impl Minecart {
|
||||
tick_count: 0,
|
||||
last_activated: 0,
|
||||
command_due: false,
|
||||
// A fresh `BaseSpawner`, as `MinecartSpawner`'s constructor makes.
|
||||
spawner: (kind == CartKind::Spawner).then(Default::default),
|
||||
synced_command: String::new(),
|
||||
synced_output: String::new(),
|
||||
}
|
||||
@@ -293,6 +311,7 @@ impl CartModels {
|
||||
CartKind::Furnace,
|
||||
CartKind::Tnt,
|
||||
CartKind::CommandBlock,
|
||||
CartKind::Spawner,
|
||||
];
|
||||
let models = kinds
|
||||
.into_iter()
|
||||
@@ -906,6 +925,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// MinecartSpawner.tick: `BaseSpawner.serverTick` at the cart's block.
|
||||
CartKind::Spawner => {
|
||||
if let Some(mut spawner) = c.spawner.take() {
|
||||
let key = (
|
||||
c.position.x.floor() as i32,
|
||||
c.position.y.floor() as i32,
|
||||
c.position.z.floor() as i32,
|
||||
);
|
||||
let _ = self.server_tick_spawner_of(key, &mut spawner, Some(c.id));
|
||||
c.spawner = Some(spawner);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
@@ -963,6 +994,7 @@ impl Game<'_> {
|
||||
if let Some(i) = self.carts.iter().position(|x| x.id == c.id) {
|
||||
self.eject_cart_mobs(i);
|
||||
}
|
||||
self.release_vehicle_container(c.id);
|
||||
self.carts.retain(|x| x.id != c.id);
|
||||
// ExplosionInteraction.TNT: the tnt_explosion_drop_decay rule.
|
||||
let interaction = self.destroy_type("tnt_explosion_drop_decay");
|
||||
@@ -1226,6 +1258,7 @@ impl Game<'_> {
|
||||
}
|
||||
} else {
|
||||
self.eject_cart_mobs(i);
|
||||
self.release_vehicle_container(id);
|
||||
self.carts.remove(i);
|
||||
}
|
||||
}
|
||||
@@ -1331,6 +1364,7 @@ impl Game<'_> {
|
||||
self.push_from_cart(i);
|
||||
} else {
|
||||
self.eject_cart_mobs(i);
|
||||
self.release_vehicle_container(c.id);
|
||||
self.carts.remove(i);
|
||||
self.riding = None;
|
||||
}
|
||||
@@ -1539,6 +1573,8 @@ impl Game<'_> {
|
||||
// client opens the editor, `open_cart_command_block`), anyone else
|
||||
// PASSes to the held item. SUCCESS swings on the client only.
|
||||
CartKind::CommandBlock => return self.can_use_game_master_blocks(),
|
||||
// MinecartSpawner has no interact of its own (`Entity.interact` passes).
|
||||
CartKind::Spawner => return false,
|
||||
}
|
||||
self.swing_arm();
|
||||
true
|
||||
@@ -1577,6 +1613,19 @@ impl Game<'_> {
|
||||
self.store_cart_items(key);
|
||||
self.world.block_entities.remove(&key);
|
||||
}
|
||||
/// A chest or hopper minecart or a chest boat is about to be removed
|
||||
/// (`Containers.dropContents` / `AbstractChestBoat.remove`): what an open
|
||||
/// menu edited goes back to the vehicle, so it is what drops or travels
|
||||
/// with it, and the menu's stand-in leaves the level. A menu that is
|
||||
/// still open over it then has nothing left to take.
|
||||
pub(super) fn release_vehicle_container(&mut self, id: u32) {
|
||||
let key = (i32::MAX - 15, CART_KEY_Y, id as i32);
|
||||
self.store_cart_items(key);
|
||||
self.world.block_entities.remove(&key);
|
||||
if self.container == Some(key) {
|
||||
self.container = None;
|
||||
}
|
||||
}
|
||||
/// The menu of a chest or hopper minecart or a chest boat works on the
|
||||
/// vehicle's own items (`ContainerEntity`): the placeholder under `key`
|
||||
/// that the menu edits is copied back to the vehicle.
|
||||
@@ -1644,6 +1693,8 @@ impl Game<'_> {
|
||||
}
|
||||
if burning && self.carts[i].kind == CartKind::Tnt {
|
||||
self.eject_cart_mobs(i);
|
||||
let id = self.carts[i].id;
|
||||
self.release_vehicle_container(id);
|
||||
let c = self.carts.remove(i);
|
||||
self.explode_cart(&c, arrow_velocity.length_squared());
|
||||
return;
|
||||
@@ -1669,6 +1720,10 @@ impl Game<'_> {
|
||||
if !tnt_prime {
|
||||
self.eject_cart_mobs(i);
|
||||
}
|
||||
let id = self.carts[i].id;
|
||||
if !tnt_prime {
|
||||
self.release_vehicle_container(id);
|
||||
}
|
||||
let mut c = self.carts.remove(i);
|
||||
if c.kind == CartKind::Tnt && (fire || speed >= f64::from(0.01f32)) {
|
||||
// MinecartTNT.destroy: prime instead of breaking.
|
||||
@@ -1685,7 +1740,7 @@ impl Game<'_> {
|
||||
self.spawn_item(s.clone(), at, [0.0, 0.1, 0.0], 10);
|
||||
}
|
||||
if !creative {
|
||||
self.spawn_item(ItemStack::new(c.kind.id(), 1), at, [0.0, 0.1, 0.0], 10);
|
||||
self.spawn_item(ItemStack::new(c.kind.item(), 1), at, [0.0, 0.1, 0.0], 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1805,6 +1860,8 @@ impl Game<'_> {
|
||||
),
|
||||
CartKind::Tnt => registry.default_state("minecraft:tnt"),
|
||||
CartKind::CommandBlock => registry.default_state("minecraft:command_block"),
|
||||
// MinecartSpawner.getDefaultDisplayBlockState.
|
||||
CartKind::Spawner => registry.default_state("minecraft:spawner"),
|
||||
}
|
||||
}
|
||||
/// `AbstractMinecartRenderer` (old render): on the rail between the
|
||||
@@ -2027,6 +2084,16 @@ impl Minecart {
|
||||
}
|
||||
}
|
||||
}
|
||||
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
|
||||
CartKind::Spawner => {
|
||||
if let Some(spawner) = &self.spawner {
|
||||
for (key, value) in
|
||||
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
|
||||
{
|
||||
put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
CartKind::Rideable => {}
|
||||
}
|
||||
// A loaded loot table goes back while the items are still its roll;
|
||||
@@ -2071,6 +2138,12 @@ impl Minecart {
|
||||
c.synced_command.clone_from(&c.command.command);
|
||||
c.synced_output = c.command.last_output_string();
|
||||
}
|
||||
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
|
||||
if kind == CartKind::Spawner {
|
||||
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
|
||||
tag,
|
||||
)));
|
||||
}
|
||||
Some(c)
|
||||
}
|
||||
}
|
||||
@@ -2103,6 +2176,67 @@ mod tests {
|
||||
assert_eq!(display_offset(CartKind::Rideable), 6.0);
|
||||
}
|
||||
|
||||
/// `MinecartSpawner`: its `BaseSpawner` is saved and loaded with the cart (the delay, the
|
||||
/// ranges and the `SpawnData`), it drops and is picked as a plain minecart, and it shows a
|
||||
/// spawner block.
|
||||
#[test]
|
||||
fn a_spawner_cart_keeps_its_spawner_and_drops_a_minecart() {
|
||||
use horizon_world::nbt::Tag;
|
||||
let saved = Tag::compound([
|
||||
("id", Tag::String("minecraft:spawner_minecart".into())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(
|
||||
[0.5f64, 40.5, 0.5]
|
||||
.map(|v| Tag::Double(v.to_bits()))
|
||||
.to_vec(),
|
||||
),
|
||||
),
|
||||
("Delay", Tag::Short(37)),
|
||||
("MaxSpawnDelay", Tag::Short(120)),
|
||||
("SpawnRange", Tag::Short(2)),
|
||||
(
|
||||
"SpawnData",
|
||||
Tag::compound([(
|
||||
"entity",
|
||||
Tag::compound([("id", Tag::String("minecraft:zombie".into()))]),
|
||||
)]),
|
||||
),
|
||||
]);
|
||||
let mut cart = Minecart::from_nbt(&saved, 1, CartKind::Spawner).unwrap();
|
||||
let spawner = cart.spawner.as_mut().expect("a spawner cart has a spawner");
|
||||
assert_eq!(
|
||||
(
|
||||
spawner.spawn_delay,
|
||||
spawner.max_spawn_delay,
|
||||
spawner.spawn_range
|
||||
),
|
||||
(37, 120, 2)
|
||||
);
|
||||
// No raw tag to copy from: what comes out is the cart's own save.
|
||||
spawner.spawn_delay = 41;
|
||||
let out = cart.to_nbt([0; 4], None);
|
||||
assert_eq!(out.get("Delay"), Some(&Tag::Short(41)));
|
||||
assert_eq!(out.get("MaxSpawnDelay"), Some(&Tag::Short(120)));
|
||||
assert_eq!(
|
||||
out.get("SpawnData")
|
||||
.and_then(|d| d.get("entity"))
|
||||
.and_then(|e| e.get("id")),
|
||||
Some(&Tag::String("minecraft:zombie".into()))
|
||||
);
|
||||
assert_eq!(CartKind::Spawner.item(), "minecraft:minecart");
|
||||
assert_eq!(
|
||||
CartKind::from_id("minecraft:spawner_minecart"),
|
||||
Some(CartKind::Spawner)
|
||||
);
|
||||
// A summoned one has a spawner of its own.
|
||||
assert!(
|
||||
Minecart::new(2, CartKind::Spawner, Vec3::ZERO)
|
||||
.spawner
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cow_boards_an_empty_cart_and_an_iron_golem_does_not() {
|
||||
assert!(pickup_instead_of_push(Species::Cow, false, false));
|
||||
|
||||
@@ -14,10 +14,7 @@ use horizon_client::{
|
||||
mobs::{
|
||||
Mob, Species,
|
||||
equip::{self, Facts, ItemTags, Slot},
|
||||
finalize::{
|
||||
self, set_drop_chance, set_item, set_slot_enchantments, slot_enchantments,
|
||||
stored_drop_chance,
|
||||
},
|
||||
finalize::{self, set_drop_chance, set_item_stack, stored_drop_chance},
|
||||
},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
@@ -92,27 +89,29 @@ impl Game<'_> {
|
||||
if !replace {
|
||||
return None;
|
||||
}
|
||||
// shortcut: a mob's equipment is one item per slot here, so a hand
|
||||
// takes one item of a stack where vanilla takes the whole stack;
|
||||
// upgrade with a per-slot count in `Extra`.
|
||||
let taken = 1;
|
||||
// `EquipmentSlot.limit`: an armour slot takes one item, a hand the
|
||||
// whole stack.
|
||||
let taken = if slot.is_armor() {
|
||||
1
|
||||
} else {
|
||||
stack.count.max(1)
|
||||
};
|
||||
let m = &mut self.mobs[index];
|
||||
let old = finalize::item(m, slot.index());
|
||||
let old = finalize::item_stack(m, slot.index());
|
||||
let chance = stored_drop_chance(m, slot.index());
|
||||
let old_enchantments = slot_enchantments(m, slot.index());
|
||||
// `DropChances.byEquipment` against the roll the equip makes.
|
||||
let drop_old = old.is_some() && (m.random.next_float() - 0.1).max(0.0) < chance;
|
||||
set_item(m, slot.index(), item);
|
||||
// The stack's enchantments come with it: a Sharpness V sword stays sharp.
|
||||
set_slot_enchantments(m, slot.index(), stack.enchantments().to_vec());
|
||||
// The whole stack comes with it: its enchantments (a Sharpness V sword
|
||||
// stays sharp), damage, name and components.
|
||||
let mut equipped = stack.clone();
|
||||
equipped.count = taken;
|
||||
set_item_stack(m, slot.index(), equipped);
|
||||
// `setItemSlotAndDropWhenKilled`, then `persistenceRequired`.
|
||||
set_drop_chance(m, slot.index(), 2.0);
|
||||
m.persistent = true;
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
if drop_old && let Some(old) = old {
|
||||
let mut dropped = ItemStack::new(&old, 1);
|
||||
dropped.set_crafting_enchantments(old_enchantments);
|
||||
self.spawn_item(dropped, at, super::dispenser::new_item_motion(), 10);
|
||||
self.spawn_item(old, at, super::dispenser::new_item_motion(), 10);
|
||||
}
|
||||
Some(taken)
|
||||
}
|
||||
@@ -120,9 +119,10 @@ impl Game<'_> {
|
||||
/// `Mob.canReplaceCurrentItem` for what `m` wears or holds in `slot`.
|
||||
fn can_replace(&self, index: usize, stack: &ItemStack, slot: Slot) -> bool {
|
||||
let m = &self.mobs[index];
|
||||
let Some(current) = finalize::item(m, slot.index()) else {
|
||||
let Some(worn_stack) = finalize::item_stack(m, slot.index()) else {
|
||||
return true;
|
||||
};
|
||||
let current = worn_stack.item.clone();
|
||||
let species = m.species();
|
||||
// `Drowned.canReplaceCurrentItem` never parts with a nautilus shell.
|
||||
if species == Species::Drowned && &*current == "minecraft:nautilus_shell" {
|
||||
@@ -140,21 +140,15 @@ impl Game<'_> {
|
||||
// Binding's, the only enchantment that carries one.
|
||||
prevents_armor_change: stack.enchantment_level("minecraft:binding_curse") > 0,
|
||||
};
|
||||
let worn_enchantments = slot_enchantments(m, slot.index());
|
||||
let (armor, toughness, attack) = self.attributes(m, ¤t, slot);
|
||||
// shortcut: what a mob already wears keeps no damage value or name here
|
||||
// (`Extra` records neither), so a worn piece always compares as fresh;
|
||||
// upgrade with a stack per equipment slot.
|
||||
let worn = Facts {
|
||||
armor,
|
||||
armor_toughness: toughness,
|
||||
attack_damage: attack,
|
||||
enchantments: worn_enchantments.len(),
|
||||
damage: 0,
|
||||
custom_name: false,
|
||||
prevents_armor_change: worn_enchantments
|
||||
.iter()
|
||||
.any(|(id, level)| &**id == "minecraft:binding_curse" && *level > 0),
|
||||
enchantments: worn_stack.enchantments().len(),
|
||||
damage: worn_stack.damage as i32,
|
||||
custom_name: worn_stack.custom_name().is_some(),
|
||||
prevents_armor_change: worn_stack.enchantment_level("minecraft:binding_curse") > 0,
|
||||
};
|
||||
let preferred = equip::preferred_weapon_tag(species).map(|tag| {
|
||||
(
|
||||
|
||||
@@ -103,12 +103,14 @@ pub(super) fn content_root(
|
||||
}
|
||||
register_mob_effects(&mods);
|
||||
register_entity_types(&mods);
|
||||
register_menu_types(&mods);
|
||||
out
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: content mods not loaded: {e}");
|
||||
register_mob_effects(&[]);
|
||||
register_entity_types(&[]);
|
||||
register_menu_types(&[]);
|
||||
root.to_owned()
|
||||
}
|
||||
}
|
||||
@@ -161,6 +163,25 @@ fn register_entity_types(mods: &[horizon_client::mod_content::ContentMod]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// the mods' menu types, as their entity types: set once, from the startup
|
||||
/// root.
|
||||
fn register_menu_types(mods: &[horizon_client::mod_content::ContentMod]) {
|
||||
use horizon_client::containers;
|
||||
let menus = horizon_client::mod_content::menu_types(mods);
|
||||
let set = containers::register_mod_menu_types(menus.iter().map(|m| {
|
||||
let results = m.results.iter().map(|&r| usize::from(r)).collect();
|
||||
(m.id.clone(), m.slots.into(), m.data_slots.into(), results)
|
||||
}));
|
||||
if !set
|
||||
&& containers::mod_menu_types()
|
||||
.iter()
|
||||
.map(|m| m.id)
|
||||
.ne(menus.iter().map(|m| m.id.as_str()))
|
||||
{
|
||||
log::warn!("Horizon: the mods' menu types changed; restart the game to use them");
|
||||
}
|
||||
}
|
||||
|
||||
/// What the launcher writes beside a game it built with native mods: their ids under `mods`.
|
||||
const BUILD_INFO: &str = "native-mods.json";
|
||||
|
||||
|
||||
@@ -319,6 +319,16 @@ impl Game<'_> {
|
||||
v.extend((0..open.columns * 3).map(|i| CHEST + i));
|
||||
Some(v)
|
||||
}
|
||||
/// How many slots `mount_slots` lists, counted without building it
|
||||
/// (`Game::slot_count` asks every frame).
|
||||
pub(super) fn mount_slot_count(&self) -> Option<usize> {
|
||||
let open = self
|
||||
.mount_menu
|
||||
.open
|
||||
.as_ref()
|
||||
.filter(|_| self.mount_active())?;
|
||||
Some(36 + usize::from(open.show_saddle) + usize::from(open.show_armor) + open.columns * 3)
|
||||
}
|
||||
pub(super) fn mount_slot_position(&self, i: usize) -> Option<(f32, f32)> {
|
||||
let open = self
|
||||
.mount_menu
|
||||
|
||||
@@ -17,6 +17,15 @@ use horizon_core::{
|
||||
random::RandomSource,
|
||||
};
|
||||
|
||||
fn steering_item(species: Species, main: Option<&str>, offhand: Option<&str>) -> bool {
|
||||
let item = match species {
|
||||
Species::Pig => "minecraft:carrot_on_a_stick",
|
||||
Species::Strider => "minecraft:warped_fungus_on_a_stick",
|
||||
_ => return false,
|
||||
};
|
||||
main == Some(item) || offhand == Some(item)
|
||||
}
|
||||
|
||||
/// `passengerAttachments` minus the player's 0.6 vehicle attachment.
|
||||
fn seat(s: Species) -> f64 {
|
||||
let attach = match s {
|
||||
@@ -367,12 +376,11 @@ impl Game<'_> {
|
||||
let strafe = f32::from(u8::from(keys.left)) - f32::from(u8::from(keys.right));
|
||||
let forward = f32::from(u8::from(keys.forward)) - f32::from(u8::from(keys.backward));
|
||||
// LocalPlayer.aiStep: the riding jump charges while space is held.
|
||||
let held = self.held().map(|s| s.item.clone());
|
||||
let steer = match self.mobs[i].species() {
|
||||
Species::Pig => held.as_deref() == Some("minecraft:carrot_on_a_stick"),
|
||||
Species::Strider => held.as_deref() == Some("minecraft:warped_fungus_on_a_stick"),
|
||||
_ => false,
|
||||
};
|
||||
let steer = steering_item(
|
||||
self.mobs[i].species(),
|
||||
self.held().map(|s| s.item.as_ref()),
|
||||
self.offhand.as_ref().map(|s| s.item.as_ref()),
|
||||
);
|
||||
let jump = self.charge_mount_jump(i, keys);
|
||||
let m = &mut self.mobs[i];
|
||||
if jump > 0.0 {
|
||||
@@ -839,6 +847,17 @@ mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn steering_accepts_the_correct_item_in_either_hand() {
|
||||
let carrot = "minecraft:carrot_on_a_stick";
|
||||
let fungus = "minecraft:warped_fungus_on_a_stick";
|
||||
assert!(steering_item(Species::Pig, Some(carrot), None));
|
||||
assert!(steering_item(Species::Pig, None, Some(carrot)));
|
||||
assert!(steering_item(Species::Strider, None, Some(fungus)));
|
||||
assert!(!steering_item(Species::Pig, Some(fungus), None));
|
||||
assert!(!steering_item(Species::Horse, Some(carrot), Some(fungus)));
|
||||
}
|
||||
|
||||
/// A lava sea up to y 30 with full blocks placed in it; air above.
|
||||
struct Sea {
|
||||
blocks: HashMap<[i32; 3], ()>,
|
||||
|
||||
Loaded 100 of 335 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user