mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 10:39:46 -10:00
Compare commits
732
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cec9a29e0f | ||
|
|
eb5aa98ed5 | ||
|
|
6dfc835661 | ||
|
|
87d8c68734 | ||
|
|
9364785f11 | ||
|
|
e42b640471 | ||
|
|
ba9978c494 | ||
|
|
e547614923 | ||
|
|
6886bf948f | ||
|
|
11db39e485 | ||
|
|
476dae8af8 | ||
|
|
3985dca926 | ||
|
|
35ac0f37a4 | ||
|
|
411363e5d8 | ||
|
|
6e5074c211 | ||
|
|
7e56cd4be9 | ||
|
|
f91d165127 | ||
|
|
192b8f05ab | ||
|
|
a82da8b7ec | ||
|
|
ac92613b68 | ||
|
|
47699ce2da | ||
|
|
1bc34772f2 | ||
|
|
10f62131f8 | ||
|
|
9bd48bcad1 | ||
|
|
e7b46eabba | ||
|
|
18c35350b7 | ||
|
|
e494327b75 | ||
|
|
baeade2e91 | ||
|
|
8a1a76f729 | ||
|
|
e797c725ad | ||
|
|
4365b5ffc9 | ||
|
|
799082f609 | ||
|
|
2d8f409431 | ||
|
|
14df544b9e | ||
|
|
5bd7bc9216 | ||
|
|
ea4e32a7bb | ||
|
|
f79ede65cb | ||
|
|
070d66adbd | ||
|
|
88cd86f30f | ||
|
|
d3703bb7ce | ||
|
|
83c486232a | ||
|
|
8182353b24 | ||
|
|
31184e1498 | ||
|
|
0d07d06ac4 | ||
|
|
099f5e3ce0 | ||
|
|
7b02eaae15 | ||
|
|
a3bfed0fa6 | ||
|
|
626cc3bc20 | ||
|
|
109a2a7710 | ||
|
|
34f62e0ec7 | ||
|
|
34f5b4ff90 | ||
|
|
163b675f40 | ||
|
|
4380ac25e5 | ||
|
|
682810bf36 | ||
|
|
47ccf7c08e | ||
|
|
f3cbbb4832 | ||
|
|
0a9925287b | ||
|
|
86b27e11f4 | ||
|
|
ecc8d9935c | ||
|
|
b93b8cd1e1 | ||
|
|
8dbdcacc0a | ||
|
|
7d60f894a1 | ||
|
|
757ab0af18 | ||
|
|
2e62147c92 | ||
|
|
2c48d43d1d | ||
|
|
176538045d | ||
|
|
fab6a8ab7e | ||
|
|
dc9315809b | ||
|
|
e9405482da | ||
|
|
8cfbc8a571 | ||
|
|
6ccfd62eed | ||
|
|
1f9b81bf34 | ||
|
|
7263ce913f | ||
|
|
07ea597760 | ||
|
|
3853adf547 | ||
|
|
e950c219ae | ||
|
|
1a9ed0ee8f | ||
|
|
c40c5304ca | ||
|
|
7fd1b9137d | ||
|
|
bf6edbc29a | ||
|
|
300f5bbd7e | ||
|
|
b4dc165371 | ||
|
|
648e2b6422 | ||
|
|
0805a5619e | ||
|
|
ebc27522f5 | ||
|
|
8800efade6 | ||
|
|
c54d8ac368 | ||
|
|
1255c8489e | ||
|
|
ee05a0c04c | ||
|
|
a4b1d4764e | ||
|
|
83cdae17f2 | ||
|
|
4260417cd4 | ||
|
|
d8f17d5d26 | ||
|
|
1ff15314cf | ||
|
|
5ed28b1f85 | ||
|
|
9c7643f73e | ||
|
|
0a3bf41eb2 | ||
|
|
aff26431bc | ||
|
|
97a4f951ef | ||
|
|
65f8fbb1b6 | ||
|
|
19b12543b4 | ||
|
|
6c74658ead | ||
|
|
f436caa55b | ||
|
|
fadc5392ad | ||
|
|
bb65ae8c95 | ||
|
|
db87c15e7c | ||
|
|
83e4f7028b | ||
|
|
5f43eede5c | ||
|
|
fae637f7a7 | ||
|
|
3f9e9ef765 | ||
|
|
0f85344163 | ||
|
|
cc9da4ef8f | ||
|
|
76b8915538 | ||
|
|
53987660ff | ||
|
|
0420757c65 | ||
|
|
4c5f020ba9 | ||
|
|
b310f706dd | ||
|
|
efded83921 | ||
|
|
fd7521ab49 | ||
|
|
b8acb6f67b | ||
|
|
0eb6959229 | ||
|
|
b2a9078600 | ||
|
|
8f3f49dafe | ||
|
|
f6edfc1556 | ||
|
|
2e2d2994fc | ||
|
|
9d30ef3ba7 | ||
|
|
694cb27369 | ||
|
|
f30bdbaaa9 | ||
|
|
b5bbc989ae | ||
|
|
3f489a5e73 | ||
|
|
ca6a29bf50 | ||
|
|
b6e5125c26 | ||
|
|
85f2a3d6c1 | ||
|
|
c3d493b309 | ||
|
|
0c080475bc | ||
|
|
89a904c8a5 | ||
|
|
93848bf19b | ||
|
|
0ffdf24711 | ||
|
|
0a41b1b12c | ||
|
|
3888a0fcc3 | ||
|
|
a740d4407f | ||
|
|
f7ce74d8ab | ||
|
|
5e79ce9da6 | ||
|
|
613205b79c | ||
|
|
203b3ac802 | ||
|
|
7cf2213433 | ||
|
|
68f661b686 | ||
|
|
1cf95d4de1 | ||
|
|
a9214faaa2 | ||
|
|
24df2e964b | ||
|
|
b6902327b4 | ||
|
|
94740f1b3b | ||
|
|
8f1fe17a2f | ||
|
|
182a3c2fe7 | ||
|
|
7c77479175 | ||
|
|
31cd7d4e08 | ||
|
|
7256e407de | ||
|
|
ff58018a06 | ||
|
|
a2e0595255 | ||
|
|
f52d16d46a | ||
|
|
0d9ebac9ce | ||
|
|
07fa9693f1 | ||
|
|
1c6d163156 | ||
|
|
a41ac74237 | ||
|
|
c2535ba21f | ||
|
|
8caa631434 | ||
|
|
dde65ec694 | ||
|
|
a3a423c26f | ||
|
|
ba998968f6 | ||
|
|
e07e18f925 | ||
|
|
6f3f47f4b9 | ||
|
|
851ef6ebbf | ||
|
|
504f35eeb0 | ||
|
|
01e27e93f0 | ||
|
|
625844b654 | ||
|
|
d51ac0e772 | ||
|
|
5852c51e76 | ||
|
|
fab1bf4f99 | ||
|
|
e4f993d5b3 | ||
|
|
9cd7b899eb | ||
|
|
1db479a6eb | ||
|
|
1197c246ce | ||
|
|
24321e2da7 | ||
|
|
aad77e73e1 | ||
|
|
133a9e6cb8 | ||
|
|
9c364b492d | ||
|
|
db63f2f6fe | ||
|
|
59e4e5bc0c | ||
|
|
127d79c02c | ||
|
|
0346fe5f36 | ||
|
|
4af07e93ac | ||
|
|
7aed5be227 | ||
|
|
529d713ba0 | ||
|
|
bcb1be65a9 | ||
|
|
dc8b7dd1c0 | ||
|
|
603ff7030d | ||
|
|
3588d63f6b | ||
|
|
f22ba38e1d | ||
|
|
257e4f3f88 | ||
|
|
673bf94146 | ||
|
|
0cc69c80a3 | ||
|
|
7dbbd8bc9f | ||
|
|
8a9f5a1288 | ||
|
|
c57792a7c3 | ||
|
|
40fe249d09 | ||
|
|
246b32c604 | ||
|
|
0c65c8d47b | ||
|
|
fc442a81aa | ||
|
|
df31a2fe7d | ||
|
|
2756d39448 | ||
|
|
33f2d64da8 | ||
|
|
1d6a65b2b0 | ||
|
|
23e9193dc3 | ||
|
|
0b65f696e2 | ||
|
|
51a0732053 | ||
|
|
b33b610d56 | ||
|
|
00ee749a58 | ||
|
|
08eb26ad9e | ||
|
|
1cd834a3d8 | ||
|
|
2bb1ef83e4 | ||
|
|
5abd285cc1 | ||
|
|
62707dae44 | ||
|
|
f4aeb6aa14 | ||
|
|
040629ea7e | ||
|
|
6ba328d7ca | ||
|
|
812188bcc4 | ||
|
|
f66c07b0c3 | ||
|
|
f4cb8d2fba | ||
|
|
c2b8c12bb7 | ||
|
|
c76286a74a | ||
|
|
8c39d11219 | ||
|
|
f7063d3621 | ||
|
|
d7bbe2f5e3 | ||
|
|
6feba56d0e | ||
|
|
96f310676c | ||
|
|
01ecb800e2 | ||
|
|
78151f0e02 | ||
|
|
f858ee752f | ||
|
|
e47abbc7fe | ||
|
|
90796bc21b | ||
|
|
773e7850b5 | ||
|
|
1df9099c8f | ||
|
|
338a8c36f8 | ||
|
|
266bdda26e | ||
|
|
f5515b409c | ||
|
|
ee0a6c2093 | ||
|
|
976346aff5 | ||
|
|
141842b509 | ||
|
|
e5f333e885 | ||
|
|
63e8ff40fe | ||
|
|
e3242b385b | ||
|
|
f3418e1525 | ||
|
|
c4c0b54fad | ||
|
|
a33d57b36e | ||
|
|
b4e940a4ec | ||
|
|
02cc303caa | ||
|
|
9e053b5fab | ||
|
|
6865327231 | ||
|
|
39f04d6797 | ||
|
|
ca46e45c7d | ||
|
|
20cf432402 | ||
|
|
3a00fb7f85 | ||
|
|
d44bca934e | ||
|
|
ce5ba9d529 | ||
|
|
0497e8bc74 | ||
|
|
a5d17ed836 | ||
|
|
857c741402 | ||
|
|
d3017b792e | ||
|
|
465a6752f4 | ||
|
|
0e8f5dbb8a | ||
|
|
70f7da15c8 | ||
|
|
fcb88320e1 | ||
|
|
3808ecf716 | ||
|
|
0fcdcc425a | ||
|
|
1700772be1 | ||
|
|
167e772c20 | ||
|
|
298ca95813 | ||
|
|
9803a016c4 | ||
|
|
6ea8967d2f | ||
|
|
121c6b27d5 | ||
|
|
7d0f8b956b | ||
|
|
d9482ad26d | ||
|
|
6de492a6fc | ||
|
|
67e5c2d205 | ||
|
|
669e6b33d2 | ||
|
|
e9846abaf3 | ||
|
|
e263c6a6ef | ||
|
|
3b695e3b18 | ||
|
|
78482b23de | ||
|
|
672e276940 | ||
|
|
896d960bd7 | ||
|
|
fc912ff3a1 | ||
|
|
2a1437a1c0 | ||
|
|
1c385ab9b0 | ||
|
|
06dc58c8a4 | ||
|
|
85878fa594 | ||
|
|
f35632c2a9 | ||
|
|
de4ed2745c | ||
|
|
bab2e82183 | ||
|
|
ef2ce44b59 | ||
|
|
f4938d63aa | ||
|
|
0b5e417e4c | ||
|
|
7c2db89229 | ||
|
|
fe04f52326 | ||
|
|
7a56b3516d | ||
|
|
bd3c2d7a14 | ||
|
|
230ff171f0 | ||
|
|
9a12a64459 | ||
|
|
fa727686e3 | ||
|
|
1e981c99c6 | ||
|
|
790d1e3c27 | ||
|
|
e3cf8bb456 | ||
|
|
dd3c9c6562 | ||
|
|
08819d4636 | ||
|
|
e5e6e26d91 | ||
|
|
efe6d10ff1 | ||
|
|
243ab4e220 | ||
|
|
3799edbc34 | ||
|
|
bab8bf1166 | ||
|
|
f0d91bb791 | ||
|
|
05e56173c2 | ||
|
|
25c5fd1db7 | ||
|
|
4243b253d2 | ||
|
|
577ea049d3 | ||
|
|
c1b0538540 | ||
|
|
385393ec3c | ||
|
|
85ce314e76 | ||
|
|
bbe70b1ca2 | ||
|
|
ba81d33cb7 | ||
|
|
fb123d2479 | ||
|
|
fa5554339e | ||
|
|
0ebfe7d116 | ||
|
|
bff36a3b38 | ||
|
|
265b1a3cb7 | ||
|
|
a2b3f4fe86 | ||
|
|
03093772e4 | ||
|
|
62495116b5 | ||
|
|
549c7a2bc8 | ||
|
|
9b17b20107 | ||
|
|
e38e4f6bcc | ||
|
|
5a562fb5a2 | ||
|
|
b4948662ae | ||
|
|
f1e42dcaf9 | ||
|
|
d9bd370a39 | ||
|
|
e1899cca8f | ||
|
|
834ecc9625 | ||
|
|
f923aa5118 | ||
|
|
9b185113e5 | ||
|
|
47d2e49fc7 | ||
|
|
92618fa3a9 | ||
|
|
f51e2b0ec1 | ||
|
|
35f1f912a3 | ||
|
|
b5254c9f6d | ||
|
|
4132d833ff | ||
|
|
d88c9de224 | ||
|
|
f687a35393 | ||
|
|
25a495c107 | ||
|
|
817641fa36 | ||
|
|
64d2225b47 | ||
|
|
0cd71497b9 | ||
|
|
9358df9085 | ||
|
|
bb827b3bfe | ||
|
|
0f2374a9f1 | ||
|
|
f6e0f987ca | ||
|
|
f60f2a7cd6 | ||
|
|
58ba6ee7bf | ||
|
|
fae353e82a | ||
|
|
982afd44b7 | ||
|
|
27d330d03b | ||
|
|
a520869bfa | ||
|
|
1570016310 | ||
|
|
edf6782e02 | ||
|
|
03a5d8f278 | ||
|
|
55d85acf5c | ||
|
|
d5504cd328 | ||
|
|
f528cc4298 | ||
|
|
309ac52058 | ||
|
|
5e7b000a0e | ||
|
|
7ee63f2db5 | ||
|
|
e5534306c7 | ||
|
|
30e4b26b37 | ||
|
|
29be573768 | ||
|
|
cb1e8d891e | ||
|
|
0741d53c75 | ||
|
|
ce2ec03a01 | ||
|
|
8de6ff0bca | ||
|
|
1501a0fd60 | ||
|
|
ba342ec0f6 | ||
|
|
546db0269c | ||
|
|
51c2e96495 | ||
|
|
a9fb6ffc9a | ||
|
|
159fb6af86 | ||
|
|
d1a4478daa | ||
|
|
efda24ee40 | ||
|
|
3dd49e2848 | ||
|
|
7b59041a89 | ||
|
|
5fe5747f44 | ||
|
|
841a5a4acd | ||
|
|
e5397553a2 | ||
|
|
196348ee3e | ||
|
|
a44f73e9ae | ||
|
|
3fa342468c | ||
|
|
b7b1d73a66 | ||
|
|
3fe80aae12 | ||
|
|
af996ff963 | ||
|
|
633cac87b5 | ||
|
|
592c4f55ef | ||
|
|
4c100ad4f1 | ||
|
|
99893d81c0 | ||
|
|
34b9d2cd64 | ||
|
|
0b2279aaa3 | ||
|
|
72c9d15042 | ||
|
|
32f28b9d08 | ||
|
|
89f236e3ef | ||
|
|
e2a28f96b8 | ||
|
|
eee4242951 | ||
|
|
b1d469b419 | ||
|
|
44aa36f166 | ||
|
|
3f0328f42f | ||
|
|
d0e38cc6c7 | ||
|
|
fcc01b662b | ||
|
|
fae81ab428 | ||
|
|
0030a4add8 | ||
|
|
c2be77292a | ||
|
|
c5a4b2cdd5 | ||
|
|
caac31c8df | ||
|
|
dd208c4f97 | ||
|
|
7092049967 | ||
|
|
4006b3da52 | ||
|
|
b44fa6e966 | ||
|
|
0331afdf3d | ||
|
|
0952f173b5 | ||
|
|
bff215ab70 | ||
|
|
13b8277019 | ||
|
|
3587cef272 | ||
|
|
92d612f109 | ||
|
|
04d8249f08 | ||
|
|
30628bf79c | ||
|
|
19e86d6782 | ||
|
|
0f82100ddd | ||
|
|
a0794bfd2e | ||
|
|
aac0f59fd7 | ||
|
|
962ce1dfbc | ||
|
|
5c684b8cf1 | ||
|
|
c504f1347a | ||
|
|
0ab94f3e13 | ||
|
|
8e02a3cb80 | ||
|
|
a5cb5bdadd | ||
|
|
3c7500e007 | ||
|
|
31c060e41b | ||
|
|
b3e597d983 | ||
|
|
84b445e017 | ||
|
|
a31ee46ddf | ||
|
|
c23d7cdf4b | ||
|
|
264ea0cc7c | ||
|
|
bee1d42fd2 | ||
|
|
b3aded6e7d | ||
|
|
8bb7788b8e | ||
|
|
1c888ac5b2 | ||
|
|
bcb76434bc | ||
|
|
29cbcd69d1 | ||
|
|
6e42a09d63 | ||
|
|
abbe20ec90 | ||
|
|
87c51909d4 | ||
|
|
20111ddf7b | ||
|
|
252a012722 | ||
|
|
7f9e634e22 | ||
|
|
f4b8e96eca | ||
|
|
77614f2697 | ||
|
|
0cb33a7139 | ||
|
|
cd069f2065 | ||
|
|
271b3626a2 | ||
|
|
aac977d7dd | ||
|
|
3b34e7ba33 | ||
|
|
7c5a4a621d | ||
|
|
f2c1081089 | ||
|
|
ab56dc20bd | ||
|
|
76d91d6b27 | ||
|
|
fb0a077202 | ||
|
|
b90fee34be | ||
|
|
42a0d12f62 | ||
|
|
40a9813dcd | ||
|
|
fa7924b3fb | ||
|
|
e327c79d1f | ||
|
|
b18000dccf | ||
|
|
9c83ccf52f | ||
|
|
321a391209 | ||
|
|
2f400ca79e | ||
|
|
42d1e6d7d7 | ||
|
|
ae40b07eeb | ||
|
|
16fdd05784 | ||
|
|
b6036c08c1 | ||
|
|
6b8be62697 | ||
|
|
1f11377f60 | ||
|
|
7457de69bb | ||
|
|
8db87db310 | ||
|
|
385bd4d0b1 | ||
|
|
0989100291 | ||
|
|
a174de80d3 | ||
|
|
42a73e846d | ||
|
|
24e5d3d707 | ||
|
|
598a294156 | ||
|
|
3bdce4d658 | ||
|
|
da0cab5bfa | ||
|
|
5c6329ba54 | ||
|
|
e8b162cd2f | ||
|
|
b3f312cf99 | ||
|
|
87ba93d8d1 | ||
|
|
5c6d7b6dab | ||
|
|
795ae8e8a0 | ||
|
|
7cd858f208 | ||
|
|
fd055a0020 | ||
|
|
c30e611ec3 | ||
|
|
a5f7ec2c4c | ||
|
|
9a4ac94b73 | ||
|
|
efaa1660e9 | ||
|
|
f39318eeb0 | ||
|
|
d398fbc8b0 | ||
|
|
39af844866 | ||
|
|
81337c3a2f | ||
|
|
3192adafe0 | ||
|
|
a6705ea839 | ||
|
|
d9b3005690 | ||
|
|
eef19d5cf9 | ||
|
|
1eb93f5884 | ||
|
|
4bc55da08d | ||
|
|
dcd90c2b1f | ||
|
|
41a72c6eda | ||
|
|
3ae606abce | ||
|
|
10eb936805 | ||
|
|
29ac896b4c | ||
|
|
f9eb983ba1 | ||
|
|
e13a206334 | ||
|
|
2ba45c262d | ||
|
|
0be41c06be | ||
|
|
3e4cfbe7ea | ||
|
|
110e686f4d | ||
|
|
646572e69b | ||
|
|
3176bb630a | ||
|
|
a8d74ca97b | ||
|
|
fe74121c5b | ||
|
|
d170e4f674 | ||
|
|
bde1cc63c5 | ||
|
|
0b5b68bdd2 | ||
|
|
39d6f25e58 | ||
|
|
4b9cc3eeae | ||
|
|
b00506bc0e | ||
|
|
4a3058d4ad | ||
|
|
be5c7e2fe2 | ||
|
|
68bb5be687 | ||
|
|
0a63e373ad | ||
|
|
291a28fc99 | ||
|
|
70d3441c4a | ||
|
|
54b6232191 | ||
|
|
aea460736c | ||
|
|
af256dcc40 | ||
|
|
e54d91dc0c | ||
|
|
fffa3bf6b8 | ||
|
|
f70686be76 | ||
|
|
fe70a48402 | ||
|
|
67ebf35e57 | ||
|
|
1406684c20 | ||
|
|
4b4dca948c | ||
|
|
b2a457fdef | ||
|
|
d9eda46d06 | ||
|
|
1f149381f8 | ||
|
|
02bbcbd466 | ||
|
|
e0d14e4edf | ||
|
|
b4b6eefcfb | ||
|
|
6f31c8d6d1 | ||
|
|
545cb15f18 | ||
|
|
e2c9f5dd08 | ||
|
|
389a495f09 | ||
|
|
7f2fdba6ba | ||
|
|
7f63f42e27 | ||
|
|
91879dc898 | ||
|
|
4dd5d0ce71 | ||
|
|
b0bb5d2a9e | ||
|
|
860355f55e | ||
|
|
3233aaabe9 | ||
|
|
8d8ef94673 | ||
|
|
f9374b154b | ||
|
|
009baf97ad | ||
|
|
e7cd3c4c6c | ||
|
|
c945576faf | ||
|
|
3fd4fed964 | ||
|
|
068b0a5a74 | ||
|
|
539933e0a1 | ||
|
|
bfd5c69005 | ||
|
|
54cae6c3ef | ||
|
|
d5c7b201ce | ||
|
|
cf23b0da77 | ||
|
|
0ce693d743 | ||
|
|
e5bbad5f2f | ||
|
|
c052c5523a | ||
|
|
5d5e00f783 | ||
|
|
f7e6433729 | ||
|
|
2d4a338796 | ||
|
|
48ec2657df | ||
|
|
a05497bbcd | ||
|
|
40d0364acc | ||
|
|
ce99d8445d | ||
|
|
b5723fd33d | ||
|
|
09ccb39c4f | ||
|
|
5273939a30 | ||
|
|
68f4708d84 | ||
|
|
aa3819d4a5 | ||
|
|
3316c45f44 | ||
|
|
36896744bf | ||
|
|
dc22fb4396 | ||
|
|
f7a1f50728 | ||
|
|
f4aa6beb4a | ||
|
|
8b65ee2372 | ||
|
|
f523a60066 | ||
|
|
27d7fdd859 | ||
|
|
34b0265744 | ||
|
|
abcbf3739d | ||
|
|
45bcdca495 | ||
|
|
4e724e319b | ||
|
|
6b2fadfcd5 | ||
|
|
96f1fb36a2 | ||
|
|
2e258065d7 | ||
|
|
0256b28b72 | ||
|
|
8200d52bdd | ||
|
|
ce61a0e1de | ||
|
|
9791592034 | ||
|
|
afd3e48ccd | ||
|
|
56dbf356a0 | ||
|
|
478e7b45bd | ||
|
|
db32617271 | ||
|
|
ad7effc349 | ||
|
|
0299f2fe2f | ||
|
|
a145d469c6 | ||
|
|
0ec0dbb2f4 | ||
|
|
c5e3ef940b | ||
|
|
b83ab5e01d | ||
|
|
e8a1e5b997 | ||
|
|
acd32b8d31 | ||
|
|
8695ecb3b4 | ||
|
|
08ed0dcb83 | ||
|
|
6aa575f7d1 | ||
|
|
65d988b030 | ||
|
|
a4bf12b2da | ||
|
|
42635ff154 | ||
|
|
79a2d9d554 | ||
|
|
0d45709554 | ||
|
|
936a48fe4b | ||
|
|
afc1d6a0e0 | ||
|
|
226c1ceeef | ||
|
|
21a5d735ea | ||
|
|
0e525f3952 | ||
|
|
e5f945dc55 | ||
|
|
00299919d9 | ||
|
|
5661b09d71 | ||
|
|
40c12c20e0 | ||
|
|
a7c004b6ac | ||
|
|
4d6f0a1c4a | ||
|
|
bd13d0f39c | ||
|
|
468ab204c7 | ||
|
|
aa208521eb | ||
|
|
92c1eb1b76 | ||
|
|
408d193d01 | ||
|
|
9f903d2b3f | ||
|
|
e0615ba966 | ||
|
|
ad183565d8 | ||
|
|
70c8fe35a2 | ||
|
|
4bcfbe6eb3 | ||
|
|
28722235de | ||
|
|
3f71bd7deb | ||
|
|
3b3ed4b217 | ||
|
|
a0747ddd25 | ||
|
|
a3770664c2 | ||
|
|
f03f847d4f | ||
|
|
408e4fda1c | ||
|
|
7b92e8a8aa | ||
|
|
bcd3d93200 | ||
|
|
2ece11744d | ||
|
|
12b51783dd | ||
|
|
d9a8fb0667 | ||
|
|
cc1825d017 | ||
|
|
cadfdbcfb1 | ||
|
|
bb6e95d5fe | ||
|
|
63db5cdd81 | ||
|
|
81caf4c301 | ||
|
|
efbd55185e | ||
|
|
b68899faad | ||
|
|
0618cea36e | ||
|
|
8beaf4b572 | ||
|
|
14a479c392 | ||
|
|
ded1909d91 | ||
|
|
92a19421d2 | ||
|
|
e17f52b8c2 | ||
|
|
dc881c302a | ||
|
|
0e17b03481 | ||
|
|
7f4a2fe8a0 | ||
|
|
0ed839b8a1 | ||
|
|
dd3bac8217 | ||
|
|
e39af976c9 | ||
|
|
eca3a71c8c | ||
|
|
ef2142e5a6 | ||
|
|
bf7ec63e8e | ||
|
|
b8e5247b35 | ||
|
|
cd18425b4a | ||
|
|
5f94268324 | ||
|
|
eb83113dbb | ||
|
|
1522c80b8f | ||
|
|
9b0a0d56a9 | ||
|
|
69193a3635 | ||
|
|
fb350ecfc4 | ||
|
|
3b7a7a9ef3 | ||
|
|
b89066f4ac | ||
|
|
f9e8af6a94 | ||
|
|
42cad38648 | ||
|
|
86b883ab33 | ||
|
|
8a0ec928fa | ||
|
|
dce5e62714 | ||
|
|
f102160191 | ||
|
|
125b46829c | ||
|
|
5845d88a53 | ||
|
|
02d9ee7962 | ||
|
|
8a197bf691 | ||
|
|
34897bdcd3 | ||
|
|
dc5420e82e | ||
|
|
9850f641b5 | ||
|
|
652be13517 | ||
|
|
e1294dbaf6 | ||
|
|
381c191dc1 | ||
|
|
05a9ed3057 | ||
|
|
7a065ec02b | ||
|
|
df7f146ed9 | ||
|
|
1d29778ef7 | ||
|
|
01b4e8b995 |
No files matched your search
@@ -2,7 +2,8 @@
|
||||
|
||||
## What and why
|
||||
|
||||
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). -->
|
||||
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). Over about 500 lines: link the issue with its
|
||||
approved spec too ("Spec: #123"), CONTRIBUTING.md "Large pull requests". -->
|
||||
|
||||
## Kind
|
||||
|
||||
@@ -16,6 +17,16 @@
|
||||
- [ ] tooling: tools, CI, tests, harnesses
|
||||
- [ ] docs
|
||||
|
||||
## Layer
|
||||
|
||||
<!-- engine, platform or Horizon (docs/layers.md, "Where a new feature goes"). Reviewers check:
|
||||
- the new code sits in a crate or module of that layer;
|
||||
- no new dependency its layer may not use (CI's layer check): no engine code calling platform or Horizon code,
|
||||
and Horizon code using the platform's public API only, never the engine directly;
|
||||
- Horizon features in the game are behind `horizon-extras`, and platform features that change the vanilla
|
||||
look are off by default;
|
||||
- no new built-in server address outside the Horizon layer. -->
|
||||
|
||||
## Vanilla reference
|
||||
|
||||
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
|
||||
|
||||
@@ -13,5 +13,4 @@
|
||||
87152661 rdbtCVS # added by Ludovic, 2026-10-27
|
||||
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
|
||||
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
|
||||
87478350 Qwerner88880 # added by Ludovic, 2026-10-31 (Discord vladislav_hagen; native-mod hooks)
|
||||
169198624 itarqos5 # added by Ludovic, 2026-11-08 (Discord Literal; Horizon Team)
|
||||
@@ -78,6 +78,21 @@ jobs:
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
rustup default stable
|
||||
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
|
||||
# docs/layers.md: a crate depends only on its own layer or a lower one; today's exceptions only shrink.
|
||||
- name: Crates follow the layers
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: python3 tools/check_layers.py ${BASE_SHA:+--base "$BASE_SHA"}
|
||||
# docs/layers.md#built-in-addresses: every built-in outside address in the Horizon layer's home; the rest only shrinks.
|
||||
- name: Built-in addresses stay in their home
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: python3 tools/check_addresses.py ${BASE_SHA:+--base "$BASE_SHA"}
|
||||
# docs/layers.md#file-size: a Rust file over 3000 lines may not grow (pull requests; main has no base to compare).
|
||||
- name: Big files don't grow
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: python3 tools/check_file_size.py ${BASE_SHA:+--base "$BASE_SHA"}
|
||||
- name: Test offline research tooling
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Install Rust toolchain and the ALSA and udev headers the client links against
|
||||
|
||||
@@ -25,6 +25,8 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
|
||||
`Co-Authored-By: Claude <model> <[email protected]>`, last line
|
||||
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
||||
- **Layers** ([docs/layers.md](docs/layers.md)): engine < platform < Horizon; a crate depends only on its own layer or a
|
||||
lower one (`tools/check_layers.py`, crate layers in `tools/layers.json`). New Horizon code goes behind `horizon-extras`.
|
||||
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
|
||||
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
|
||||
they would change the vanilla look.
|
||||
@@ -61,6 +63,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
|
||||
| `horizon-anticheat` | The dedicated server's anticheat, off by default: `[anticheat]` settings, violation levels, the checks' rules; the hub runs them (`lan/anticheat.rs`) ([docs/anticheat.md](docs/anticheat.md)) |
|
||||
| `horizon-cosmetics` | Cosmetic items Horizon clients draw on players (off by default): the item format, bones and animations ([docs/cosmetics.md](docs/cosmetics.md)) |
|
||||
| `horizon-addresses` | Every built-in address of an outside service vanilla doesn't contact (Horizon layer; [docs/layers.md](docs/layers.md#built-in-addresses)) |
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
@@ -70,6 +73,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
|
||||
| `horizon-system` | The machine the game runs on (OS, CPU, memory, drive, power, GPU maker and memory), for the crash report, the renderer's GPU info and telemetry |
|
||||
| `horizon-platform` | The platform's public API: what the Horizon layer may use of the engine ([docs/layers.md](docs/layers.md)); re-exports `horizon-system` today |
|
||||
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
|
||||
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
|
||||
|
||||
@@ -101,6 +106,7 @@ python3 tools/parity.py --changed-files paths.txt --list # which harnesses cov
|
||||
python3 tools/parity.py mob_ai sculk # named harnesses
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
|
||||
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
python3 tools/lang_coverage.py --summary # the keys each language still misses, and its English leftovers
|
||||
```
|
||||
|
||||
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
|
||||
@@ -152,17 +158,17 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `fn tick`, `fn tick_world`, `fn render`; G: `window_handler.rs` (`impl ApplicationHandler for Game`: `window_event`, `about_to_wait`); L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs`, `screen_backgrounds.rs` (panorama, menu blur, border wall) |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `world_entry.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `fn tick`, `fn tick_world`, `fn render`; G: `window_handler.rs` (`impl ApplicationHandler for Game`: `window_event`, `about_to_wait`), `launch_options.rs` (the command line); L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `frame_geometry.rs` (cracks, sun and moon, first-person hand, rain and snow), `item_draw.rs`, `screen_backgrounds.rs` (panorama, menu blur, border wall) |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `world_entry.rs`, `postprocess.rs`, `world_update.rs` (`fn update_world`, far terrain) |
|
||||
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); G: `block_ticks.rs` (`fn tick_blocks`); game.rs `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn tick_mining`, `fn break_block`); G: `item_actions.rs` (`fn use_item`), `melee.rs` (`fn attack`), `hand.rs`, `combat.rs`, `consume.rs`, `using_item.rs` (an item in use, tick by tick), `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs`, `inventory_input.rs` (keys and clicks over a container), `inventory_slots.rs` (slot layout and drawing) |
|
||||
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `item_entities.rs` (dropped items, experience orbs), `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
|
||||
| Player actions | game.rs (`fn tick_mining`, `fn break_block`); G: `use_hand.rs` (`fn use_hand`), `item_actions.rs` (`fn use_item`), `melee.rs` (`fn attack`), `hand.rs`, `combat.rs`, `consume.rs`, `using_item.rs` (an item in use, tick by tick), `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; G: `menu_clicks.rs` (`fn menu_ops`, `fn click_inventory`), `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs`, `inventory_input.rs` (keys and clicks over a container), `inventory_slots.rs` (slot layout and drawing) |
|
||||
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `settings_shell.rs` (Horizon's settings screens on it: `extras_ui.rs`, `graphics_ui.rs`, `controller_ui.rs`), `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `hud_kit/` (Horizon overlays' `Room` and `Plate`, [docs/ui-kit.md](docs/ui-kit.md)), `pad_glyph.rs` (controller glyphs, by what the button does), `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `settings_shell.rs` (Horizon's settings screens on it: `extras_ui.rs`, `graphics_ui.rs`, `controller_ui.rs`, `shader_ui.rs`; a page list too long for its column collapses into one row), `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `hud_kit/` (Horizon overlays' `Room` and `Plate`, the collapsed page strip `tabs` the shell's page list uses, [docs/ui-kit.md](docs/ui-kit.md)), `pad_glyph.rs` (controller glyphs, by what the button does), `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `resource_loader.rs` (what a start loads, `run_loading`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
|
||||
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
|
||||
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
|
||||
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
|
||||
|
||||
+30
-1
@@ -38,10 +38,16 @@ and compares their outputs.
|
||||
- **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode:
|
||||
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`.
|
||||
Say in your pull request which vanilla classes and methods you followed.
|
||||
- **One version: 26.3.** Parity work targets 26.3 until its parity is good, redstone first. Don't open pull requests
|
||||
that move Horizon to a newer Minecraft version for now; to join servers on a newer version, put a Via proxy
|
||||
(ViaVersion, ViaProxy) in front of them.
|
||||
- **Gameplay changes come with a harness**, new or extended, so the next change can't silently break them.
|
||||
[The parity workflow](docs/parity-workflow.md) shows how harnesses run; copy a nearby one.
|
||||
- **Horizon's own features** (the launcher, the new menus) follow [the design system](docs/design-system.md) and are
|
||||
off by default wherever they would change the vanilla look.
|
||||
- **Three layers: engine, platform, Horizon.** Vanilla code never depends on the platform (mods, shader packs,
|
||||
Horizon-only settings) or on Horizon (cosmetics, the shop, official servers, telemetry), and the platform never
|
||||
depends on Horizon. [docs/layers.md](docs/layers.md) says where a new feature goes; CI checks the dependencies.
|
||||
- **Everything players run is Rust.** Python and Java are fine for dev tools and harness drivers in `tools/`, never
|
||||
for player-facing code.
|
||||
- **No game files in the repo.** Never commit game files, decompiled source or prepared resources (`.local/`):
|
||||
@@ -80,6 +86,15 @@ license. Shader packs are a clean-room rewrite of the pack format, produced from
|
||||
source, and the rules above still apply to anything you read while working on them. A shader-pack pull request
|
||||
names its sources: the sections of that specification, the documentation pages and the packs it ran.
|
||||
|
||||
## Vendor libraries and Java mods
|
||||
|
||||
- **Upscalers and latency tools.** AMD FSR (MIT) may go in the repository, like any code under an allowed license.
|
||||
NVIDIA's DLSS, Reflex and any other library under an NVIDIA license may not: they ship only as an optional native mod
|
||||
that downloads NVIDIA's files itself, on the player's machine. No NVIDIA binaries, headers or SDK files in the
|
||||
repository.
|
||||
- **The JVM bridge** (running Java mods) is only ever a separate, optional download, never installed or on by
|
||||
default. The game may offer to turn it on when it finds a Java mod; the player decides.
|
||||
|
||||
## Before asking for review
|
||||
|
||||
Run the self-check and paste the block it prints into your pull request:
|
||||
@@ -116,7 +131,8 @@ it (edited on every push, never repeated): talk to its author in the thread befo
|
||||
## Pull requests
|
||||
|
||||
- **Small.** One change per pull request, under about 500 lines of code where you can; split bigger work into
|
||||
pieces that each make sense alone. Small pull requests get reviewed and merged the same day.
|
||||
pieces that each make sense alone. Small pull requests get reviewed and merged the same day. Bigger than that:
|
||||
[large pull requests](#large-pull-requests).
|
||||
- **Fill in the template**: what and why, the vanilla reference, the pr_ready block, screenshots for anything you
|
||||
can see.
|
||||
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
|
||||
@@ -139,6 +155,19 @@ generation, the launcher's updates) wait for a local proof first ([merging](docs
|
||||
Every merge to main is released automatically ([release flow](docs/release-flow.md)), so main always stays playable.
|
||||
How agents, checks, releases and signed updates keep this safe: [security](docs/security.md).
|
||||
|
||||
### Large pull requests
|
||||
|
||||
500 lines stays the default. A pull request that can't be split below it starts from a spec:
|
||||
|
||||
1. **Open an issue with the spec** before the code: what changes, the files it touches, the behaviour players and
|
||||
other code will see, and the tests or harnesses that prove it.
|
||||
2. **A maintainer approves the spec** in the issue. Code written before that may have to change or wait.
|
||||
3. **The pull request links the approved issue** (`Spec: #123` in the description) and stays within it; a change of
|
||||
plan goes back to the issue first.
|
||||
4. **An agent check compares the code with the spec**, run by the maintainers' agents: anything the spec doesn't
|
||||
cover, or that it promises and the code doesn't do, comes back to you.
|
||||
5. **Then the normal review**, as for any pull request.
|
||||
|
||||
## Parity checks on forks
|
||||
|
||||
The `verify` check (formatting, lints, tests) runs on every pull request. The `parity` check runs the affected
|
||||
|
||||
Generated
+20
-2
@@ -1374,6 +1374,10 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-addresses"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "horizon-anticheat"
|
||||
version = "0.1.0"
|
||||
@@ -1577,7 +1581,6 @@ dependencies = [
|
||||
"glam",
|
||||
"half",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-command",
|
||||
@@ -1592,7 +1595,7 @@ dependencies = [
|
||||
"horizon-protocol",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-telemetry",
|
||||
"horizon-system",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
"horizon-world",
|
||||
@@ -1641,6 +1644,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"glam",
|
||||
"horizon-addresses",
|
||||
"png",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1663,6 +1667,7 @@ dependencies = [
|
||||
"chacha20poly1305",
|
||||
"getrandom 0.3.4",
|
||||
"hkdf",
|
||||
"horizon-addresses",
|
||||
"if-addrs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1772,6 +1777,13 @@ dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-platform"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-system",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-polytone"
|
||||
version = "0.1.0"
|
||||
@@ -1909,11 +1921,17 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-system"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "horizon-telemetry"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"horizon-addresses",
|
||||
"horizon-platform",
|
||||
"ruzstd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga", "third_party/libmimalloc-sys"]
|
||||
members = [
|
||||
"crates/horizon-addresses",
|
||||
"crates/horizon-core",
|
||||
"crates/horizon-dualsense",
|
||||
"crates/horizon-narrator",
|
||||
@@ -27,6 +28,8 @@ members = [
|
||||
"crates/horizon-theme",
|
||||
"crates/horizon-prepare",
|
||||
"crates/horizon-telemetry",
|
||||
"crates/horizon-system",
|
||||
"crates/horizon-platform",
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
|
||||
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-0%20open-F0795A" alt="Player-visible gaps open"></a>
|
||||
</p>
|
||||
|
||||
@@ -138,13 +137,13 @@ It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public
|
||||
|
||||
## Project status
|
||||
|
||||
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
|
||||
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the table below says what the harnesses compare.
|
||||
|
||||
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs. What players still run into: [known issues](docs/known-issues.md).
|
||||
The table is the project's own audit of what the harnesses compare, subsystem by subsystem: a score says how much of that subsystem the harnesses cover and pass, not that nothing else differs from vanilla. What players still run into: [known issues](docs/known-issues.md).
|
||||
|
||||
### Java parity: 100%
|
||||
### What the harnesses compare
|
||||
|
||||
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: the harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
|
||||
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (its harnesses cover the subsystem and pass). Last audited 2026-10-13: the harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
|
||||
|
||||
| Subsystem | Score | Main gaps |
|
||||
| --- | ---: | --- |
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ Owner: [levzzz5154](https://github.com/levzzz5154) · Tracking: [#3911](https://
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. [docs/native-mods.md](docs/native-mods.md).
|
||||
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. [docs/native-mods.md](docs/native-mods.md).
|
||||
|
||||
More: [#3351](https://github.com/horizon-rust/horizon/issues/3351) · [#3353](https://github.com/horizon-rust/horizon/issues/3353) · [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
|
||||
|
||||
|
||||
@@ -338,8 +338,13 @@ is preserved.
|
||||
|
||||
## Starling
|
||||
|
||||
`crates/horizon-client-render/src/touch/layout.rs` is adapted from the touch layout of
|
||||
`crates/horizon-client-render/src/touch/layout.rs` and `crates/horizon-client-render/src/touch/aim.rs`
|
||||
are adapted from the touch layout and touch helpers of
|
||||
[Starling](https://github.com/starling-client/starling), an Android port of Horizon, at commit
|
||||
[150a414b09a1](https://github.com/starling-client/starling/tree/150a414b09a18c900489d9462a7fc035dae744b7)
|
||||
(`android` branch), written mostly by Vasysik. Starling is licensed under the same MIT license as
|
||||
Horizon (Copyright (c) 2026 Ludovic Marie and the Horizon contributors).
|
||||
|
||||
`crates/horizon-client-render/src/android_gamepad.rs` is adapted from Starling's controller decoder
|
||||
(`crates/horizon-client-render/src/android_gamepad.rs`) and the input-mode part of its
|
||||
`android_runtime.rs`, at the same commit.
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "horizon-addresses"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version.workspace = true
|
||||
license = "MIT"
|
||||
description = "Every built-in address of an outside service vanilla doesn't contact, in one place (docs/layers.md#built-in-addresses)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Every built-in address of an outside service vanilla doesn't contact, in one place
|
||||
//! (docs/layers.md#built-in-addresses). `tools/check_addresses.py` keeps new ones from going
|
||||
//! anywhere else.
|
||||
//!
|
||||
//! This is a Horizon-layer crate: the engine and the platform never depend on it. Platform code
|
||||
//! that talks to a service gets the address passed in from a Horizon crate.
|
||||
|
||||
/// Horizon's official survival server (SMP), offered once at the top of the server list by the
|
||||
/// game and the launcher (`launcher/links.json` may name another). Its SRV record leads to the
|
||||
/// port.
|
||||
pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
|
||||
|
||||
/// The official server's earlier addresses: the game and the launcher move a server list's
|
||||
/// entries for them to [`OFFICIAL_SERVER`], once. A move appends the old address here (and to
|
||||
/// `official_server_previous` in `launcher/links.json`).
|
||||
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
|
||||
|
||||
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
|
||||
/// (the telemetry server's status checks).
|
||||
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
|
||||
|
||||
/// The cosmetics service: cosmetics, emotes, "Show me as a Horizon player"
|
||||
/// (`HORIZON_COSMETICS_API` overrides it).
|
||||
pub const COSMETICS_API: &str = "https://cosmetics.playhorizon.dev";
|
||||
|
||||
/// The telemetry ingest server (`crates/horizon-telemetry-server`): opt-in telemetry, the
|
||||
/// "I'm playing" ping, benchmark uploads (`HORIZON_TELEMETRY_URL` overrides it).
|
||||
pub const TELEMETRY: &str = "https://telemetry.playhorizon.dev";
|
||||
|
||||
/// Public STUN servers for Play with Friends' NAT discovery: two independent operators, so one
|
||||
/// outage doesn't break discovery (`HORIZON_STUN_SERVERS` overrides them).
|
||||
pub const STUN_SERVERS: &[&str] = &["stun.cloudflare.com:3478", "stun.l.google.com:19302"];
|
||||
|
||||
/// Play with Friends' troubleshooting page, shown to hosts nobody can reach.
|
||||
pub const FRIENDS_HELP: &str =
|
||||
"https://github.com/horizon-rust/horizon/blob/main/docs/play-with-friends.md#troubleshooting";
|
||||
@@ -652,9 +652,10 @@ mod tests {
|
||||
assert_eq!(zh.get("%s chunks"), Some("%s 个区块"));
|
||||
// Not every bundle is complete: the en_us layer is in every map, so a
|
||||
// missing key falls back to English (`horizon.mods.not_built` is not
|
||||
// in ru_ru either).
|
||||
// in la_la).
|
||||
assert_eq!(zh.get("LOD Distance"), Some("LOD 距离"));
|
||||
assert_eq!(ru.get("horizon.mods.not_built"), Some(" (Not built)"));
|
||||
let la = Language::load(&[&empty], "la_la");
|
||||
assert_eq!(la.get("horizon.mods.not_built"), Some(" (Not built)"));
|
||||
let pl = Language::load(&[&empty], "pl_pl");
|
||||
assert_eq!(pl.get("horizon.options.controller"), Some("Kontroler"));
|
||||
assert_eq!(pl.get("horizon.options.vibration"), Some("Wibracje"));
|
||||
|
||||
@@ -11,7 +11,6 @@ horizon-cosmetics = { path = "../horizon-cosmetics" }
|
||||
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
|
||||
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-client-input = { path = "../horizon-client-input" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
@@ -27,7 +26,7 @@ horizon-polytone = { path = "../horizon-polytone" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! `cargo run --profile fast -p horizon-client-render --example pack_load_bench [-- --scale 1]`
|
||||
//! (`.github/workflows/pack-bench.yml` runs it on a branch and on its base, side by side).
|
||||
use horizon_client_render::{
|
||||
gfx::PreparedTextures,
|
||||
hud::HudAtlas,
|
||||
model_library::ParsedModels,
|
||||
packs::{self, Pack},
|
||||
@@ -403,6 +404,30 @@ fn main() {
|
||||
);
|
||||
row("CTM tiles: one by one", sprites(false, false, &tiles, true));
|
||||
row("CTM tiles: prefetched", sprites(false, true, &tiles, true));
|
||||
// The mip chains the renderer uploads, from the same pack layers: the HD pack's
|
||||
// 64 px block sprites fill the array, as they do for a player who has it on.
|
||||
let mut atlas = Sprites::with_disk_cache(&root);
|
||||
for name in &blocks {
|
||||
atlas.insert(&root, name).unwrap();
|
||||
}
|
||||
atlas.finish();
|
||||
let border = vec![255u8; (atlas.size * atlas.size * 4) as usize];
|
||||
row(
|
||||
"texture upload: sprite mip chains, both packs",
|
||||
time(
|
||||
runs,
|
||||
|| {},
|
||||
|| {
|
||||
std::hint::black_box(PreparedTextures::new(
|
||||
&atlas.pixels,
|
||||
atlas.size,
|
||||
atlas.mips.len() as u32,
|
||||
&atlas.mips,
|
||||
(&border, atlas.size),
|
||||
));
|
||||
},
|
||||
),
|
||||
);
|
||||
row(
|
||||
"GUI atlas: decode and stitch, both packs",
|
||||
time(
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
// Adapted from Starling (https://github.com/starling-client/starling, `android` branch at
|
||||
// 150a414b09a1, `crates/horizon-client-render/src/android_gamepad.rs` and the input-mode part of
|
||||
// `android_runtime.rs`), written by Vasysik and the Starling contributors. Copyright (c) 2026
|
||||
// Ludovic Marie and the Horizon contributors, under the same MIT license as Horizon (LICENSE at
|
||||
// the repository root).
|
||||
|
||||
//! Controllers as Android reports them: its standard key codes and motion axes,
|
||||
//! the same for USB and Bluetooth pads, decoded into `gamepad::PadState`, and
|
||||
//! which input device (touch, keyboard and mouse, or a controller) is in charge.
|
||||
//! Pure logic: the Android event backend feeds it, nothing here talks to the OS.
|
||||
use crate::gamepad::{Button, PadState};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The input device the player picked in the options, or the one in use.
|
||||
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum InputMode {
|
||||
/// Follow whichever device was used last.
|
||||
#[default]
|
||||
Auto,
|
||||
Touch,
|
||||
KeyboardMouse,
|
||||
Controller,
|
||||
}
|
||||
impl InputMode {
|
||||
pub const ALL: [Self; 4] = [
|
||||
Self::Auto,
|
||||
Self::Touch,
|
||||
Self::KeyboardMouse,
|
||||
Self::Controller,
|
||||
];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Auto => "Automatic",
|
||||
Self::Touch => "Touch & Android keyboard",
|
||||
Self::KeyboardMouse => "Keyboard & mouse",
|
||||
Self::Controller => "Controller",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Android `MotionEvent` axes (AXIS_*).
|
||||
const AXIS_X: usize = 0;
|
||||
const AXIS_Y: usize = 1;
|
||||
const AXIS_Z: usize = 11;
|
||||
const AXIS_RZ: usize = 14;
|
||||
const AXIS_HAT_X: usize = 15;
|
||||
const AXIS_HAT_Y: usize = 16;
|
||||
const AXIS_LTRIGGER: usize = 17;
|
||||
const AXIS_RTRIGGER: usize = 18;
|
||||
const AXIS_GAS: usize = 22;
|
||||
const AXIS_BRAKE: usize = 23;
|
||||
const AXES: usize = 24;
|
||||
|
||||
/// The Android `KeyEvent` key code (KEYCODE_*) of a controller button. Back
|
||||
/// (4) is East, as on Android's own screens; volume and other system keys are
|
||||
/// never game input.
|
||||
pub fn button(code: u32) -> Option<Button> {
|
||||
Some(match code {
|
||||
96 => Button::South,
|
||||
97 | 4 => Button::East,
|
||||
99 => Button::West,
|
||||
100 => Button::North,
|
||||
102 => Button::LeftBumper,
|
||||
103 => Button::RightBumper,
|
||||
104 => Button::LeftTrigger,
|
||||
105 => Button::RightTrigger,
|
||||
106 => Button::LeftStick,
|
||||
107 => Button::RightStick,
|
||||
108 => Button::Start,
|
||||
109 => Button::Select,
|
||||
19 => Button::DPadUp,
|
||||
20 => Button::DPadDown,
|
||||
21 => Button::DPadLeft,
|
||||
22 => Button::DPadRight,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// One controller: its key states, its last axis values, and presses too short
|
||||
/// to be seen by a poll (released before the next frame).
|
||||
#[derive(Default)]
|
||||
struct Device {
|
||||
keys: PadState,
|
||||
axes: [f32; AXES],
|
||||
pending: PadState,
|
||||
}
|
||||
impl Device {
|
||||
fn state(&self) -> PadState {
|
||||
let a = &self.axes;
|
||||
let mut s = self.keys;
|
||||
// Android's Y axes grow downwards; `PadState`'s grow upwards.
|
||||
s.left = [a[AXIS_X], -a[AXIS_Y]];
|
||||
s.right = [a[AXIS_Z], -a[AXIS_RZ]];
|
||||
// Some pads report triggers as LTRIGGER/RTRIGGER, others as BRAKE/GAS,
|
||||
// others only as L2/R2 keys: `Edges` reads triggers from `triggers`.
|
||||
let digital = |b| if s.held(b) { 1.0 } else { 0.0 };
|
||||
let triggers = [
|
||||
a[AXIS_LTRIGGER]
|
||||
.max(a[AXIS_BRAKE])
|
||||
.max(digital(Button::LeftTrigger)),
|
||||
a[AXIS_RTRIGGER]
|
||||
.max(a[AXIS_GAS])
|
||||
.max(digital(Button::RightTrigger)),
|
||||
];
|
||||
s.triggers = triggers;
|
||||
// A hat D-pad adds to a key D-pad; a centred hat doesn't release keys.
|
||||
for (button, held) in [
|
||||
(Button::DPadLeft, a[AXIS_HAT_X] < -0.5),
|
||||
(Button::DPadRight, a[AXIS_HAT_X] > 0.5),
|
||||
(Button::DPadUp, a[AXIS_HAT_Y] < -0.5),
|
||||
(Button::DPadDown, a[AXIS_HAT_Y] > 0.5),
|
||||
] {
|
||||
s.set(button, held || s.held(button));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// The connected controllers by Android device id, and the one in use.
|
||||
#[derive(Default)]
|
||||
pub struct Devices {
|
||||
devices: BTreeMap<i32, Device>,
|
||||
active: Option<i32>,
|
||||
}
|
||||
impl Devices {
|
||||
/// A motion event. Returns true only for a meaningful change, so a resting
|
||||
/// or drifting stick never takes over from a finger or a mouse.
|
||||
pub fn axis(&mut self, id: i32, axis: u32, value: f64) -> bool {
|
||||
let index = axis as usize;
|
||||
if ![
|
||||
AXIS_X,
|
||||
AXIS_Y,
|
||||
AXIS_Z,
|
||||
AXIS_RZ,
|
||||
AXIS_HAT_X,
|
||||
AXIS_HAT_Y,
|
||||
AXIS_LTRIGGER,
|
||||
AXIS_RTRIGGER,
|
||||
AXIS_GAS,
|
||||
AXIS_BRAKE,
|
||||
]
|
||||
.contains(&index)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let value = if value.is_finite() {
|
||||
value.clamp(-1.0, 1.0) as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let d = self.devices.entry(id).or_default();
|
||||
let old = std::mem::replace(&mut d.axes[index], value);
|
||||
if value >= 0.5 && old < 0.5 {
|
||||
let trigger = match index {
|
||||
AXIS_LTRIGGER | AXIS_BRAKE => Some(0),
|
||||
AXIS_RTRIGGER | AXIS_GAS => Some(1),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(i) = trigger {
|
||||
d.pending.triggers[i] = value;
|
||||
}
|
||||
}
|
||||
let activity = value.abs() > 0.25 && (value - old).abs() > 0.01;
|
||||
if activity {
|
||||
self.active = Some(id);
|
||||
}
|
||||
activity
|
||||
}
|
||||
/// A key event. Returns true for a new press of a controller button (key
|
||||
/// repeats and releases are not activity).
|
||||
pub fn key(&mut self, id: i32, code: u32, down: bool) -> bool {
|
||||
let Some(b) = button(code) else { return false };
|
||||
let d = self.devices.entry(id).or_default();
|
||||
let activity = down && !d.keys.held(b);
|
||||
d.keys.set(b, down);
|
||||
if activity {
|
||||
d.pending.set(b, true);
|
||||
match b {
|
||||
Button::LeftTrigger => d.pending.triggers[0] = 1.0,
|
||||
Button::RightTrigger => d.pending.triggers[1] = 1.0,
|
||||
_ => {}
|
||||
}
|
||||
self.active = Some(id);
|
||||
}
|
||||
activity
|
||||
}
|
||||
/// Forget controllers that are no longer connected.
|
||||
pub fn retain(&mut self, ids: &[i32]) {
|
||||
self.devices.retain(|id, _| ids.contains(id));
|
||||
if self.active.is_some_and(|id| !ids.contains(&id)) {
|
||||
self.active = None;
|
||||
}
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
pub fn active_id(&self) -> Option<i32> {
|
||||
self.active
|
||||
}
|
||||
/// The controller in use, without consuming short presses.
|
||||
pub fn state(&self) -> PadState {
|
||||
self.active
|
||||
.and_then(|id| self.devices.get(&id))
|
||||
.map(Device::state)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// The controller in use, once per frame: a press released since the last
|
||||
/// poll still shows as held this once.
|
||||
pub fn poll(&mut self) -> PadState {
|
||||
self.active
|
||||
.and_then(|id| self.devices.get_mut(&id))
|
||||
.map(|d| d.state().merge(&std::mem::take(&mut d.pending)))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The picked input mode and the device in charge. In `Auto` the last device
|
||||
/// used takes over; a picked mode ignores the others.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Selection {
|
||||
mode: InputMode,
|
||||
active: InputMode,
|
||||
}
|
||||
impl Default for Selection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: InputMode::Auto,
|
||||
active: InputMode::Touch,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Selection {
|
||||
/// The mode picked in the options.
|
||||
pub fn mode(self) -> InputMode {
|
||||
self.mode
|
||||
}
|
||||
/// The device in charge now; never `Auto`.
|
||||
pub fn active(self) -> InputMode {
|
||||
self.active
|
||||
}
|
||||
/// Pick a mode. Returns true when it changed: the caller drops held
|
||||
/// controller and keypad input so nothing stays pressed across the switch.
|
||||
pub fn configure(&mut self, mode: InputMode) -> bool {
|
||||
if self.mode == mode {
|
||||
return false;
|
||||
}
|
||||
self.mode = mode;
|
||||
self.active = if mode == InputMode::Auto {
|
||||
InputMode::Touch
|
||||
} else {
|
||||
mode
|
||||
};
|
||||
true
|
||||
}
|
||||
/// A device of `kind` was used. Returns whether it may act: false when
|
||||
/// another mode was picked.
|
||||
pub fn note(&mut self, kind: InputMode) -> bool {
|
||||
if kind == InputMode::Auto || (self.mode != InputMode::Auto && self.mode != kind) {
|
||||
return false;
|
||||
}
|
||||
self.active = kind;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn android_y_is_inverted_and_hats_do_not_erase_key_dpad() {
|
||||
let mut d = Devices::default();
|
||||
d.axis(7, 1, -1.0);
|
||||
d.axis(7, 14, 0.7);
|
||||
d.key(7, 19, true);
|
||||
d.axis(7, 16, 0.0);
|
||||
assert_eq!(d.state().left, [0.0, 1.0]);
|
||||
assert_eq!(d.state().right, [0.0, -0.7]);
|
||||
assert!(d.state().held(Button::DPadUp));
|
||||
d.key(7, 19, false);
|
||||
assert!(!d.state().held(Button::DPadUp));
|
||||
d.axis(7, 15, 1.0);
|
||||
assert!(d.state().held(Button::DPadRight));
|
||||
}
|
||||
#[test]
|
||||
fn a_short_press_survives_until_one_poll_and_disconnect_clears_it() {
|
||||
let mut d = Devices::default();
|
||||
d.key(4, 96, true);
|
||||
assert_eq!(d.active_id(), Some(4));
|
||||
d.key(4, 96, false);
|
||||
assert!(d.poll().held(Button::South));
|
||||
assert!(!d.poll().held(Button::South));
|
||||
d.key(4, 96, true);
|
||||
d.retain(&[]);
|
||||
assert_eq!(d.active_id(), None);
|
||||
assert_eq!(d.poll(), PadState::default());
|
||||
}
|
||||
#[test]
|
||||
fn triggers_accept_analog_and_digital_reports() {
|
||||
let mut d = Devices::default();
|
||||
d.key(3, 104, true);
|
||||
d.axis(3, 17, 0.0);
|
||||
assert!(d.state().held(Button::LeftTrigger));
|
||||
assert_eq!(d.state().triggers[0], 1.0);
|
||||
d.key(3, 104, false);
|
||||
assert_eq!(d.state().triggers[0], 0.0);
|
||||
d.axis(3, 23, 0.8);
|
||||
assert_eq!(d.state().triggers[0], 0.8);
|
||||
d.axis(3, 22, 0.6);
|
||||
assert_eq!(d.state().triggers[1], 0.6);
|
||||
assert!(!d.key(3, 24, true)); // Volume up is never a game action.
|
||||
}
|
||||
#[test]
|
||||
fn digital_triggers_reach_edges_even_when_tapped_between_polls() {
|
||||
let mut d = Devices::default();
|
||||
let mut edges = crate::gamepad::Edges::default();
|
||||
d.key(1, 105, true);
|
||||
d.key(1, 105, false);
|
||||
assert_eq!(edges.update(&d.poll(), 0.4), [(Button::RightTrigger, true)]);
|
||||
assert_eq!(
|
||||
edges.update(&d.poll(), 0.4),
|
||||
[(Button::RightTrigger, false)]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn drift_neutral_and_repeats_do_not_select_another_controller() {
|
||||
let mut d = Devices::default();
|
||||
assert!(!d.axis(1, 0, 0.12));
|
||||
assert_eq!(d.state(), PadState::default());
|
||||
assert!(d.key(2, 96, true));
|
||||
assert!(!d.key(2, 96, true));
|
||||
d.axis(1, 0, 0.0);
|
||||
assert!(d.state().held(Button::South));
|
||||
d.retain(&[1]);
|
||||
assert_eq!(d.state(), PadState::default());
|
||||
d.axis(1, 0, f64::NAN);
|
||||
assert_eq!(d.state(), PadState::default());
|
||||
assert!(!d.axis(1, 9, 1.0)); // An axis no controller stick uses.
|
||||
}
|
||||
#[test]
|
||||
fn picked_modes_reject_other_devices_and_auto_follows_activity() {
|
||||
let mut s = Selection::default();
|
||||
assert_eq!((s.mode(), s.active()), (InputMode::Auto, InputMode::Touch));
|
||||
assert!(s.configure(InputMode::Touch));
|
||||
assert!(!s.configure(InputMode::Touch));
|
||||
assert!(!s.note(InputMode::Controller));
|
||||
assert_eq!(s.active(), InputMode::Touch);
|
||||
assert!(s.configure(InputMode::Controller));
|
||||
assert_eq!(s.active(), InputMode::Controller);
|
||||
assert!(!s.note(InputMode::Touch));
|
||||
assert!(s.configure(InputMode::Auto));
|
||||
assert_eq!(s.active(), InputMode::Touch);
|
||||
assert!(s.note(InputMode::KeyboardMouse));
|
||||
assert_eq!(s.active(), InputMode::KeyboardMouse);
|
||||
assert!(s.note(InputMode::Controller));
|
||||
assert_eq!(s.active(), InputMode::Controller);
|
||||
assert!(!s.note(InputMode::Auto));
|
||||
assert_eq!(s.active(), InputMode::Controller);
|
||||
}
|
||||
#[test]
|
||||
fn input_modes_save_by_name() {
|
||||
let names: Vec<_> = InputMode::ALL
|
||||
.iter()
|
||||
.map(|m| serde_json::to_string(m).unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"\"auto\"",
|
||||
"\"touch\"",
|
||||
"\"keyboard-mouse\"",
|
||||
"\"controller\""
|
||||
]
|
||||
);
|
||||
assert!(InputMode::ALL.iter().all(|m| !m.label().is_empty()));
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,8 @@ struct Mixer {
|
||||
cameras: Vec<([f64; 3], Basis, u32)>,
|
||||
/// Per-source gain/pan/step cache for one buffer, reused across callbacks.
|
||||
voices: Vec<(ChannelId, [f32; 3], f64)>,
|
||||
/// Mods' audio sources (`push_voice`), each with its queue.
|
||||
mod_audio: Vec<ModAudioSource>,
|
||||
/// A mod's muffle of the sounds it does not exempt.
|
||||
muffle: Muffle,
|
||||
limiter_peak: f32,
|
||||
@@ -121,10 +123,158 @@ struct Mixer {
|
||||
/// The stream reported its device gone (`Library.isCurrentDeviceDisconnected`).
|
||||
#[cfg(feature = "audio")]
|
||||
device_lost: bool,
|
||||
/// The stream's recent backend errors, which tell a dead stream from a hiccup.
|
||||
#[cfg(feature = "audio")]
|
||||
stream_errors: StreamErrors,
|
||||
}
|
||||
|
||||
/// A stream's backend errors close together (`StreamErrors::dead`).
|
||||
#[cfg(any(feature = "audio", test))]
|
||||
#[derive(Default)]
|
||||
struct StreamErrors {
|
||||
/// When the current run of errors began, and how many it holds.
|
||||
since: Option<std::time::Instant>,
|
||||
count: u32,
|
||||
}
|
||||
#[cfg(any(feature = "audio", test))]
|
||||
impl StreamErrors {
|
||||
/// Errors this close together...
|
||||
const WINDOW: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
/// ...this many of them: the stream is dead. Under vanilla a device that fails unrecoverably
|
||||
/// reads as disconnected (`ALC_CONNECTED`, the `ALC_EXT_disconnect` extension), and
|
||||
/// `SoundEngine` opens the output again; cpal reports a dead ALSA stream (`alsa::poll()`
|
||||
/// returning POLLERR, over and over) as a backend error like any other, so a run of them
|
||||
/// stands for it. One alone is a hiccup the stream recovers from.
|
||||
const DEAD_AFTER: u32 = 3;
|
||||
/// One more backend error at `now`: whether the stream counts as dead.
|
||||
fn dead(&mut self, now: std::time::Instant) -> bool {
|
||||
match self.since {
|
||||
Some(since) if now.duration_since(since) <= Self::WINDOW => self.count += 1,
|
||||
_ => {
|
||||
self.since = Some(now);
|
||||
self.count = 1;
|
||||
}
|
||||
}
|
||||
self.count >= Self::DEAD_AFTER
|
||||
}
|
||||
}
|
||||
|
||||
/// Opening the output again after it failed: a few more tries, a couple of seconds apart, so a
|
||||
/// device that is gone for good isn't asked forever.
|
||||
#[cfg(any(feature = "audio", test))]
|
||||
#[derive(Default, Debug, PartialEq)]
|
||||
struct Reopen {
|
||||
/// The tries left; 0 while the output plays, or after the last try.
|
||||
tries: u32,
|
||||
/// Ticks since the last try.
|
||||
ticks: u32,
|
||||
}
|
||||
#[cfg(any(feature = "audio", test))]
|
||||
impl Reopen {
|
||||
const TRIES: u32 = 5;
|
||||
/// Two seconds of client ticks.
|
||||
const TICKS: u32 = 40;
|
||||
/// The output failed to open: whether to keep trying.
|
||||
fn failed(&mut self, retrying: bool) -> bool {
|
||||
self.ticks = 0;
|
||||
self.tries = if retrying {
|
||||
self.tries.saturating_sub(1)
|
||||
} else {
|
||||
Self::TRIES
|
||||
};
|
||||
self.tries > 0
|
||||
}
|
||||
/// A tick while tries are left: whether to try now.
|
||||
fn due(&mut self) -> bool {
|
||||
if self.tries == 0 {
|
||||
return false;
|
||||
}
|
||||
self.ticks += 1;
|
||||
self.ticks >= Self::TICKS
|
||||
}
|
||||
}
|
||||
/// A camera's forward, up and right vectors.
|
||||
type Basis = ([f64; 3], [f64; 3], [f64; 3]);
|
||||
/// How much audio one mod source may have waiting, in seconds (`push_voice`): a burst past it
|
||||
/// is refused rather than delayed, so a stalled sender cannot pile up latency.
|
||||
const MOD_AUDIO_SECONDS: f64 = 2.0;
|
||||
/// How many mod audio sources may play at once, across mods.
|
||||
const MOD_AUDIO_SOURCES: usize = 64;
|
||||
/// The distance at which positioned mod audio has faded out, in blocks (linear, as a sound's
|
||||
/// `AL_LINEAR_DISTANCE` fades).
|
||||
const MOD_AUDIO_RANGE: f32 = 48.0;
|
||||
/// One of a mod's audio sources (`push_voice`): a speaker, keyed by the mod and its own id
|
||||
/// for it. Its chunks play one after another, oldest first, so each speaker's audio stays in
|
||||
/// order while different speakers play at the same time.
|
||||
struct ModAudioSource {
|
||||
mod_id: String,
|
||||
source: u128,
|
||||
chunks: std::collections::VecDeque<ModAudioChunk>,
|
||||
/// How far into the head chunk the callback has got, in its own samples.
|
||||
played: f64,
|
||||
}
|
||||
/// A block of mono samples a mod queued.
|
||||
struct ModAudioChunk {
|
||||
/// Mono samples, -1 to 1.
|
||||
samples: Vec<f32>,
|
||||
/// The rate they were sampled at.
|
||||
rate: u32,
|
||||
/// Where they were spoken, for distance and direction; none plays them centred.
|
||||
at: Option<[f64; 3]>,
|
||||
}
|
||||
impl Mixer {
|
||||
/// Queues `samples` behind what `source` of `mod_id` already has waiting. False, and
|
||||
/// nothing queued, when they are empty or the rate is 0, when the source would hold more
|
||||
/// than [`MOD_AUDIO_SECONDS`], or when a new source would pass [`MOD_AUDIO_SOURCES`].
|
||||
fn push_mod_audio(
|
||||
&mut self,
|
||||
mod_id: &str,
|
||||
source: u128,
|
||||
samples: &[f32],
|
||||
rate: u32,
|
||||
at: Option<[f64; 3]>,
|
||||
) -> bool {
|
||||
if samples.is_empty() || rate == 0 {
|
||||
return false;
|
||||
}
|
||||
let seconds = |len: usize, rate: u32| len as f64 / f64::from(rate);
|
||||
let added = seconds(samples.len(), rate);
|
||||
let chunk = ModAudioChunk {
|
||||
samples: samples.to_vec(),
|
||||
rate,
|
||||
at,
|
||||
};
|
||||
match self
|
||||
.mod_audio
|
||||
.iter()
|
||||
.position(|s| s.source == source && s.mod_id == mod_id)
|
||||
{
|
||||
Some(i) => {
|
||||
let s = &mut self.mod_audio[i];
|
||||
let waiting: f64 = s
|
||||
.chunks
|
||||
.iter()
|
||||
.map(|c| seconds(c.samples.len(), c.rate))
|
||||
.sum();
|
||||
if waiting + added > MOD_AUDIO_SECONDS {
|
||||
return false;
|
||||
}
|
||||
s.chunks.push_back(chunk);
|
||||
}
|
||||
None => {
|
||||
if added > MOD_AUDIO_SECONDS || self.mod_audio.len() >= MOD_AUDIO_SOURCES {
|
||||
return false;
|
||||
}
|
||||
self.mod_audio.push(ModAudioSource {
|
||||
mod_id: mod_id.to_owned(),
|
||||
source,
|
||||
chunks: std::collections::VecDeque::from([chunk]),
|
||||
played: 0.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The id of world `name` (1 and up), given once per name.
|
||||
fn world_id(&mut self, name: &str) -> u32 {
|
||||
let i = match self.world_names.iter().position(|w| w == name) {
|
||||
@@ -164,6 +314,24 @@ impl MuffleFade {
|
||||
[self.amount * left, self.lowpass * left]
|
||||
}
|
||||
}
|
||||
impl MixerBackend {
|
||||
/// Queues a mod's audio for the output callback (`Host::push_voice`). False when nothing
|
||||
/// was queued: no output device, or what [`Mixer::push_mod_audio`] refuses.
|
||||
pub fn push_voice(
|
||||
&self,
|
||||
mod_id: &str,
|
||||
source: u128,
|
||||
samples: &[f32],
|
||||
rate: u32,
|
||||
at: Option<[f64; 3]>,
|
||||
) -> bool {
|
||||
self.live
|
||||
&& self
|
||||
.mixer
|
||||
.lock()
|
||||
.is_ok_and(|mut m| m.push_mod_audio(mod_id, source, samples, rate, at))
|
||||
}
|
||||
}
|
||||
/// The mixer side of the engine (`Backend`).
|
||||
pub struct MixerBackend {
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
@@ -286,6 +454,21 @@ pub struct CapturedSound {
|
||||
pub except_entity: Option<u32>,
|
||||
}
|
||||
|
||||
impl Audio {
|
||||
/// Plays a mod's audio (`Host::push_voice`): mono samples, -1 to 1, at `rate`, behind what
|
||||
/// `source` of `mod_id` already has waiting; `at` places them for distance and direction.
|
||||
pub fn push_voice(
|
||||
&self,
|
||||
mod_id: &str,
|
||||
source: u128,
|
||||
samples: &[f32],
|
||||
rate: u32,
|
||||
at: Option<[f64; 3]>,
|
||||
) -> bool {
|
||||
self.backend.push_voice(mod_id, source, samples, rate, at)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Audio {
|
||||
_stream: Option<Stream>,
|
||||
device_name: String,
|
||||
@@ -327,6 +510,9 @@ pub struct Audio {
|
||||
/// Ticks since the output devices were last looked at (`DeviceTracker.tick`).
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: u32,
|
||||
/// Opening the output again after it failed to (`Reopen`).
|
||||
#[cfg(feature = "audio")]
|
||||
reopen: Reopen,
|
||||
/// The look at the output devices running on its own thread, until a tick
|
||||
/// takes its answer: listing them can take 100 ms (#11490).
|
||||
#[cfg(feature = "audio")]
|
||||
@@ -836,6 +1022,62 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
mixer.muffle.stages = [[0.0; 2]; 2];
|
||||
}
|
||||
mixer.muffle.bus = bus;
|
||||
// A mod's audio (`push_voice`): every source at once, each playing its own chunks in order;
|
||||
// a chunk that ends mid-buffer hands the rest of the buffer to the next one.
|
||||
if !mixer.mod_audio.is_empty() {
|
||||
let (forward, up, right) = basis(mixer.listener_yaw, mixer.listener_pitch);
|
||||
let listener = mixer.listener;
|
||||
let frames = data.len() / channels.max(1);
|
||||
for source in &mut mixer.mod_audio {
|
||||
let ModAudioSource { chunks, played, .. } = source;
|
||||
let mut f = 0;
|
||||
while f < frames {
|
||||
let Some(chunk) = chunks.front() else {
|
||||
break;
|
||||
};
|
||||
let len = chunk.samples.len();
|
||||
let [left, right_gain, gain] = match chunk.at {
|
||||
Some(at) => {
|
||||
let d = [
|
||||
at[0] - listener[0],
|
||||
at[1] - listener[1],
|
||||
at[2] - listener[2],
|
||||
];
|
||||
let gain = distance_gain(Some(MOD_AUDIO_RANGE), dot(d, d).sqrt());
|
||||
let [l, r] = stereo_gains(dot(d, right), dot(d, up), dot(d, forward));
|
||||
[l * gain, r * gain, gain]
|
||||
}
|
||||
None => [1.0; 3],
|
||||
};
|
||||
let step = f64::from(chunk.rate) / f64::from(rate);
|
||||
while f < frames && (*played as usize) < len {
|
||||
let i = *played as usize;
|
||||
let t = (*played - i as f64) as f32;
|
||||
let next = chunk
|
||||
.samples
|
||||
.get(i + 1)
|
||||
.copied()
|
||||
.unwrap_or(chunk.samples[i]);
|
||||
let s = chunk.samples[i] * (1.0 - t) + next * t;
|
||||
let frame = &mut data[f * channels..(f + 1) * channels];
|
||||
if channels >= 2 {
|
||||
frame[0] += s * left;
|
||||
frame[1] += s * right_gain;
|
||||
} else {
|
||||
frame[0] += s * gain;
|
||||
}
|
||||
*played += step;
|
||||
f += 1;
|
||||
}
|
||||
if *played as usize >= len {
|
||||
// The next chunk starts where this one's last step left off.
|
||||
*played -= len as f64;
|
||||
chunks.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
mixer.mod_audio.retain(|s| !s.chunks.is_empty());
|
||||
}
|
||||
// Limit the whole buffer together, preserving waveform and stereo balance
|
||||
// instead of flattening each overloaded sample; release over 200 ms
|
||||
// (OpenAL Soft's output limiter, `ALC_OUTPUT_LIMITER_SOFT`, releases
|
||||
@@ -903,6 +1145,7 @@ fn open_device(
|
||||
if let Ok(mut m) = mixer.lock() {
|
||||
m.device_name = device.name().unwrap_or_default();
|
||||
m.device_lost = false;
|
||||
m.stream_errors = StreamErrors::default();
|
||||
}
|
||||
Ok((stream, config))
|
||||
}
|
||||
@@ -1052,10 +1295,19 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
},
|
||||
move |e| {
|
||||
log::warn!("Horizon: audio stream error: {e}");
|
||||
if matches!(e, cpal::StreamError::DeviceNotAvailable)
|
||||
&& let Ok(mut m) = on_error.lock()
|
||||
{
|
||||
m.device_lost = true;
|
||||
if let Ok(mut m) = on_error.lock() {
|
||||
let dead = match e {
|
||||
cpal::StreamError::DeviceNotAvailable => true,
|
||||
cpal::StreamError::BackendSpecific { .. } => {
|
||||
m.stream_errors.dead(std::time::Instant::now())
|
||||
}
|
||||
};
|
||||
if dead && !m.device_lost {
|
||||
if !matches!(e, cpal::StreamError::DeviceNotAvailable) {
|
||||
log::warn!("Horizon: the audio stream stopped; opening the output again");
|
||||
}
|
||||
m.device_lost = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
None,
|
||||
@@ -1177,6 +1429,8 @@ impl Audio {
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: 0,
|
||||
#[cfg(feature = "audio")]
|
||||
reopen: Reopen::default(),
|
||||
#[cfg(feature = "audio")]
|
||||
device_probe: None,
|
||||
}
|
||||
}
|
||||
@@ -1340,6 +1594,8 @@ impl Audio {
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: 0,
|
||||
#[cfg(feature = "audio")]
|
||||
reopen: Reopen::default(),
|
||||
#[cfg(feature = "audio")]
|
||||
device_probe: None,
|
||||
}
|
||||
}
|
||||
@@ -1447,6 +1703,27 @@ impl Audio {
|
||||
}
|
||||
changed
|
||||
}
|
||||
/// Opens the output on the chosen device again; `retrying` after an earlier try failed.
|
||||
#[cfg(feature = "audio")]
|
||||
fn reopen_output(&mut self, retrying: bool) {
|
||||
// The old stream lets go of the device first: a dead one may still hold it.
|
||||
self._stream = None;
|
||||
let device = self.device_name.clone();
|
||||
match self.set_device(&device) {
|
||||
Ok(()) => {
|
||||
if retrying {
|
||||
log::info!("Horizon: the audio output is back");
|
||||
}
|
||||
self.reopen = Reopen::default();
|
||||
}
|
||||
Err(e) if self.reopen.failed(retrying) => {
|
||||
log::warn!("Horizon: could not reopen the audio output ({e}); trying again soon");
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: could not reopen the audio output ({e}); it stays off");
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `SoundManager.tick(paused)`, once per client tick.
|
||||
pub fn tick(&mut self, paused: bool, ctx: &TickContext) {
|
||||
#[cfg(feature = "audio")]
|
||||
@@ -1454,10 +1731,9 @@ impl Audio {
|
||||
if self.should_change_device() {
|
||||
// SoundEngine.reload: stop every sound, open the output again.
|
||||
self.stop_all();
|
||||
let device = self.device_name.clone();
|
||||
if let Err(e) = self.set_device(&device) {
|
||||
log::warn!("Horizon: could not reopen the audio output: {e}");
|
||||
}
|
||||
self.reopen_output(false);
|
||||
} else if self.backend.owner == 0 && self.reopen.due() {
|
||||
self.reopen_output(true);
|
||||
}
|
||||
}
|
||||
self.sync_volumes();
|
||||
@@ -1858,6 +2134,46 @@ impl Sounds for Audio {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A run of backend errors close together is a dead stream; one or two, or errors far apart,
|
||||
/// are hiccups.
|
||||
#[test]
|
||||
fn a_run_of_stream_errors_is_a_dead_stream() {
|
||||
use std::time::{Duration, Instant};
|
||||
let start = Instant::now();
|
||||
let at = |ms: u64| start + Duration::from_millis(ms);
|
||||
let mut errors = StreamErrors::default();
|
||||
assert!(!errors.dead(at(0)));
|
||||
assert!(!errors.dead(at(500)));
|
||||
assert!(errors.dead(at(900)));
|
||||
// Spread out, they never add up.
|
||||
let mut apart = StreamErrors::default();
|
||||
for k in 0..10 {
|
||||
assert!(!apart.dead(at(k * 2500)), "error {k}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed reopening is tried again every two seconds, five more times, then given up.
|
||||
#[test]
|
||||
fn a_failed_reopening_retries_a_few_times_then_stops() {
|
||||
let mut reopen = Reopen::default();
|
||||
assert!(!reopen.due(), "nothing to retry while the output plays");
|
||||
assert!(reopen.failed(false));
|
||||
let mut tries = 0;
|
||||
for _ in 0..1000 {
|
||||
if reopen.due() {
|
||||
tries += 1;
|
||||
if !reopen.failed(true) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(tries, Reopen::TRIES);
|
||||
assert_eq!(reopen, Reopen::default());
|
||||
// The next lost device starts a fresh run.
|
||||
assert!(reopen.failed(false));
|
||||
assert_eq!(reopen.tries, Reopen::TRIES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_capture_keeps_order_category_entity_and_actor_exclusion() {
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-entity-sound-test"));
|
||||
@@ -2517,6 +2833,66 @@ mod tests {
|
||||
assert_eq!(level(&audio), [0.0; 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_mod_audio_source_plays_its_own_chunks_in_order() {
|
||||
let mut m = Mixer::default();
|
||||
// Two speakers of one mod, each queuing two chunks, interleaved.
|
||||
for (source, level) in [(1, 0.1), (2, 0.4), (1, 0.2), (2, 0.3)] {
|
||||
assert!(m.push_mod_audio("voice", source, &[level; 4], 48_000, None));
|
||||
}
|
||||
let mut data = vec![0.0f32; 8 * 2];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
// Each speaker's first chunk, then its second: never both of one speaker at once.
|
||||
let left: Vec<f32> = data.chunks(2).map(|f| f[0]).collect();
|
||||
let expected = [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5];
|
||||
assert!(
|
||||
left.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6),
|
||||
"{left:?}"
|
||||
);
|
||||
assert!(m.mod_audio.is_empty(), "both sources played out");
|
||||
// Order within a source: its second chunk follows its first.
|
||||
assert!(m.push_mod_audio("voice", 1, &[0.25; 2], 48_000, None));
|
||||
assert!(m.push_mod_audio("voice", 1, &[0.5; 2], 48_000, None));
|
||||
let mut data = vec![0.0f32; 4 * 2];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
let left: Vec<f32> = data.chunks(2).map(|f| f[0]).collect();
|
||||
assert_eq!(left, [0.25, 0.25, 0.5, 0.5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mod_audio_sources_are_kept_apart_by_mod_and_capped() {
|
||||
let mut m = Mixer::default();
|
||||
assert!(m.push_mod_audio("a", 1, &[0.1; 48_000], 48_000, None));
|
||||
assert!(m.push_mod_audio("b", 1, &[0.1; 48_000], 48_000, None));
|
||||
assert_eq!(
|
||||
m.mod_audio.len(),
|
||||
2,
|
||||
"the same id in two mods is two sources"
|
||||
);
|
||||
// Two seconds wait at most per source; a burst past that is refused, not delayed.
|
||||
assert!(m.push_mod_audio("a", 1, &[0.1; 48_000], 48_000, None));
|
||||
assert!(!m.push_mod_audio("a", 1, &[0.1; 1], 48_000, None));
|
||||
assert!(!m.push_mod_audio("a", 2, &[], 48_000, None));
|
||||
assert!(!m.push_mod_audio("a", 2, &[0.1], 0, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mod_audio_is_panned_and_faded_by_where_it_was_spoken() {
|
||||
let mut m = Mixer::default();
|
||||
// Facing south (yaw 0), -X is the listener's right.
|
||||
assert!(m.push_mod_audio("voice", 1, &[0.5; 16], 48_000, Some([-4.0, 0.0, 0.0])));
|
||||
let mut data = vec![0.0f32; 16 * 2];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
let left: f32 = data.chunks(2).map(|f| f[0]).sum();
|
||||
let right: f32 = data.chunks(2).map(|f| f[1]).sum();
|
||||
assert!(right > left, "right {right} should beat left {left}");
|
||||
// Past the range it is silent.
|
||||
assert!(m.push_mod_audio("voice", 1, &[0.5; 16], 48_000, Some([0.0, 0.0, 60.0])));
|
||||
let mut data = vec![0.0f32; 16 * 2];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
assert!(data.iter().all(|s| *s == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_devices_are_looked_at_again_only_when_the_cards_change() {
|
||||
// No fingerprint (not Linux, or the cards could not be read): the list
|
||||
|
||||
@@ -1459,7 +1459,37 @@ impl BlockEntityRenderer {
|
||||
registry: &BlockRegistry,
|
||||
visibility: FaceVisibility,
|
||||
) -> Result<Self> {
|
||||
let all: Value = serde_json::from_slice(&std::fs::read(root.join("entity-models.json"))?)?;
|
||||
Self::load_with_visibility_inner(root, registry, visibility, None, None)
|
||||
}
|
||||
|
||||
/// Load from what the client startup batch already decoded: its entity-model
|
||||
/// document (none reads `entity-models.json`) and the item defaults, avoiding a
|
||||
/// second read and decode of either.
|
||||
pub fn load_with_visibility_models_and_defaults(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
visibility: FaceVisibility,
|
||||
models: Option<&Value>,
|
||||
defaults: &horizon_world::item_defaults::Defaults,
|
||||
) -> Result<Self> {
|
||||
Self::load_with_visibility_inner(root, registry, visibility, models, Some(defaults))
|
||||
}
|
||||
|
||||
fn load_with_visibility_inner(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
visibility: FaceVisibility,
|
||||
models: Option<&Value>,
|
||||
defaults: Option<&horizon_world::item_defaults::Defaults>,
|
||||
) -> Result<Self> {
|
||||
let owned_models = if models.is_none() {
|
||||
Some(serde_json::from_slice(&std::fs::read(
|
||||
root.join("entity-models.json"),
|
||||
)?)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let all = models.or(owned_models.as_ref()).expect("entity models");
|
||||
let models = MODELS
|
||||
.iter()
|
||||
.filter_map(|(key, layer)| all.get(*layer).map(|v| (*key, ModelPart::parse(v))))
|
||||
@@ -1469,12 +1499,17 @@ impl BlockEntityRenderer {
|
||||
// The packs' custom entity models (`.jem` files), before anything draws.
|
||||
renderer.set_shapes(renderer.shapes_for(root));
|
||||
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
|
||||
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()),
|
||||
let owned_defaults = if defaults.is_none() {
|
||||
Some(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()),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
for (item, components) in &items {
|
||||
let items = defaults.or(owned_defaults.as_ref()).unwrap();
|
||||
for (item, components) in items {
|
||||
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -871,7 +871,10 @@ impl RolledLoot {
|
||||
/// Puts the loot table back in place of `Items` while they are still the
|
||||
/// roll; false once the container holds anything else.
|
||||
pub fn restore(&self, map: &mut Compound) -> bool {
|
||||
if map.get(&"Items".into()) != Some(&self.items) {
|
||||
// An empty shulker box saves no `Items`, which matches an empty roll.
|
||||
let saved = map.get(&"Items".into());
|
||||
let empty = matches!(&self.items, Tag::List(l) if l.is_empty());
|
||||
if saved != Some(&self.items) && !(saved.is_none() && empty) {
|
||||
return false;
|
||||
}
|
||||
map.remove(&"Items".into());
|
||||
|
||||
@@ -80,6 +80,8 @@ pub enum BlockEntity {
|
||||
Skull(Skull),
|
||||
/// `TheEndGatewayBlockEntity`: its age and configured destination.
|
||||
EndGateway(EndGateway),
|
||||
/// `TheEndPortalBlockEntity`: no data, only identity.
|
||||
EndPortal,
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
|
||||
@@ -52,6 +52,10 @@ pub struct ChunkMap {
|
||||
recent_deltas: VecDeque<(u64, ChunkMembershipDelta)>,
|
||||
/// The resident chunks generation has not placed its animals in yet.
|
||||
fresh: FxHashSet<Key>,
|
||||
/// For each column, how many of the 3×3 columns around it (itself
|
||||
/// included) are resident; absent when none is. `surrounded` asks one
|
||||
/// map instead of nine every tick, for every ticking chunk.
|
||||
near: FxHashMap<Key, u8>,
|
||||
}
|
||||
impl Default for ChunkMap {
|
||||
fn default() -> Self {
|
||||
@@ -62,6 +66,7 @@ impl Default for ChunkMap {
|
||||
min_valid_revision: revision,
|
||||
recent_deltas: VecDeque::new(),
|
||||
fresh: FxHashSet::default(),
|
||||
near: FxHashMap::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +129,11 @@ impl ChunkMap {
|
||||
}
|
||||
let previous = self.columns.insert(key, chunk);
|
||||
if previous.is_none() {
|
||||
for z in key.1 - 1..=key.1 + 1 {
|
||||
for x in key.0 - 1..=key.0 + 1 {
|
||||
*self.near.entry((x, z)).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
self.revision = next_revision();
|
||||
self.record_delta(ChunkMembershipDelta::Added(key));
|
||||
}
|
||||
@@ -133,6 +143,16 @@ impl ChunkMap {
|
||||
self.fresh.remove(key);
|
||||
let previous = self.columns.remove(key);
|
||||
if previous.is_some() {
|
||||
for z in key.1 - 1..=key.1 + 1 {
|
||||
for x in key.0 - 1..=key.0 + 1 {
|
||||
if let Some(n) = self.near.get_mut(&(x, z)) {
|
||||
*n -= 1;
|
||||
if *n == 0 {
|
||||
self.near.remove(&(x, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.revision = next_revision();
|
||||
self.record_delta(ChunkMembershipDelta::Removed(*key));
|
||||
}
|
||||
@@ -142,6 +162,7 @@ impl ChunkMap {
|
||||
if !self.columns.is_empty() {
|
||||
self.columns.clear();
|
||||
self.fresh.clear();
|
||||
self.near.clear();
|
||||
self.revision = next_revision();
|
||||
self.recent_deltas.clear();
|
||||
self.recent_deltas
|
||||
@@ -149,6 +170,10 @@ impl ChunkMap {
|
||||
self.min_valid_revision = self.revision;
|
||||
}
|
||||
}
|
||||
/// Whether the column at `key` and its eight neighbours are all resident.
|
||||
pub fn surrounded(&self, key: Key) -> bool {
|
||||
self.near.get(&key) == Some(&9)
|
||||
}
|
||||
pub fn has_fresh(&self) -> bool {
|
||||
!self.fresh.is_empty()
|
||||
}
|
||||
@@ -382,4 +407,41 @@ mod tests {
|
||||
assert_eq!(ordered, expected);
|
||||
assert_eq!(ordered.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surrounded_follows_loads_unloads_and_clears() {
|
||||
// Brute force: the column and its eight neighbours are all resident.
|
||||
fn expected(map: &ChunkMap, (x, z): Key) -> bool {
|
||||
(-1..=1).all(|dz| (-1..=1).all(|dx| map.contains_key(&(x + dx, z + dz))))
|
||||
}
|
||||
let mut map = ChunkMap::default();
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
let mut next = || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
seed
|
||||
};
|
||||
for step in 0..4000 {
|
||||
let key = ((next() % 9) as i32 - 4, (next() % 9) as i32 - 4);
|
||||
if next() % 3 == 0 {
|
||||
map.remove(&key);
|
||||
} else {
|
||||
// Replacing a resident column changes nothing.
|
||||
map.insert(key, Arc::new(Chunk::empty(key.0, key.1)));
|
||||
}
|
||||
if step == 2500 {
|
||||
map.clear();
|
||||
}
|
||||
for x in -6..=6 {
|
||||
for z in -6..=6 {
|
||||
assert_eq!(
|
||||
map.surrounded((x, z)),
|
||||
expected(&map, (x, z)),
|
||||
"{x},{z} at {step}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,16 @@ pub fn radius_cells(cloud_range_chunks: i32) -> i32 {
|
||||
((cloud_range_chunks * 16) as f32 / 12.0).ceil() as i32
|
||||
}
|
||||
|
||||
/// Cells the mesh's origin snaps to. The mesh is a window on the cloud pattern around the
|
||||
/// camera's cell, so it used to be rebuilt, and the whole array re-uploaded, on every cell
|
||||
/// crossed: 6.5 MB, about 11 ms at the default 128-chunk range, the worst frame of a flight.
|
||||
/// Anchoring the origin to a coarser grid rebuilds it once every `SNAP_CELLS` cells instead,
|
||||
/// and `Uniform::offset` carries the remaining shift. The ring of cells the shift can leave
|
||||
/// uncovered at the far side lies past the cloud fog end, where the cloud fade has already
|
||||
/// taken the alpha to 0 (`entity.wgsl`'s `fs_entity`), so the extra cells cost only the
|
||||
/// vertices they add.
|
||||
const SNAP_CELLS: i64 = 4;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CellIter {
|
||||
radius: i32,
|
||||
@@ -145,6 +155,10 @@ struct MeshKey {
|
||||
// The two vertical face tests include both surfaces at the boundaries.
|
||||
top: bool,
|
||||
bottom: bool,
|
||||
// The camera's cell within the snapped origin's, for the 3x3 cells whose inward
|
||||
// faces are drawn. Only read while the camera is inside the layer.
|
||||
sub_x: i32,
|
||||
sub_z: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
@@ -169,16 +183,30 @@ impl Input<'_> {
|
||||
let (tw, th) = (f64::from(*w) * 12.0, f64::from(*h) * 12.0);
|
||||
x -= (x / tw).floor() * tw;
|
||||
z -= (z / th).floor() * th;
|
||||
let cell_x = (x / 12.0).floor() as i64;
|
||||
let cell_z = (z / 12.0).floor() as i64;
|
||||
let cell_x = (x / 12.0).floor() as i64 / SNAP_CELLS * SNAP_CELLS;
|
||||
let cell_z = (z / 12.0).floor() as i64 / SNAP_CELLS * SNAP_CELLS;
|
||||
let bottom = (CLOUD_HEIGHT - self.camera.y) as f32;
|
||||
// The inward faces of the 3x3 cells around the camera are only seen from inside the
|
||||
// layer; outside it they are back-facing, so the mesh need not follow the camera there.
|
||||
let inside_layer = bottom <= 0.0 && bottom + 4.0 >= 0.0;
|
||||
let (sub_x, sub_z) = if inside_layer {
|
||||
(
|
||||
(x / 12.0).floor() as i32 - cell_x as i32,
|
||||
(z / 12.0).floor() as i32 - cell_z as i32,
|
||||
)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
let key = MeshKey {
|
||||
cell_x,
|
||||
cell_z,
|
||||
radius: radius_cells(self.range),
|
||||
// One more ring than the range, for the cells the snapped origin can uncover.
|
||||
radius: radius_cells(self.range) + SNAP_CELLS as i32,
|
||||
fast: self.fast,
|
||||
top: bottom <= 0.0,
|
||||
bottom: bottom + 4.0 >= 0.0,
|
||||
sub_x,
|
||||
sub_z,
|
||||
};
|
||||
let colors = [1.0, 0.7, 0.8, 0.9].map(|shade| {
|
||||
let [r, g, b, a] = self.color;
|
||||
@@ -200,6 +228,10 @@ impl Input<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The cloud cells around a snapped origin, rebuilt when the key changes: once every
|
||||
/// [`SNAP_CELLS`] cells the camera crosses, and every cell while the camera is inside the
|
||||
/// layer, where the inward faces follow it. The renderer copies the vertices to the GPU on
|
||||
/// a rebuild, which is why the origin snaps at all.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Mesh {
|
||||
key: Option<MeshKey>,
|
||||
@@ -357,8 +389,8 @@ impl Mesh {
|
||||
}
|
||||
|
||||
let mask = face_mask(
|
||||
dx,
|
||||
dz,
|
||||
dx - key.sub_x,
|
||||
dz - key.sub_z,
|
||||
bottom,
|
||||
[
|
||||
!filled(cx, cz - 1),
|
||||
@@ -414,13 +446,16 @@ mod tests {
|
||||
assert_eq!(color[3], 127.0 / 255.0);
|
||||
}
|
||||
}
|
||||
// One block into the second snap cell: every case below moves the snapped origin, or
|
||||
// another part of the key, so each one rebuilds.
|
||||
let far_cell = SNAP_CELLS as f64 * 12.0 + 1.0;
|
||||
for (x, y, z, range, fast) in [
|
||||
(13.0, 101.0, 1.0, 2, false),
|
||||
(13.0, 101.0, -5.0, 2, false),
|
||||
(13.0, 192.33, -5.0, 2, false),
|
||||
(13.0, 196.34, -5.0, 2, false),
|
||||
(13.0, 196.34, -5.0, 3, false),
|
||||
(13.0, 196.34, -5.0, 3, true),
|
||||
(far_cell, 101.0, 1.0, 2, false),
|
||||
(far_cell, 101.0, -5.0, 2, false),
|
||||
(far_cell, 192.33, -5.0, 2, false),
|
||||
(far_cell, 196.34, -5.0, 2, false),
|
||||
(far_cell, 196.34, -5.0, 3, false),
|
||||
(far_cell, 196.34, -5.0, 3, true),
|
||||
] {
|
||||
input.camera = glam::DVec3::new(x, y, z);
|
||||
input.range = range;
|
||||
@@ -434,8 +469,14 @@ mod tests {
|
||||
);
|
||||
assert!(!mesh.prepare(&input).0);
|
||||
}
|
||||
// Drift alone crosses a cell, even with a stationary camera.
|
||||
input.time += 400;
|
||||
// Drift alone crosses the snapped origin, even with a stationary camera.
|
||||
input.camera = glam::DVec3::new(1.0, 101.0, 1.0);
|
||||
input.time = 0;
|
||||
input.partial_tick = 0.0;
|
||||
assert!(mesh.prepare(&input).0);
|
||||
assert!(!mesh.prepare(&input).0);
|
||||
// 400 ticks of drift move the clouds 12 blocks: one whole snap cell.
|
||||
input.time = SNAP_CELLS * 400;
|
||||
assert!(mesh.prepare(&input).0);
|
||||
// Resource reload constructs a new renderer and cache, including empty packs.
|
||||
let mut reloaded = Mesh::default();
|
||||
@@ -446,6 +487,80 @@ mod tests {
|
||||
assert!(!reloaded.prepare(&input).0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snap_cell_of_motion_keeps_the_mesh_at_the_same_world_cells() {
|
||||
let texture = (8, 8, (0..64).map(|i| i % 3 != 0).collect());
|
||||
let mut input = Input {
|
||||
texture: &texture,
|
||||
uv: [0.5; 2],
|
||||
camera: glam::DVec3::new(1.0, 100.0, 1.0),
|
||||
time: 0,
|
||||
partial_tick: 0.0,
|
||||
range: 2,
|
||||
fast: false,
|
||||
color: [1.0; 4],
|
||||
};
|
||||
// One snap cell, in blocks.
|
||||
let cell = SNAP_CELLS as f64 * 12.0;
|
||||
let mut mesh = Mesh::default();
|
||||
let (rebuilt, uniform) = mesh.prepare(&input);
|
||||
assert!(rebuilt);
|
||||
// The shader draws a vertex at `camera + position + offset`, so the world cells the
|
||||
// cloud covers are `camera` plus this: the same ones anywhere inside a snap cell.
|
||||
assert_eq!(input.camera.x as f32 + uniform.offset[0], 0.0);
|
||||
for x in [1.5, cell * 0.15, cell * 0.5, cell * 0.75, cell - 1.5] {
|
||||
input.camera.x = x;
|
||||
let (rebuilt, uniform) = mesh.prepare(&input);
|
||||
assert!(!rebuilt, "rebuilt at {x}");
|
||||
assert_eq!(input.camera.x as f32 + uniform.offset[0], 0.0);
|
||||
// The trailing origin still covers the whole range past the camera.
|
||||
let far = mesh
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|v| v.pos[0] + uniform.offset[0])
|
||||
.fold(f32::MIN, f32::max);
|
||||
assert!(far >= 32.0, "clouds end {far} blocks ahead at {x}");
|
||||
}
|
||||
// The next snap cell takes the origin with it.
|
||||
input.camera.x = cell;
|
||||
let (rebuilt, uniform) = mesh.prepare(&input);
|
||||
assert!(rebuilt);
|
||||
assert_eq!(input.camera.x as f32 + uniform.offset[0], cell as f32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_inside_the_layer_does_the_mesh_follow_the_camera_between_snap_cells() {
|
||||
let texture = (8, 8, (0..64).map(|i| i % 3 != 0).collect());
|
||||
let mut input = Input {
|
||||
texture: &texture,
|
||||
uv: [0.5; 2],
|
||||
camera: glam::DVec3::new(1.0, 101.0, 1.0),
|
||||
time: 0,
|
||||
partial_tick: 0.0,
|
||||
range: 2,
|
||||
fast: false,
|
||||
color: [1.0; 4],
|
||||
};
|
||||
let mut mesh = Mesh::default();
|
||||
assert!(mesh.prepare(&input).0);
|
||||
// Below the layer, cells crossed inside one snap cell change nothing visible.
|
||||
for (x, z) in [(11.9, 11.9), (24.0, 12.0), (35.5, 23.9)] {
|
||||
input.camera.x = x;
|
||||
input.camera.z = z;
|
||||
assert!(
|
||||
!mesh.prepare(&input).0,
|
||||
"rebuilt at {x},{z} below the layer"
|
||||
);
|
||||
}
|
||||
// Inside it, the inward faces around the camera do move with its own cell.
|
||||
input.camera.y = 194.0;
|
||||
assert!(mesh.prepare(&input).0);
|
||||
input.camera.x = 12.0;
|
||||
assert!(mesh.prepare(&input).0);
|
||||
input.camera.y = 101.0;
|
||||
assert!(mesh.prepare(&input).0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_and_packed_alpha_gate_clouds() {
|
||||
for mode in ["false", "fast", "true"] {
|
||||
|
||||
@@ -423,49 +423,57 @@ impl SceneCollisions<'_, '_> {
|
||||
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
|
||||
return false;
|
||||
}
|
||||
let mut column: Option<((i32, i32), Option<&crate::chunk::Chunk>)> = None;
|
||||
let mut section: Option<((i32, i32), i32, bool)> = None;
|
||||
for z in lo.z..=hi.z {
|
||||
for y in lo.y..=hi.y {
|
||||
for x in lo.x..=hi.x {
|
||||
let outer = [
|
||||
x == lo.x || x == hi.x,
|
||||
y == lo.y || y == hi.y,
|
||||
z == lo.z || z == hi.z,
|
||||
];
|
||||
let kind = outer.into_iter().filter(|&o| o).count();
|
||||
if kind == 3 {
|
||||
// A column and section at a time. Most boxes lie in a few sections whose palette says that no
|
||||
// block in them can matter (nothing that collides, nothing that reaches out of its cell), and
|
||||
// then none of their cells is read: a particle's box at speed spans dozens of cells.
|
||||
for cz in (lo.z >> 4)..=(hi.z >> 4) {
|
||||
for cx in (lo.x >> 4)..=(hi.x >> 4) {
|
||||
// The scan passes the cells of a column that is not resident.
|
||||
let Some(chunk) = self.world.chunks.get(&(cx, cz)).map(|c| &**c) else {
|
||||
continue;
|
||||
};
|
||||
let (x0, x1) = (lo.x.max(cx << 4), hi.x.min((cx << 4) + 15));
|
||||
let (z0, z1) = (lo.z.max(cz << 4), hi.z.min((cz << 4) + 15));
|
||||
for sy in (lo.y >> 4)..=(hi.y >> 4) {
|
||||
let reaches = self.section_reaches_out(chunk, sy);
|
||||
if !reaches && self.section_blocks_all_empty(chunk, sy) {
|
||||
continue;
|
||||
}
|
||||
let at = (x >> 4, z >> 4);
|
||||
let chunk = match column {
|
||||
Some((c, chunk)) if c == at => chunk,
|
||||
_ => {
|
||||
let chunk = self.world.chunks.get(&at).map(|c| &**c);
|
||||
column = Some((at, chunk));
|
||||
chunk
|
||||
}
|
||||
};
|
||||
// The scan passes the cells of a column that is not resident.
|
||||
let Some(chunk) = chunk else {
|
||||
continue;
|
||||
};
|
||||
if kind != 0 {
|
||||
let reaches = match section {
|
||||
Some((c, sy, reaches)) if c == at && sy == y >> 4 => reaches,
|
||||
_ => {
|
||||
let reaches = self.section_reaches_out(chunk, y >> 4);
|
||||
section = Some((at, y >> 4, reaches));
|
||||
reaches
|
||||
let (y0, y1) = (lo.y.max(sy << 4), hi.y.min((sy << 4) + 15));
|
||||
if !reaches {
|
||||
// Nothing in the section reaches out of its cell: only the inner cells matter.
|
||||
for z in z0.max(lo.z + 1)..=z1.min(hi.z - 1) {
|
||||
for y in y0.max(lo.y + 1)..=y1.min(hi.y - 1) {
|
||||
for x in x0.max(lo.x + 1)..=x1.min(hi.x - 1) {
|
||||
if self.binding.may_collide(chunk.block(x, y, z)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if reaches {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if self.binding.may_collide(chunk.block(x, y, z)) {
|
||||
return false;
|
||||
for z in z0..=z1 {
|
||||
let outer_z = z == lo.z || z == hi.z;
|
||||
for y in y0..=y1 {
|
||||
let outer_y = y == lo.y || y == hi.y;
|
||||
for x in x0..=x1 {
|
||||
let outer = [x == lo.x || x == hi.x, outer_y, outer_z];
|
||||
let kind = outer.into_iter().filter(|&o| o).count();
|
||||
if kind == 3 {
|
||||
continue;
|
||||
}
|
||||
if kind != 0 {
|
||||
if reaches {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if self.binding.may_collide(chunk.block(x, y, z)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -473,6 +481,23 @@ impl SceneCollisions<'_, '_> {
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether every block a section's palette holds has an empty collision shape (air, plants);
|
||||
/// false for a palette of the whole registry or a section outside the column.
|
||||
fn section_blocks_all_empty(&self, chunk: &crate::chunk::Chunk, section_y: i32) -> bool {
|
||||
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
blocks.is_some_and(|blocks| {
|
||||
!blocks.is_global()
|
||||
&& !blocks.maybe_has(|v| {
|
||||
self.binding
|
||||
.collision_block(BlockStateId(v))
|
||||
.map_or(true, |block| !block.empty_shape)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// `may_reach_out`: whether a section's palette holds a block that reaches out of
|
||||
/// its cell; a global palette, or a section outside the column, may.
|
||||
fn section_reaches_out(&self, chunk: &crate::chunk::Chunk, section_y: i32) -> bool {
|
||||
@@ -503,18 +528,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
|
||||
/// From the section's palette: every block it can hold has an empty shape (air, plants).
|
||||
/// A palette of the whole registry, or a section outside the column, is read cell by cell.
|
||||
fn section_is_empty(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
|
||||
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
Ok(blocks.is_some_and(|blocks| {
|
||||
!blocks.is_global()
|
||||
&& !blocks.maybe_has(|v| {
|
||||
self.binding
|
||||
.collision_block(BlockStateId(v))
|
||||
.map_or(true, |block| !block.empty_shape)
|
||||
})
|
||||
}))
|
||||
Ok(self.section_blocks_all_empty(chunk, section_y))
|
||||
}
|
||||
/// The resident columns do not change while a scan runs, and looking one up changes nothing.
|
||||
fn missing_chunks_stay_missing(&self) -> bool {
|
||||
@@ -999,6 +1013,76 @@ mod shulker_collision_tests {
|
||||
mod section_tests {
|
||||
use super::*;
|
||||
|
||||
impl SceneCollisions<'_, '_> {
|
||||
/// `cells_without_collision` as it was before it skipped sections: every cell of the box, one at a time.
|
||||
fn cells_without_collision_by_cell(&self, bounds: Aabb) -> bool {
|
||||
let (min, max) = (bounds.min_position(), bounds.max_position());
|
||||
if !min.is_finite() || !max.is_finite() {
|
||||
return false;
|
||||
}
|
||||
// The scan's cells: one past the box on every side (`block_collision_scan::cells`).
|
||||
let lo =
|
||||
BlockPos::containing(Vec3::new(min.x - 1.0e-7, min.y - 1.0e-7, min.z - 1.0e-7))
|
||||
.offset(-1, -1, -1);
|
||||
let hi =
|
||||
BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
|
||||
.offset(1, 1, 1);
|
||||
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
|
||||
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
|
||||
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
|
||||
return false;
|
||||
}
|
||||
let mut column: Option<((i32, i32), Option<&crate::chunk::Chunk>)> = None;
|
||||
let mut section: Option<((i32, i32), i32, bool)> = None;
|
||||
for z in lo.z..=hi.z {
|
||||
for y in lo.y..=hi.y {
|
||||
for x in lo.x..=hi.x {
|
||||
let outer = [
|
||||
x == lo.x || x == hi.x,
|
||||
y == lo.y || y == hi.y,
|
||||
z == lo.z || z == hi.z,
|
||||
];
|
||||
let kind = outer.into_iter().filter(|&o| o).count();
|
||||
if kind == 3 {
|
||||
continue;
|
||||
}
|
||||
let at = (x >> 4, z >> 4);
|
||||
let chunk = match column {
|
||||
Some((c, chunk)) if c == at => chunk,
|
||||
_ => {
|
||||
let chunk = self.world.chunks.get(&at).map(|c| &**c);
|
||||
column = Some((at, chunk));
|
||||
chunk
|
||||
}
|
||||
};
|
||||
// The scan passes the cells of a column that is not resident.
|
||||
let Some(chunk) = chunk else {
|
||||
continue;
|
||||
};
|
||||
if kind != 0 {
|
||||
let reaches = match section {
|
||||
Some((c, sy, reaches)) if c == at && sy == y >> 4 => reaches,
|
||||
_ => {
|
||||
let reaches = self.section_reaches_out(chunk, y >> 4);
|
||||
section = Some((at, y >> 4, reaches));
|
||||
reaches
|
||||
}
|
||||
};
|
||||
if reaches {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if self.binding.may_collide(chunk.block(x, y, z)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Boxes swept through air, past torches (no collision shape), a fence (a shape
|
||||
/// reaching into the cell above) and the scene's stone: wherever the cells say no
|
||||
/// block can collide, the full resolution returns the movement, to the bit; the rest
|
||||
@@ -1056,6 +1140,11 @@ mod section_tests {
|
||||
(next() - 0.5) * reach,
|
||||
);
|
||||
let fast = scene.cells_without_collision(bb.expand_towards(d));
|
||||
assert_eq!(
|
||||
fast,
|
||||
scene.cells_without_collision_by_cell(bb.expand_towards(d)),
|
||||
"{bb:?} by {d:?}"
|
||||
);
|
||||
let mut full = SceneCollisions {
|
||||
world: &world,
|
||||
binding: &binding,
|
||||
|
||||
@@ -316,7 +316,10 @@ impl SkullSkin {
|
||||
/// The item slots of the container `name`'s block carries, if any.
|
||||
fn container_size(name: &str) -> Option<usize> {
|
||||
match name {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => Some(27),
|
||||
"minecraft:chest"
|
||||
| "minecraft:trapped_chest"
|
||||
| "minecraft:barrel"
|
||||
| "minecraft:ender_chest" => Some(27),
|
||||
n if n.ends_with("copper_chest") => Some(27),
|
||||
"minecraft:hopper" => Some(HOPPER_SLOTS),
|
||||
"minecraft:dispenser" | "minecraft:dropper" => Some(9),
|
||||
@@ -453,6 +456,14 @@ impl BlockEntity {
|
||||
if name == "minecraft:daylight_detector" {
|
||||
return Some(Self::DaylightDetector);
|
||||
}
|
||||
if name == "minecraft:ender_chest" {
|
||||
// EnderChestBlock.newBlockEntity: 27 slots, player-specific inventory.
|
||||
return Some(Self::Chest(vec![None; 27]));
|
||||
}
|
||||
if name == "minecraft:end_portal" {
|
||||
// EndPortalBlock.newBlockEntity: no data, only identity.
|
||||
return Some(Self::EndPortal);
|
||||
}
|
||||
if name.ends_with("copper_golem_statue") {
|
||||
return Some(Self::CopperGolemStatue(Default::default()));
|
||||
}
|
||||
@@ -547,6 +558,7 @@ impl BlockEntity {
|
||||
Self::DaylightDetector => name == "minecraft:daylight_detector",
|
||||
Self::Bell => name == "minecraft:bell",
|
||||
Self::Conduit(_) => name == "minecraft:conduit",
|
||||
Self::EndPortal => name == "minecraft:end_portal",
|
||||
Self::EnchantingTable => name == "minecraft:enchanting_table",
|
||||
Self::StructureBlock(_) => name == "minecraft:structure_block",
|
||||
Self::TestBlock(_) => name == "minecraft:test_block",
|
||||
@@ -588,6 +600,7 @@ impl BlockEntity {
|
||||
Self::DaylightDetector => "minecraft:daylight_detector",
|
||||
Self::Bell => "minecraft:bell",
|
||||
Self::Conduit(_) => "minecraft:conduit",
|
||||
Self::EndPortal => "minecraft:end_portal",
|
||||
Self::EnchantingTable => "minecraft:enchanting_table",
|
||||
Self::Brushable(_) => "minecraft:brushable_block",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
@@ -625,6 +638,7 @@ impl BlockEntity {
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Conduit(_)
|
||||
| Self::EndPortal
|
||||
| Self::EnchantingTable
|
||||
| Self::Brushable(_) => Vec::new(),
|
||||
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
|
||||
@@ -658,6 +672,7 @@ impl BlockEntity {
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Conduit(_)
|
||||
| Self::EndPortal
|
||||
| Self::EnchantingTable
|
||||
| Self::Brushable(_) => &[],
|
||||
Self::Jukebox(j) => &j.items,
|
||||
@@ -696,6 +711,7 @@ impl BlockEntity {
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Conduit(_)
|
||||
| Self::EndPortal
|
||||
| Self::EnchantingTable
|
||||
| Self::Brushable(_) => &mut [],
|
||||
Self::Jukebox(j) => &mut j.items,
|
||||
@@ -949,4 +965,34 @@ mod tests {
|
||||
assert!((250..450).contains(&paid), "{paid}");
|
||||
assert!((0..100).all(|_| (1..=2).contains(&create_experience(1, 1.7, &mut random))));
|
||||
}
|
||||
|
||||
/// An ender chest placed with /setblock gets its block entity (`EnderChestBlock.newBlockEntity`),
|
||||
/// so `/data get block` answers with the entity and the chunk saves it.
|
||||
#[test]
|
||||
fn an_ender_chest_starts_as_a_block_entity() {
|
||||
let entity = BlockEntity::for_block("minecraft:ender_chest").expect("an ender chest");
|
||||
// `Chest` variant with 27 slots; `id()` reports the chest kind, block name comes from NBT.
|
||||
assert_eq!(entity.id(), "minecraft:chest");
|
||||
assert_eq!(entity.slots().len(), 27);
|
||||
assert!(entity.slots()[0].is_none());
|
||||
assert!(matches!(entity, BlockEntity::Chest(_)));
|
||||
// `fits`: an ender chest entity fits an ender chest block.
|
||||
assert!(entity.fits("minecraft:ender_chest"));
|
||||
// A stone block is not an ender chest.
|
||||
assert!(BlockEntity::for_block("minecraft:stone").is_none());
|
||||
}
|
||||
|
||||
/// An end portal placed with /setblock gets its block entity (`EndPortalBlock.newBlockEntity`),
|
||||
/// so `/data get block` answers with the entity and the chunk saves it.
|
||||
#[test]
|
||||
fn an_end_portal_starts_as_a_block_entity() {
|
||||
let entity = BlockEntity::for_block("minecraft:end_portal").expect("an end portal");
|
||||
assert_eq!(entity.id(), "minecraft:end_portal");
|
||||
assert_eq!(entity.slots().len(), 0);
|
||||
assert!(matches!(entity, BlockEntity::EndPortal));
|
||||
// `fits`: an end portal entity fits an end portal block.
|
||||
assert!(entity.fits("minecraft:end_portal"));
|
||||
// A stone block is not an end portal.
|
||||
assert!(BlockEntity::for_block("minecraft:stone").is_none());
|
||||
}
|
||||
}
|
||||
@@ -479,6 +479,8 @@ impl Screen for ControllerScreen {
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
listed: true,
|
||||
heading: None,
|
||||
};
|
||||
rows().into_iter().map(sheet).collect()
|
||||
}
|
||||
@@ -532,8 +534,9 @@ impl Screen for ControllerScreen {
|
||||
true
|
||||
}
|
||||
/// This player's controller settings, bindings included, as the screen found them.
|
||||
fn undo(&mut self, s: &mut Settings, before: &Settings) {
|
||||
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
|
||||
s.controller = before.controller.clone();
|
||||
Vec::new()
|
||||
}
|
||||
/// Undo is there exactly while the controller settings differ from what the screen opened
|
||||
/// with: a binding the game made while the row waited counts, a value stepped back doesn't.
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use chrono::{DateTime, Local};
|
||||
use horizon_telemetry::system::machine_arch;
|
||||
use horizon_system::{command_output, machine_arch};
|
||||
|
||||
/// What the game is doing, set as it goes: graphics adapter, settings, packs, world.
|
||||
static DETAILS: Mutex<Vec<(&'static str, String)>> = Mutex::new(Vec::new());
|
||||
@@ -230,15 +230,6 @@ pub fn release_tag() -> Option<String> {
|
||||
.then(|| folder.to_owned())
|
||||
}
|
||||
|
||||
fn command_output(program: &str, args: &[&str]) -> Option<String> {
|
||||
let out = std::process::Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
||||
(out.status.success() && !text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
fn read(path: &str) -> Option<String> {
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
@@ -269,7 +260,7 @@ fn operating_system() -> String {
|
||||
} else if cfg!(target_os = "macos") {
|
||||
command_output("sw_vers", &["-productVersion"]).map(|v| format!("macOS {v}"))
|
||||
} else if cfg!(windows) {
|
||||
horizon_telemetry::system::windows_version()
|
||||
horizon_system::windows_version()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -284,11 +275,11 @@ fn operating_system() -> String {
|
||||
pub fn cpu() -> String {
|
||||
let threads = std::thread::available_parallelism().map_or(0, |n| n.get());
|
||||
let name = if cfg!(target_os = "linux") {
|
||||
read("/proc/cpuinfo").and_then(|t| horizon_telemetry::system::linux_cpu_name(&t))
|
||||
read("/proc/cpuinfo").and_then(|t| horizon_system::linux_cpu_name(&t))
|
||||
} else if cfg!(target_os = "macos") {
|
||||
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
} else if cfg!(windows) {
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
horizon_system::windows_cpu()
|
||||
} else {
|
||||
std::env::var("PROCESSOR_IDENTIFIER").ok()
|
||||
};
|
||||
@@ -387,6 +378,21 @@ fn thread_name(side: &str) -> String {
|
||||
/// promise to launchers (docs/api/launch.md), so it never changes within a line of that API.
|
||||
pub const SAVED_LINE: &str = "Horizon: crash report saved to ";
|
||||
|
||||
/// What runs on each report before it is saved ([`on_report`]).
|
||||
pub type ReportHook = fn(&Path, &Report);
|
||||
|
||||
static REPORT_HOOKS: std::sync::RwLock<Vec<ReportHook>> = std::sync::RwLock::new(Vec::new());
|
||||
|
||||
/// Runs `hook` with every crash report, before it is saved: the game folder and the report. An
|
||||
/// extension point for what lives above the engine (Horizon's opt-in telemetry keeps the
|
||||
/// report). A hook runs inside the crash handler: it must not panic or block.
|
||||
pub fn on_report(hook: ReportHook) {
|
||||
REPORT_HOOKS
|
||||
.write()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push(hook);
|
||||
}
|
||||
|
||||
/// Writes the report and tells where it is; the log gets the error too.
|
||||
fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
if REPORTED.swap(true, Ordering::SeqCst) {
|
||||
@@ -407,7 +413,13 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
),
|
||||
);
|
||||
crate::logging::flush();
|
||||
crate::telemetry::keep_crash(home, &report);
|
||||
for hook in REPORT_HOOKS
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.iter()
|
||||
{
|
||||
hook(home, &report);
|
||||
}
|
||||
// A player of a shared game folder (`--player-dir`) finds the report beside its own log.
|
||||
match report.save(crate::logging::player_dir_override().unwrap_or(home)) {
|
||||
Ok(path) => {
|
||||
|
||||
@@ -661,6 +661,8 @@ pub struct DataStore {
|
||||
pub predicates: BTreeMap<String, serde_json::Value>,
|
||||
/// `item_modifier` files, for `/item modify`.
|
||||
pub item_modifiers: BTreeMap<String, serde_json::Value>,
|
||||
/// `slot_source` definitions, evaluated against a command's live accessor.
|
||||
pub slot_sources: BTreeMap<String, serde_json::Value>,
|
||||
/// The content tables' raw files from the packs after the built-in one:
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/`, `enchantment/` and `tags/…/` files, by
|
||||
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
|
||||
@@ -720,6 +722,7 @@ impl DataStore {
|
||||
"predicate",
|
||||
"recipe",
|
||||
"recipe_filter",
|
||||
"slot_source",
|
||||
"tags",
|
||||
"trade_set",
|
||||
"villager_trade",
|
||||
@@ -756,6 +759,11 @@ impl DataStore {
|
||||
&& let Ok(v) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
s.item_modifiers.insert(id, v);
|
||||
} else if let Some(id) = resource_id(&f, "slot_source", ".json")
|
||||
&& let Some(bytes) = pack.read(&f)
|
||||
&& let Ok(v) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
s.slot_sources.insert(id, v);
|
||||
} else if !builtin
|
||||
&& let Some(id) = content_id(&f)
|
||||
&& let Some(bytes) = pack.read(&f)
|
||||
|
||||
@@ -432,7 +432,15 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
|
||||
return in.color * (1.0 - fog);
|
||||
}
|
||||
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
if in.overlay == 45u {
|
||||
// A Polytone custom particle drawn additively: the fog over its colour, as vanilla's
|
||||
// particle.fsh, under the additive blend (no alpha cutout: its faint texels add up).
|
||||
let f = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = celestial_color(in);
|
||||
|
||||
@@ -8,7 +8,7 @@ use horizon_world::environment_attributes::{
|
||||
use horizon_world::environment_system::{BiomeBlend, Builder, Conditions, System};
|
||||
use horizon_world::environment_timeline::Timeline;
|
||||
use serde_json::Value as Json;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
@@ -169,6 +169,14 @@ pub struct Frame {
|
||||
pub downfall: f32,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// `g.rain`: the mean of the rain and thunder levels, so plain rain is 0.5 and a
|
||||
/// thunderstorm 1 (observed on the real mod: a pack's `g.rain` colormap row).
|
||||
pub fn global_rain(&self) -> f64 {
|
||||
(f64::from(self.rain) + f64::from(self.thunder)) / 2.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The game side of a pack expression in an attribute value: `g`, `p`, `c`, `o` (the camera's
|
||||
/// position) and `r`, `v` (the value so far) and the pack's `config`.
|
||||
struct FrameHost<'a> {
|
||||
@@ -178,6 +186,21 @@ struct FrameHost<'a> {
|
||||
seed: u64,
|
||||
}
|
||||
|
||||
/// `program`'s value for the frame (a pack's expression outside attribute layers: its `v` is 0).
|
||||
pub(crate) fn eval_frame(
|
||||
program: &horizon_polytone::expr::Program,
|
||||
frame: &Frame,
|
||||
configs: &horizon_polytone::pack::Configs,
|
||||
) -> Option<horizon_polytone::expr::Value> {
|
||||
let mut host = FrameHost {
|
||||
frame,
|
||||
configs,
|
||||
value: 0.0,
|
||||
seed: frame.game_time as u64,
|
||||
};
|
||||
program.eval(&mut host).ok()
|
||||
}
|
||||
|
||||
impl horizon_polytone::expr::Host for FrameHost<'_> {
|
||||
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
|
||||
use horizon_polytone::expr::{Root, Value as V};
|
||||
@@ -197,7 +220,7 @@ impl horizon_polytone::expr::Host for FrameHost<'_> {
|
||||
let on = |root: Root| *target == V::Object(root as u32);
|
||||
let num = |n: f64| Ok(V::Num(n));
|
||||
match name {
|
||||
"rain" if on(Root::Global) => num(f64::from(f.rain)),
|
||||
"rain" if on(Root::Global) => num(f.global_rain()),
|
||||
"time" if on(Root::Global) => num(f.game_time as f64),
|
||||
"dayTime" if on(Root::Global) => num(f.day_time as f64),
|
||||
"skyType" if on(Root::Global) => num(f64::from(f.sky_type)),
|
||||
@@ -710,43 +733,6 @@ impl Resolver {
|
||||
}
|
||||
}
|
||||
|
||||
/// The biome ids a `#tag` (`data/<namespace>/tags/worldgen/biome/<path>.json`) holds, nested
|
||||
/// tags included.
|
||||
fn biome_tag(
|
||||
root: &Path,
|
||||
tag: &str,
|
||||
cache: &mut HashMap<String, HashSet<String>>,
|
||||
) -> HashSet<String> {
|
||||
if let Some(hit) = cache.get(tag) {
|
||||
return hit.clone();
|
||||
}
|
||||
cache.insert(tag.to_owned(), HashSet::new());
|
||||
let (namespace, path) = tag.split_once(':').unwrap_or(("minecraft", tag));
|
||||
let file = root.join(format!("data/{namespace}/tags/worldgen/biome/{path}.json"));
|
||||
let mut out = HashSet::new();
|
||||
if let Ok(json) = horizon_world::json_file::read::<Json>(&file) {
|
||||
for value in json["values"].as_array().into_iter().flatten() {
|
||||
let id = value
|
||||
.as_str()
|
||||
.or_else(|| value["id"].as_str())
|
||||
.unwrap_or_default();
|
||||
match id.strip_prefix('#') {
|
||||
Some(inner) => out.extend(biome_tag(root, inner, cache)),
|
||||
None if !id.is_empty() => {
|
||||
out.insert(if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
});
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
cache.insert(tag.to_owned(), out.clone());
|
||||
out
|
||||
}
|
||||
|
||||
/// The timeline ids a dimension type's `timelines` names: an id, a `#tag` or a list,
|
||||
/// tags resolved through `tags/timeline/` (their entries may be `{"id", "required"}`).
|
||||
fn timeline_ids(root: &Path, json: &Json, out: &mut Vec<String>, depth: u32) {
|
||||
@@ -800,10 +786,14 @@ impl Environment {
|
||||
&root.join(format!("data/minecraft/dimension_type/{dimension}.json")),
|
||||
)?;
|
||||
let id = format!("minecraft:{dimension}");
|
||||
let tags = crate::pack_tags::Tags::new(root);
|
||||
let mine: Vec<_> = packs
|
||||
.dimensions
|
||||
.iter()
|
||||
.filter(|m| m.targets.matches(&id, |_| false))
|
||||
.filter(|m| {
|
||||
m.targets
|
||||
.matches(&id, |tag| tags.contains("dimension_type", tag, &id))
|
||||
})
|
||||
.collect();
|
||||
let frame = Arc::new(RwLock::new(Frame::default()));
|
||||
let resolver = Resolver {
|
||||
@@ -826,7 +816,6 @@ impl Environment {
|
||||
// Without biome data (a bare dimension type), there is no biome layer.
|
||||
let mut biomes = HashMap::new();
|
||||
let mut post = HashMap::new();
|
||||
let tags = std::cell::RefCell::new(HashMap::new());
|
||||
if let Ok(folder) =
|
||||
horizon_world::json_folder::read(&root.join("data/minecraft/worldgen/biome"))
|
||||
{
|
||||
@@ -842,8 +831,7 @@ impl Environment {
|
||||
};
|
||||
let mut post_map = AttributeMap::default();
|
||||
for m in &packs.biomes {
|
||||
let in_tag =
|
||||
|tag: &str| biome_tag(root, tag, &mut tags.borrow_mut()).contains(&name);
|
||||
let in_tag = |tag: &str| tags.contains("worldgen/biome", tag, &name);
|
||||
if m.targets.matches(&name, in_tag) {
|
||||
resolver.apply(&mut map, &m.base);
|
||||
resolver.apply(&mut post_map, &m.post_process);
|
||||
@@ -1167,20 +1155,25 @@ mod tests {
|
||||
r#"{"default_value": 7}"#,
|
||||
),
|
||||
]);
|
||||
let at = |rain: f32, chunks: i32| {
|
||||
let at = |rain: f32, thunder: f32, chunks: i32| {
|
||||
env.set_frame(Frame {
|
||||
rain,
|
||||
thunder,
|
||||
render_distance: chunks,
|
||||
..Frame::default()
|
||||
});
|
||||
sky_at(&env, "minecraft:plains", 0.0)
|
||||
};
|
||||
// `c.viewDistance` is in blocks: 16 chunks are 256.
|
||||
assert_eq!(at(0.0, 16).fog_end, 224.0);
|
||||
assert_eq!(at(0.25, 32).fog_end, 498.0);
|
||||
assert_eq!(at(0.0, 0.0, 16).fog_end, 224.0);
|
||||
// `g.rain` is the mean of rain and thunder: half rain is 0.25 (448 + 50), plain rain
|
||||
// 0.5 and a thunderstorm 1, both past the `min` (224 + 100).
|
||||
assert_eq!(at(0.5, 0.0, 32).fog_end, 498.0);
|
||||
assert_eq!(at(1.0, 0.0, 16).fog_end, 324.0);
|
||||
assert_eq!(at(1.0, 1.0, 16).fog_end, 324.0);
|
||||
// `v` is the value the layers before gave: the default start 0, and 192.33 for clouds.
|
||||
assert_eq!(at(0.0, 16).fog_start, 7.0);
|
||||
assert_eq!(at(0.0, 16).cloud_height, 192.33 * 0.5);
|
||||
assert_eq!(at(0.0, 0.0, 16).fog_start, 7.0);
|
||||
assert_eq!(at(0.0, 0.0, 16).cloud_height, 192.33 * 0.5);
|
||||
}
|
||||
|
||||
/// A PNG of RGBA pixels in one row.
|
||||
@@ -1215,18 +1208,21 @@ mod tests {
|
||||
),
|
||||
("assets/mypack/polytone/colormaps/sky.png", &png),
|
||||
]);
|
||||
let sky = |rain: f32| {
|
||||
let sky = |rain: f32, thunder: f32| {
|
||||
env.set_frame(Frame {
|
||||
rain,
|
||||
thunder,
|
||||
..Frame::default()
|
||||
});
|
||||
env.sample(&Sample::default()).sky_color
|
||||
};
|
||||
assert_eq!(sky(0.0), [1.0, 0.0, 0.0]);
|
||||
assert_eq!(sky(0.3), [0.0, 1.0, 0.0]);
|
||||
assert_eq!(sky(1.0), [0.0, 0.0, 1.0]);
|
||||
// A transparent pixel keeps the colour the layers before gave (the dimension's #808080).
|
||||
assert_eq!(sky(0.7), [128.0 / 255.0; 3]);
|
||||
// `g.rain` 0, 0.3 (rain 0.6), 1 (a thunderstorm).
|
||||
assert_eq!(sky(0.0, 0.0), [1.0, 0.0, 0.0]);
|
||||
assert_eq!(sky(0.6, 0.0), [0.0, 1.0, 0.0]);
|
||||
assert_eq!(sky(1.0, 1.0), [0.0, 0.0, 1.0]);
|
||||
// A transparent pixel keeps the colour the layers before gave (the dimension's #808080):
|
||||
// `g.rain` 0.7.
|
||||
assert_eq!(sky(1.0, 0.4), [128.0 / 255.0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -107,12 +107,7 @@ impl Screen for ExtrasScreen {
|
||||
}
|
||||
}
|
||||
}
|
||||
vec![Sheet {
|
||||
id: (),
|
||||
name: lang::get(TITLE),
|
||||
about: lang::get(ABOUT),
|
||||
groups,
|
||||
}]
|
||||
vec![Sheet::new((), lang::get(TITLE), lang::get(ABOUT), groups)]
|
||||
}
|
||||
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
|
||||
match row {
|
||||
|
||||
@@ -99,10 +99,11 @@ impl FogState {
|
||||
self.block_light_flicker += (a - b) * c * d * 0.1;
|
||||
self.block_light_flicker *= 0.9;
|
||||
}
|
||||
/// `LocalPlayer.aiStep`: water vision ramps up while the eye is in water.
|
||||
pub fn tick(&mut self, eye_in_water: bool) {
|
||||
/// `LocalPlayer.aiStep`: water vision ramps up while the eye is in water, ten times faster
|
||||
/// for a spectator.
|
||||
pub fn tick(&mut self, eye_in_water: bool, spectator: bool) {
|
||||
self.water_vision_time = if eye_in_water {
|
||||
(self.water_vision_time + 1).min(600)
|
||||
(self.water_vision_time + if spectator { 10 } else { 1 }).min(600)
|
||||
} else {
|
||||
(self.water_vision_time - 10).max(0)
|
||||
};
|
||||
@@ -707,11 +708,11 @@ mod tests {
|
||||
fn water_vision_ramps() {
|
||||
let mut s = FogState::default();
|
||||
for _ in 0..100 {
|
||||
s.tick(true);
|
||||
s.tick(true, false);
|
||||
}
|
||||
assert!((s.water_vision(true) - 0.6).abs() < 1e-6);
|
||||
for _ in 0..500 {
|
||||
s.tick(true);
|
||||
s.tick(true, false);
|
||||
}
|
||||
assert_eq!(s.water_vision(true), 1.0);
|
||||
assert_eq!(s.water_vision(false), 0.0);
|
||||
@@ -726,17 +727,17 @@ mod tests {
|
||||
input.fog_type = FogType::Water;
|
||||
// Just in: vanilla's water vision is 0, so the fog ends at a quarter of 96 blocks.
|
||||
let mut state = FogState::default();
|
||||
state.tick(true);
|
||||
state.tick(true, false);
|
||||
let first = setup_fog(&tables, &state, &input);
|
||||
assert_eq!(first.environmental_end, 24.0);
|
||||
for _ in 0..600 {
|
||||
state.tick(true);
|
||||
state.tick(true, false);
|
||||
}
|
||||
let settled = setup_fog(&tables, &state, &input);
|
||||
assert_eq!(settled.environmental_end, 96.0);
|
||||
input.full_water_vision = true;
|
||||
let mut fresh = FogState::default();
|
||||
fresh.tick(true);
|
||||
fresh.tick(true, false);
|
||||
let clear = setup_fog(&tables, &fresh, &input);
|
||||
assert_eq!(clear.environmental_end, settled.environmental_end);
|
||||
assert_eq!(clear.color, settled.color);
|
||||
@@ -782,4 +783,20 @@ mod tests {
|
||||
assert_eq!(fog.environmental_start, -160.0);
|
||||
assert_eq!(fog.environmental_end, 768.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spectator_sees_clearly_under_water_ten_times_sooner() {
|
||||
let mut swimmer = FogState::default();
|
||||
let mut spectator = FogState::default();
|
||||
for _ in 0..60 {
|
||||
swimmer.tick(true, false);
|
||||
spectator.tick(true, true);
|
||||
}
|
||||
// LocalPlayer.getWaterVision after 60 ticks: 60 and 600 ticks of vision.
|
||||
assert!((swimmer.water_vision(true) - 0.36).abs() < 1e-6);
|
||||
assert_eq!(spectator.water_vision(true), 1.0);
|
||||
// Out of the water both lose it 10 a tick.
|
||||
spectator.tick(false, true);
|
||||
assert_eq!(spectator.water_vision_time, 590);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -20,6 +20,10 @@ const MATERIALS: usize = 16;
|
||||
pub const TERRAIN_MODEL_SOLID_OVERLAY: u32 = 42;
|
||||
pub const TERRAIN_MODEL_CUTOUT_OVERLAY: u32 = 43;
|
||||
pub const TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY: u32 = 44;
|
||||
/// The overlay of a resource pack's Polytone custom particle drawn additively
|
||||
/// (`additive_translucent`): fog tints its colour as vanilla's particle shader does, which the
|
||||
/// other additive geometry does not take.
|
||||
pub const ADDITIVE_PARTICLE_OVERLAY: u32 = 45;
|
||||
/// Native terrain-array UV/payloads must never enter a shared-atlas shader-pack pass.
|
||||
pub fn is_native_terrain(layer: u32) -> bool {
|
||||
matches!(
|
||||
|
||||
@@ -36,7 +36,7 @@ pub(super) struct EntityOutline {
|
||||
|
||||
impl EntityOutline {
|
||||
/// `entity` draws the silhouettes with the entity shader's vertex stage and
|
||||
/// `fs_outline`; `format` is the frame's.
|
||||
/// `fs_outline`; `format` is what it draws onto (`Renderer::overlay_format`).
|
||||
pub(super) fn new(
|
||||
device: &wgpu::Device,
|
||||
entity: (
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
//! start, and the run is drawn indirectly with the count the copy reached.
|
||||
use super::SolidPipeline;
|
||||
use super::arena::GRAIN;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
|
||||
/// `QUADS` for a solid draw; phase 2: its entry in phase 2's draws), base
|
||||
@@ -825,7 +826,7 @@ impl Flat {
|
||||
});
|
||||
self.phase2_group = Some((group, count as u32));
|
||||
}
|
||||
let groups: Vec<(wgpu::BindGroup, u32)> = self
|
||||
let groups: SmallVec<[(wgpu::BindGroup, u32); 8]> = self
|
||||
.segments
|
||||
.iter()
|
||||
.filter_map(|&(source, first, count)| {
|
||||
|
||||
@@ -93,6 +93,8 @@ struct Layered {
|
||||
tonemap: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
keep: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
composite: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
/// Compiled only when a screenshot needs it, for its readback format and HUD mode.
|
||||
screenshot: Option<(wgpu::TextureFormat, bool, bool, wgpu::RenderPipeline)>,
|
||||
/// What `tonemap` and `composite` read, written before they run (`begin`).
|
||||
params: wgpu::Buffer,
|
||||
}
|
||||
@@ -187,8 +189,9 @@ pub struct Hdr {
|
||||
pub ui: f32,
|
||||
/// Which pixels of a light source shine (`Masks`).
|
||||
pub masks: Masks,
|
||||
/// HDR lighting: off while a shader pack's post or material stage runs, the pack's own, and
|
||||
/// with `HORIZON_HDR_LIGHTING=0`, to compare.
|
||||
/// HDR lighting, whether Horizon lifts this frame's world: off under a Horizon shader pack
|
||||
/// that draws, while a `shaders/` pack's post or material stage runs (the pack's own),
|
||||
/// with HDR Lighting (`look`) off, and with `HORIZON_HDR_LIGHTING=0`, to compare.
|
||||
pub lighting: bool,
|
||||
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
|
||||
/// sun included, so the picture is SDR's on the HDR canvas.
|
||||
@@ -328,15 +331,23 @@ impl Hdr {
|
||||
})
|
||||
};
|
||||
let layered = coverage.then(|| {
|
||||
let with = |label, textures: &[u32], params, entry| {
|
||||
let with = |label, textures: &[u32], params, entry, format| {
|
||||
let layout = layout(label, textures, params);
|
||||
let pipeline = pass(label, &layout, entry, format);
|
||||
(layout, pipeline)
|
||||
};
|
||||
let hud = super::hud_coverage::HUD_FORMAT;
|
||||
Layered {
|
||||
tonemap: with("Horizon HDR tone curve", &[1], true, "fs_tonemap"),
|
||||
keep: with("Horizon HDR HUD backdrop", &[0], false, "fs_keep"),
|
||||
composite: with("Horizon HDR composite", &[0, 4, 5], true, "fs_composite"),
|
||||
tonemap: with("Horizon HDR tone curve", &[1], true, "fs_tonemap", format),
|
||||
keep: with("Horizon HDR HUD backdrop", &[0], false, "fs_keep", hud),
|
||||
composite: with(
|
||||
"Horizon HDR composite",
|
||||
&[0, 4, 5],
|
||||
true,
|
||||
"fs_composite",
|
||||
format,
|
||||
),
|
||||
screenshot: None,
|
||||
params: params(),
|
||||
}
|
||||
});
|
||||
@@ -445,12 +456,11 @@ impl Hdr {
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
)
|
||||
});
|
||||
// In the frame's format, so the HUD's pipelines draw on it as on the frame with HDR
|
||||
// off; the last pass clips it at white.
|
||||
// 8-bit, so each HUD draw clips and rounds as on the frame with HDR off.
|
||||
let hud = layered.then(|| {
|
||||
texture(
|
||||
"HUD",
|
||||
self.format,
|
||||
super::hud_coverage::HUD_FORMAT,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
)
|
||||
});
|
||||
@@ -512,17 +522,110 @@ impl Hdr {
|
||||
self.targets.as_ref().unwrap().frame.clone()
|
||||
}
|
||||
|
||||
/// Lets the targets go, a scaled screenshot's: the next `frame` makes them again.
|
||||
pub fn release_targets(&mut self) {
|
||||
self.targets = None;
|
||||
}
|
||||
|
||||
/// The float frame with HUD coverage (`Layered`).
|
||||
pub fn layered(&self) -> bool {
|
||||
self.layered.is_some()
|
||||
}
|
||||
|
||||
/// The layered frame's HUD target, which the HUD and menus draw on instead of the frame
|
||||
/// once `keep_world` made it, and which an ordinary screenshot shows.
|
||||
/// once `keep_world` made it.
|
||||
pub fn hud(&self) -> Option<&wgpu::Texture> {
|
||||
self.targets.as_ref()?.hud.as_ref()
|
||||
}
|
||||
|
||||
/// The layered frame's SDR screenshot, directly in the readback's texture; the icon
|
||||
/// maps only the world. Where Horizon lifted nothing (`lighting` off: HDR Lighting off,
|
||||
/// or under a shader pack) nothing is mapped. Neither the display's targets nor its
|
||||
/// parameters are changed.
|
||||
pub fn sdr_screenshot(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
format: wgpu::TextureFormat,
|
||||
world_only: bool,
|
||||
) -> Option<wgpu::Texture> {
|
||||
let shoulder = self.lighting;
|
||||
let (l, t) = (self.layered.as_mut()?, self.targets.as_ref()?);
|
||||
if l.screenshot
|
||||
.as_ref()
|
||||
.is_none_or(|(f, w, s, _)| (*f, *w, *s) != (format, world_only, shoulder))
|
||||
{
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("HDR SDR screenshot"),
|
||||
source: wgpu::ShaderSource::Wgsl(shader().into()),
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("HDR SDR screenshot"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_sdr_screenshot"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &[
|
||||
("screenshot_world_only", f64::from(u8::from(world_only))),
|
||||
("screenshot_shoulder", f64::from(u8::from(shoulder))),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(format.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
l.screenshot = Some((format, world_only, shoulder, pipeline));
|
||||
}
|
||||
let pipeline = &l.screenshot.as_ref()?.3;
|
||||
let views = [
|
||||
(0, &t.frame),
|
||||
(4, t.coverage.as_ref()?),
|
||||
(5, t.hud.as_ref()?),
|
||||
]
|
||||
.map(|(binding, texture)| (binding, texture.create_view(&Default::default())));
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("HDR SDR screenshot"),
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &views
|
||||
.each_ref()
|
||||
.map(|(binding, view)| wgpu::BindGroupEntry {
|
||||
binding: *binding,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
}),
|
||||
});
|
||||
let copy = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon screenshot copy"),
|
||||
size: t.frame.size(),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
Self::draw(
|
||||
encoder,
|
||||
pipeline,
|
||||
&group,
|
||||
©.create_view(&Default::default()),
|
||||
"HDR SDR screenshot",
|
||||
None,
|
||||
);
|
||||
Some(copy)
|
||||
}
|
||||
|
||||
/// On the layered frame, before `tonemap` and `composite`: what they read, for a frame that
|
||||
/// drew a world (`world`) or not.
|
||||
pub fn begin(&self, queue: &wgpu::Queue, world: bool) {
|
||||
@@ -601,6 +704,20 @@ impl Hdr {
|
||||
)
|
||||
}
|
||||
|
||||
/// The display as a 0.2 pack's `Frame` describes it, with the values the last pass uses:
|
||||
/// the headroom (the peak over paper white, at least 1), then paper white, the peak and
|
||||
/// the black level in nits.
|
||||
pub fn display(&self) -> [f32; 4] {
|
||||
let paper = self.paper_white.max(1.0);
|
||||
let headroom = (self.peak / paper).max(1.0);
|
||||
[
|
||||
headroom,
|
||||
paper,
|
||||
paper * headroom,
|
||||
self.black.clamp(0.0, paper),
|
||||
]
|
||||
}
|
||||
|
||||
/// The peak the HDR metadata gives: while calibration's peak step runs, PQ's own
|
||||
/// 10 000 nits, so a compositor that clips at the declared peak (KWin) lets the test
|
||||
/// patch through as bright as the display goes and the logo under it shows.
|
||||
@@ -1142,8 +1259,8 @@ var<private> params: ReferenceParams;
|
||||
});
|
||||
}
|
||||
|
||||
/// The first texel of `texture` once `encoder` ran: four half floats, or 10-bit PQ codes
|
||||
/// for HDR10.
|
||||
/// The first texel of `texture` once `encoder` ran: four half floats, 10-bit PQ codes
|
||||
/// for HDR10, or 8-bit values over 255 for the HUD's target.
|
||||
pub(super) fn texel(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
@@ -1180,6 +1297,9 @@ var<private> params: ReferenceParams;
|
||||
let pixel = u32::from_le_bytes(bytes[..4].try_into().unwrap());
|
||||
return [0, 10, 20, 30].map(|shift| ((pixel >> shift) & 0x3ff) as f32);
|
||||
}
|
||||
if texture.format() == super::super::hud_coverage::HUD_FORMAT {
|
||||
return std::array::from_fn(|i| f32::from(bytes[i]) / 255.0);
|
||||
}
|
||||
std::array::from_fn(|i| half::f16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]).to_f32())
|
||||
}
|
||||
|
||||
@@ -1412,6 +1532,173 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
|
||||
}
|
||||
|
||||
/// What a 0.2 pack's `Frame` reads of the display: the headroom the tone curve eases
|
||||
/// into, never below 1, and the black Horizon lifts after it, never above paper white.
|
||||
#[test]
|
||||
fn the_display_a_pack_reads_is_the_one_the_curve_maps_to() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
(hdr.paper_white, hdr.peak, hdr.black) = (203.0, 1000.0, 0.05);
|
||||
let [headroom, paper, peak, black] = hdr.display();
|
||||
assert_eq!((headroom, paper, black), (1000.0 / 203.0, 203.0, 0.05));
|
||||
assert!((peak - 1000.0).abs() < 1e-3, "{peak}");
|
||||
// A peak under paper white has no headroom: the display tops out at white.
|
||||
(hdr.peak, hdr.black) = (150.0, 400.0);
|
||||
assert_eq!(hdr.display(), [1.0, 203.0, 203.0, 203.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scaled_screenshot_shoots_at_its_size_and_leaves_the_window_its_own_targets() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
eprintln!("scaled screenshot GPU unavailable");
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let extent = |t: &wgpu::Texture| [t.width(), t.height()];
|
||||
hdr.frame(&device, [4, 2]);
|
||||
// `Renderer::draw_scaled_frame`: the frame at the screenshot's size, its SDR picture
|
||||
// and HDR10 copy at that size too, then the targets let go.
|
||||
assert_eq!(extent(&hdr.frame(&device, [8, 4])), [8, 4]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let sdr = hdr
|
||||
.sdr_screenshot(&device, &mut encoder, F::Bgra8Unorm, false)
|
||||
.unwrap();
|
||||
let pq = hdr.capture(&device, &queue, &mut encoder, true).unwrap();
|
||||
queue.submit([encoder.finish()]);
|
||||
assert_eq!((extent(&sdr), extent(&pq)), ([8, 4], [8, 4]));
|
||||
hdr.release_targets();
|
||||
assert!(hdr.hud().is_none() && hdr.coverage_view().is_none());
|
||||
// The window's next frame makes its own again.
|
||||
assert_eq!(extent(&hdr.frame(&device, [4, 2])), [4, 2]);
|
||||
assert_eq!(extent(hdr.hud().unwrap()), [4, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sdr_screenshots_roll_off_the_world_and_keep_the_hud_as_drawn() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
eprintln!("SDR screenshot GPU unavailable");
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
// These display settings must not scale either the SDR world or the HUD.
|
||||
(hdr.paper_white, hdr.peak, hdr.black, hdr.ui) = (300.0, 1500.0, 0.2, 0.25);
|
||||
let shoot = |hdr: &mut Hdr, world: [f64; 3], hud: [f64; 3], c: f64, icon| {
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let t = hdr.targets.as_ref().unwrap();
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, [world[0], world[1], world[2], 1.0]);
|
||||
fill(
|
||||
&mut encoder,
|
||||
t.hud.as_ref().unwrap(),
|
||||
[hud[0], hud[1], hud[2], 0.0],
|
||||
);
|
||||
fill(&mut encoder, t.coverage.as_ref().unwrap(), [c; 4]);
|
||||
// Read half-floats so 8-bit quantization cannot conceal the curve or RGB ratios.
|
||||
let copy = hdr
|
||||
.sdr_screenshot(&device, &mut encoder, F::Rgba16Float, icon)
|
||||
.unwrap();
|
||||
texel(&device, &queue, encoder, ©)
|
||||
};
|
||||
// Below the linear knee, through the last half-float below it, the stored
|
||||
// channels survive exactly. All inputs here are exactly representable.
|
||||
for gamma in [0.0f32, 0.25, 0.5, 0.75, 0.79248047] {
|
||||
let w = [gamma, gamma / 2.0, 0.0];
|
||||
let got = shoot(&mut hdr, w.map(f64::from), w.map(f64::from), 0.0, false);
|
||||
assert_eq!(got[..3], w.map(|v| half::f16::from_f32(v).to_f32()));
|
||||
}
|
||||
// Independent numeric checkpoints in linear light, not a CPU copy of the shader.
|
||||
for (light, expected) in [
|
||||
(1.0f32, 0.8),
|
||||
(2.0, 0.9111111),
|
||||
(4.0, 0.9578947),
|
||||
(8.0, 0.9794872),
|
||||
] {
|
||||
let g = f64::from(light.powf(1.0 / 2.2));
|
||||
let got = shoot(&mut hdr, [g; 3], [1.0; 3], 0.0, false);
|
||||
assert!(
|
||||
(got[0].powf(2.2) - expected).abs() < 0.002,
|
||||
"{light}: {got:?}"
|
||||
);
|
||||
assert!(got[0] < 1.0);
|
||||
}
|
||||
let mut last = 0.0;
|
||||
for i in 0..65 {
|
||||
let g = f64::from(i) / 16.0;
|
||||
let got = shoot(
|
||||
&mut hdr,
|
||||
[g, g / 2.0, 0.0],
|
||||
[g.min(1.0), (g / 2.0).min(1.0), 0.0],
|
||||
0.0,
|
||||
false,
|
||||
);
|
||||
assert!(got[0] >= last && got[0] <= 1.0, "{g}: {got:?}");
|
||||
assert!((got[0] - 2.0 * got[1]).abs() < 0.002 && got[2] == 0.0);
|
||||
last = got[0];
|
||||
}
|
||||
// Fully covered is exactly the HUD as its 8-bit target holds it, regardless of the
|
||||
// world or UI Brightness.
|
||||
let hud = [64.0, 128.0, 192.0].map(|v: f64| v / 255.0);
|
||||
assert_eq!(
|
||||
shoot(&mut hdr, [2.0, 1.0, 0.5], hud, 1.0, false),
|
||||
[hud[0], hud[1], hud[2], 1.0].map(|v| half::f16::from_f64(v).to_f32())
|
||||
);
|
||||
// At zero coverage the mapped world; the icon also ignores a fully opaque HUD.
|
||||
for (hud, c, icon) in [([1.0, 1.0, 0.5], 0.0, false), ([0.25; 3], 1.0, true)] {
|
||||
let got = shoot(&mut hdr, [2.0, 1.0, 0.5], hud, c, icon);
|
||||
for (g, expected) in got.into_iter().zip([0.983284, 0.491642, 0.245821, 1.0]) {
|
||||
assert!((g - expected).abs() < 0.002, "{got:?}");
|
||||
}
|
||||
}
|
||||
// R8's 128/255 coverage: recover the HUD against the clipped backdrop, then
|
||||
// composite in gamma. The original HUD target and world's light stay intact.
|
||||
let got = shoot(
|
||||
&mut hdr,
|
||||
[2.0, 1.0, 0.5],
|
||||
[0.75, 0.875, 0.4375],
|
||||
128.0 / 255.0,
|
||||
false,
|
||||
);
|
||||
for (g, expected) in got.into_iter().zip([0.741675, 0.62182, 0.31091, 1.0]) {
|
||||
assert!((g - expected).abs() < 0.002, "{got:?}");
|
||||
}
|
||||
let t = hdr.targets.as_ref().unwrap();
|
||||
let encoder = device.create_command_encoder(&Default::default());
|
||||
assert_eq!(
|
||||
texel(&device, &queue, encoder, &t.frame),
|
||||
[2.0, 1.0, 0.5, 1.0]
|
||||
);
|
||||
// The HUD's 8-bit target rounds what is drawn on it.
|
||||
let encoder = device.create_command_encoder(&Default::default());
|
||||
let stored = [191.0, 223.0, 112.0, 0.0].map(|v| v / 255.0);
|
||||
let hud = t.hud.as_ref().unwrap();
|
||||
assert_eq!(texel(&device, &queue, encoder, hud), stored);
|
||||
// With HDR Lighting off or under a pack, Horizon lifts nothing: the screenshot is the
|
||||
// SDR picture, clipped at white.
|
||||
hdr.lighting = false;
|
||||
for gamma in [0.5f32, 0.9, 1.0] {
|
||||
let w = [gamma, gamma / 2.0, 0.0];
|
||||
// Uncovered, the HUD's target holds the world as drawn; the icon ignores the HUD.
|
||||
let got = shoot(&mut hdr, w.map(f64::from), w.map(f64::from), 0.0, false);
|
||||
assert_eq!(got[..3], w.map(|v| half::f16::from_f32(v).to_f32()));
|
||||
let got = shoot(&mut hdr, w.map(f64::from), [0.25; 3], 1.0, true);
|
||||
assert_eq!(got[..3], w.map(|v| half::f16::from_f32(v).to_f32()));
|
||||
}
|
||||
// Partly covered, the 8-bit screenshot is the HUD's target as drawn.
|
||||
let got = shoot(
|
||||
&mut hdr,
|
||||
[1.0, 0.5, 0.0],
|
||||
[0.75, 0.875, 0.4375],
|
||||
128.0 / 255.0,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
got.map(|v| (v * 255.0).round()),
|
||||
[191.0, 223.0, 112.0, 255.0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hud_lays_over_the_worlds_light_by_its_coverage() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! HUD coverage beside its unchanged colour and alpha, only on the float HDR lane.
|
||||
|
||||
pub(crate) const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm;
|
||||
/// The HUD's own target on the layered frame (`Hdr::hud`): 8-bit, so each HUD draw clips at
|
||||
/// white and rounds as on vanilla's frame. The coverage pipelines are the only ones that draw
|
||||
/// on it.
|
||||
pub(crate) const HUD_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
|
||||
pub(crate) fn supported(
|
||||
attachments: u32,
|
||||
@@ -73,8 +77,9 @@ impl Pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the primary output and all its blend factors, including destination alpha. Wrap the
|
||||
/// final shader result, so pack text's alpha, early returns and discard all apply to coverage.
|
||||
/// Keep the primary output and all its blend factors, including destination alpha, on the
|
||||
/// HUD's target (`HUD_FORMAT`). Wrap the final shader result, so pack text's alpha, early
|
||||
/// returns and discard all apply to coverage.
|
||||
/// `None` for a fragment shader without a colour output at location 0.
|
||||
pub(super) fn pipeline(
|
||||
device: &wgpu::Device,
|
||||
@@ -88,7 +93,8 @@ pub(super) fn pipeline(
|
||||
wgpu::ShaderSource::Naga(m) => m.into_owned(),
|
||||
_ => return None,
|
||||
};
|
||||
let primary = fragment.targets.first()?.clone()?;
|
||||
let mut primary = fragment.targets.first()?.clone()?;
|
||||
primary.format = HUD_FORMAT;
|
||||
let blend = primary.blend.unwrap_or(wgpu::BlendState::REPLACE).color;
|
||||
let additive = matches!(
|
||||
(blend.src_factor, blend.dst_factor),
|
||||
|
||||
@@ -164,16 +164,24 @@ fn fullscreen_pass(
|
||||
}
|
||||
|
||||
/// The tonemap stage's entries as the renderer draws them: the check's, with the result
|
||||
/// encoded into the frame's gamma, where the camera overlays and the HUD draw on it.
|
||||
fn tonemap_entries() -> String {
|
||||
/// encoded into the frame's gamma, where the camera overlays and the HUD draw on it. A pack's
|
||||
/// `[tonemap]` is held to `frame.headroom`. Horizon's own (`default`) is a clip at white in
|
||||
/// SDR; in HDR it leaves the light as it is for Horizon's tone curve (`Hdr::tonemap`), the
|
||||
/// one HDR without a pack uses.
|
||||
fn tonemap_entries(default: bool) -> String {
|
||||
let held = if default {
|
||||
"select(min(horizon_sanitize(out), vec3<f32>(frame.headroom)), horizon_sanitize(out), frame.hdr == 1u)"
|
||||
} else {
|
||||
"min(horizon_sanitize(out), vec3<f32>(frame.headroom))"
|
||||
};
|
||||
StageKind::Tonemap.entries(Api::V0_2).replace(
|
||||
"return vec4<f32>(min(horizon_sanitize(out), vec3<f32>(frame.headroom)), 1.0);",
|
||||
"return vec4<f32>(horizon_encode(min(horizon_sanitize(out), vec3<f32>(frame.headroom))), 1.0);",
|
||||
&format!("return vec4<f32>(horizon_encode({held}), 1.0);"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Horizon's tonemap for a 0.2 pack without `[tonemap]`: SDR's clip at white, which is the
|
||||
/// entries' hold at `frame.headroom` (1 until 0.2 packs get HDR output).
|
||||
/// The tonemap of a 0.2 pack without `[tonemap]`: the picture as it is, held as
|
||||
/// `tonemap_entries` holds Horizon's.
|
||||
const DEFAULT_TONEMAP: &str =
|
||||
"fn tonemap(input: TonemapInput) -> vec3<f32> { return input.color; }\n";
|
||||
|
||||
@@ -350,6 +358,9 @@ pub struct NativeFrame {
|
||||
pub api: Api,
|
||||
pub decoder: Option<Decoder>,
|
||||
mirror: Option<wgpu::TextureView>,
|
||||
/// The HDR display a 0.2 pack's picture goes to this frame (`Hdr::display`: headroom,
|
||||
/// paper white, peak and black level), set before `write`; `None` is SDR.
|
||||
pub display: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
/// A depth texture of `size` the world's depth is copied into.
|
||||
@@ -559,6 +570,7 @@ impl NativeFrame {
|
||||
api,
|
||||
decoder: (api == Api::V0_2).then(|| Decoder::new(device)),
|
||||
mirror: None,
|
||||
display: None,
|
||||
last: None,
|
||||
eye: [f32::NAN; 4],
|
||||
history_reset: true,
|
||||
@@ -799,6 +811,13 @@ impl NativeFrame {
|
||||
if self.api == Api::V0_2 {
|
||||
inputs.sky_color = decoded(inputs.sky_color);
|
||||
inputs.fog_color = decoded(inputs.fog_color);
|
||||
if let Some([headroom, paper_white, peak, black_level]) = self.display {
|
||||
inputs.hdr = 1;
|
||||
inputs.headroom = headroom;
|
||||
inputs.paper_white = paper_white;
|
||||
inputs.peak = peak;
|
||||
inputs.black_level = black_level;
|
||||
}
|
||||
}
|
||||
if !self.reflects {
|
||||
inputs.has_reflection = 0;
|
||||
@@ -1054,7 +1073,7 @@ impl NativePost {
|
||||
let source = tonemap.map_or_else(
|
||||
|| StageKind::Tonemap.prelude(Api::V0_2) + DEFAULT_TONEMAP,
|
||||
|s| s.source.clone(),
|
||||
) + &tonemap_entries();
|
||||
) + &tonemap_entries(tonemap.is_none());
|
||||
let file = tonemap.map_or("<tonemap>", |s| s.file.as_str());
|
||||
let module = naga::front::wgsl::parse_str(&source)
|
||||
.map_err(|e| format!("{file}: {}", e.emit_to_string(&source)))?;
|
||||
@@ -1118,6 +1137,11 @@ impl NativePost {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the pack has a `[tonemap]` of its own, which replaces Horizon's tone curve.
|
||||
pub fn tonemaps(&self) -> bool {
|
||||
self.display.as_ref().is_some_and(|d| d.label.is_some())
|
||||
}
|
||||
|
||||
/// The passes' labels, in order, then the pack's tonemap's.
|
||||
pub fn labels(&self) -> Vec<&str> {
|
||||
let tonemap = self.display.as_ref().and_then(|d| d.label.as_deref());
|
||||
@@ -1557,8 +1581,8 @@ pub fn frame_inputs(
|
||||
let k = super::native_shadow::distortion(s.distance, s.resolution);
|
||||
2.0 * s.distance / s.resolution.max(1) as f32 * (1.0 - k)
|
||||
}),
|
||||
// Shader API 0.2's display fields, which 0.1 packs don't declare: SDR's values, also
|
||||
// with HDR on, until HDR output is served for 0.2 packs.
|
||||
// Shader API 0.2's display fields, which 0.1 packs don't declare: SDR's values, which
|
||||
// `NativeFrame::display` replaces on an HDR display.
|
||||
hdr: 0,
|
||||
headroom: 1.0,
|
||||
paper_white: 80.0,
|
||||
@@ -2484,7 +2508,7 @@ file = \"b.wgsl\"
|
||||
/// an 8-bit step), at alpha 1.
|
||||
#[test]
|
||||
fn a_0_2_world_comes_back_through_decode_tonemap_and_encode() {
|
||||
assert!(tonemap_entries().contains("horizon_encode(min(horizon_sanitize(out)"));
|
||||
assert!(tonemap_entries(false).contains("horizon_encode(min(horizon_sanitize(out)"));
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
@@ -2600,4 +2624,188 @@ file = \"b.wgsl\"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A 0.2 pack's `Frame` describes the HDR display its picture goes to (`display`), and SDR
|
||||
/// without one: `hdr` 0, `headroom` 1, `paper_white` and `peak` 80, `black_level` 0.
|
||||
#[test]
|
||||
fn a_0_2_frame_describes_the_display_it_goes_to() {
|
||||
let Some((device, _)) = device() else {
|
||||
return;
|
||||
};
|
||||
let hud = crate::hud::HudBatch::new([4, 4]);
|
||||
let frame = crate::gfx::pack_uniforms::initial_history_tests::frame(&hud);
|
||||
let mut native = NativeFrame::new(
|
||||
&device,
|
||||
None,
|
||||
Vec::new(),
|
||||
Default::default(),
|
||||
None,
|
||||
Api::V0_2,
|
||||
);
|
||||
let fields = |i: &FrameInputs| (i.hdr, i.headroom, i.paper_white, i.peak, i.black_level);
|
||||
assert_eq!(
|
||||
fields(&native.next_inputs(&frame, [4, 4])),
|
||||
(0, 1.0, 80.0, 80.0, 0.0)
|
||||
);
|
||||
native.display = Some([4.0, 250.0, 1000.0, 0.05]);
|
||||
assert_eq!(
|
||||
fields(&native.next_inputs(&frame, [4, 4])),
|
||||
(1, 4.0, 250.0, 1000.0, 0.05)
|
||||
);
|
||||
// Read every frame: a change of display shows on the next one.
|
||||
native.display = Some([2.0, 300.0, 600.0, 0.0]);
|
||||
assert_eq!(fields(&native.next_inputs(&frame, [4, 4])).1, 2.0);
|
||||
native.display = None;
|
||||
assert_eq!(fields(&native.next_inputs(&frame, [4, 4])).0, 0);
|
||||
}
|
||||
|
||||
/// On the layered HDR frame a 0.2 pack's light is tone mapped once: by Horizon's curve,
|
||||
/// the one HDR without a pack uses, when the pack has no `[tonemap]`, else by the pack's
|
||||
/// alone, held to the headroom. In SDR Horizon's is a clip at white.
|
||||
#[test]
|
||||
fn a_0_2_pack_is_tone_mapped_once_by_its_own_curve_or_horizons() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
// Light 8 times paper white, as the world's float frame stores it, in gamma.
|
||||
let light = 8.0f32;
|
||||
let (paper, peak) = (203.0, 1000.0);
|
||||
let headroom = peak / paper;
|
||||
// The picture shown for that light, in linear light: (a pack's [tonemap], HDR).
|
||||
let shown = |pack_curve: bool, hdr: bool| {
|
||||
let (stage, file, source) = if pack_curve {
|
||||
(
|
||||
"[tonemap]",
|
||||
"t.wgsl",
|
||||
"fn tonemap(input: TonemapInput) -> vec3<f32> { return input.color; }\n",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"[sky]",
|
||||
"s.wgsl",
|
||||
"fn sky(input: SkyInput) -> vec4<f32> { return input.color; }\n",
|
||||
)
|
||||
};
|
||||
let manifest = format!(
|
||||
"schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\n\
|
||||
{stage}\nfile = \"{file}\"\n"
|
||||
);
|
||||
let files = [(MANIFEST, manifest.as_str()), (file, source)]
|
||||
.into_iter()
|
||||
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
|
||||
.collect();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let tonemap = report.stages.iter().find(|s| s.kind == StageKind::Tonemap);
|
||||
let format = wgpu::TextureFormat::Rgba16Float;
|
||||
let frame = NativeFrame::new(
|
||||
&device,
|
||||
None,
|
||||
Vec::new(),
|
||||
Default::default(),
|
||||
None,
|
||||
Api::V0_2,
|
||||
);
|
||||
let inputs = FrameInputs {
|
||||
hdr: u32::from(hdr),
|
||||
headroom: if hdr { headroom } else { 1.0 },
|
||||
..Default::default()
|
||||
};
|
||||
queue.write_buffer(&frame.buffer, 0, bytemuck::bytes_of(&inputs));
|
||||
let mut post = NativePost::new(
|
||||
&device,
|
||||
Vec::new(),
|
||||
&report.manifest,
|
||||
&[],
|
||||
format,
|
||||
&frame,
|
||||
tonemap,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(post.tonemaps(), pack_curve);
|
||||
let mut display = super::super::hdr::Hdr::new(
|
||||
&device,
|
||||
&queue,
|
||||
format,
|
||||
super::super::hdr::Output::Pq,
|
||||
true,
|
||||
);
|
||||
(display.paper_white, display.peak) = (paper, peak);
|
||||
let screen = display.frame(&device, [4, 4]);
|
||||
let usage =
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
let color = texture(&device, format, usage);
|
||||
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage);
|
||||
let [color, depth] = [&color, &depth].map(|t| t.create_view(&Default::default()));
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let g = f64::from(light.powf(1.0 / 2.2));
|
||||
drop(encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &color,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color {
|
||||
r: g,
|
||||
g,
|
||||
b: g,
|
||||
a: 1.0,
|
||||
}),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
}));
|
||||
let view = screen.create_view(&Default::default());
|
||||
let world = (&color, &depth, &frame.surface_view);
|
||||
post.run(&device, &mut encoder, world, &view, [4, 4], true, None);
|
||||
// The renderer's rule, then its curve, as it runs them after the pack's chain.
|
||||
if crate::gfx::horizon_tone_curve(hdr, true, post.tonemaps()) {
|
||||
display.begin(&queue, true);
|
||||
display.tonemap(&mut encoder, None);
|
||||
}
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
screen.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = buffer.get_mapped_range(..).unwrap();
|
||||
half::f16::from_le_bytes([bytes[0], bytes[1]])
|
||||
.to_f32()
|
||||
.powf(2.2)
|
||||
};
|
||||
let near = |got: f32, want: f32| (got - want).abs() <= 0.01 * want;
|
||||
// Horizon's curve once: BT.2390's EETF at 203 and 1000 nits puts light 8 at 4.82
|
||||
// (worked out apart from the shader, as in the HDR tone curve's own test). Clipped at
|
||||
// the headroom first, or mapped twice, it would land lower.
|
||||
let horizon = shown(false, true);
|
||||
assert!(near(horizon, 4.82043), "{horizon}");
|
||||
// The pack's identity curve alone: the light held to the headroom, not eased again.
|
||||
let pack = shown(true, true);
|
||||
assert!(near(pack, headroom), "{pack}");
|
||||
// SDR: a clip at white, the pack's or Horizon's.
|
||||
for pack_curve in [false, true] {
|
||||
let sdr = shown(pack_curve, false);
|
||||
assert!(near(sdr, 1.0), "{pack_curve}: {sdr}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -765,6 +765,8 @@ impl PackAtlas {
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,8 +330,30 @@ fn sample(
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let samplers: Vec<wgpu::BindGroupLayoutEntry> = (program.interface.samplers.iter())
|
||||
.flat_map(|s| {
|
||||
[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: s.binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: s.binding + 1,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
|
||||
count: None,
|
||||
},
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let entries = [
|
||||
vec![binding(0, false)],
|
||||
[vec![binding(0, false)], samplers].concat(),
|
||||
if flat {
|
||||
vec![binding(0, true), binding(1, true)]
|
||||
} else {
|
||||
@@ -367,20 +389,54 @@ fn sample(
|
||||
vec![]
|
||||
},
|
||||
];
|
||||
// The program's samplers (water's boats' patches, `horizonWaterMask`): a 1x1 stand-in each,
|
||||
// as the pack binds when there is none, its sampler beside it.
|
||||
let stand_in = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::R32Float,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
let nearest = device.create_sampler(&Default::default());
|
||||
let samplers = &program.interface.samplers;
|
||||
let groups: Vec<_> = layouts
|
||||
.iter()
|
||||
.zip(bindings)
|
||||
.map(|(layout, buffers)| {
|
||||
.enumerate()
|
||||
.map(|(group, (layout, buffers))| {
|
||||
let mut entries: Vec<_> = buffers
|
||||
.into_iter()
|
||||
.map(|(binding, buffer)| wgpu::BindGroupEntry {
|
||||
binding,
|
||||
resource: buffer.as_entire_binding(),
|
||||
})
|
||||
.collect();
|
||||
if group == 0 {
|
||||
for s in samplers {
|
||||
entries.push(wgpu::BindGroupEntry {
|
||||
binding: s.binding,
|
||||
resource: wgpu::BindingResource::TextureView(&stand_in),
|
||||
});
|
||||
entries.push(wgpu::BindGroupEntry {
|
||||
binding: s.binding + 1,
|
||||
resource: wgpu::BindingResource::Sampler(&nearest),
|
||||
});
|
||||
}
|
||||
}
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout,
|
||||
entries: &buffers
|
||||
.into_iter()
|
||||
.map(|(binding, buffer)| wgpu::BindGroupEntry {
|
||||
binding,
|
||||
resource: buffer.as_entire_binding(),
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
entries: &entries,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -129,6 +129,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
far_depth: false,
|
||||
// Entities, items and block entities read the shared atlas: its own maps (paged_maps.rs).
|
||||
paged_maps: true,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +195,8 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +231,9 @@ pub(super) struct Draw {
|
||||
pub slot: u32,
|
||||
/// End portal or gateway faces: drawn with `end_portal.png` bound as the atlas.
|
||||
pub portal: bool,
|
||||
/// A boat's water patch (`WATER_MASK_OVERLAY`): depth only, with the program's pipeline that
|
||||
/// writes no colour.
|
||||
pub water_mask: bool,
|
||||
}
|
||||
|
||||
/// Which entity runs a pack's shadow pass draws (2.4, 3.2).
|
||||
@@ -455,12 +462,14 @@ impl PackEntities {
|
||||
casts,
|
||||
overlay,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
|
||||
extend_without_portals(rest, &entities[..first.min(entities.len())]);
|
||||
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let mut water_patches = Vec::new();
|
||||
let mut without_fluids = Vec::new();
|
||||
for (span, range, material) in material_runs(entities, spans, drawn) {
|
||||
let run_start = range.start as u32;
|
||||
@@ -490,19 +499,27 @@ impl PackEntities {
|
||||
// gateway: its cube and its beam): each goes to its own program.
|
||||
// Beacon beams come in a run of their own, at stage NONE (game.rs: no stage of 5.4
|
||||
// is theirs), for gbuffers_beaconbeam.
|
||||
let split = (matches!(material, Some(BLOCK | BLOCK_TRANSLUCENT))
|
||||
let split = ((matches!(material, Some(BLOCK | BLOCK_TRANSLUCENT))
|
||||
|| span.stage == render_stage("NONE"))
|
||||
&& whole
|
||||
.chunks(4)
|
||||
.any(|q| matches!(q[0].layer & 0xffff, 6 | 9 | 10));
|
||||
let parts: [(&[Vertex], Part); 3] = if split {
|
||||
.any(|q| matches!(q[0].layer & 0xffff, 6 | 9 | 10)))
|
||||
// A boat's water patch: depth only (`Part::WaterMask`).
|
||||
|| whole
|
||||
.chunks(4)
|
||||
.any(|q| q[0].layer & 0xffff == super::entity_batch::WATER_MASK_OVERLAY);
|
||||
let parts: [(&[Vertex], Part); 4] = if split {
|
||||
other.clear();
|
||||
portal_faces.clear();
|
||||
beam_faces.clear();
|
||||
water_patches.clear();
|
||||
for quad in whole.chunks(4) {
|
||||
match quad[0].layer & 0xffff {
|
||||
9 | 10 => portal_faces.extend_from_slice(quad),
|
||||
6 => beam_faces.extend_from_slice(quad),
|
||||
super::entity_batch::WATER_MASK_OVERLAY => {
|
||||
water_patches.extend_from_slice(quad)
|
||||
}
|
||||
_ => other.extend_from_slice(quad),
|
||||
}
|
||||
}
|
||||
@@ -510,9 +527,15 @@ impl PackEntities {
|
||||
(&other, Part::Plain),
|
||||
(&portal_faces, Part::Portal),
|
||||
(&beam_faces, Part::Beam),
|
||||
(&water_patches, Part::WaterMask),
|
||||
]
|
||||
} else {
|
||||
[(whole, Part::Plain), (&[], Part::Plain), (&[], Part::Plain)]
|
||||
[
|
||||
(whole, Part::Plain),
|
||||
(&[], Part::Plain),
|
||||
(&[], Part::Plain),
|
||||
(&[], Part::Plain),
|
||||
]
|
||||
};
|
||||
for (run, part) in parts {
|
||||
if run.is_empty() {
|
||||
@@ -521,6 +544,8 @@ impl PackEntities {
|
||||
let program = match part {
|
||||
Part::Portal => Some(BLOCK),
|
||||
Part::Beam => Some(BEAM),
|
||||
// Drawn by the entity program, whose depth-only pipeline it takes.
|
||||
Part::WaterMask => material.filter(|&p| p == ENTITY),
|
||||
Part::Plain => material.filter(|&p| {
|
||||
plain(run)
|
||||
|| (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
|
||||
@@ -529,7 +554,8 @@ impl PackEntities {
|
||||
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
|
||||
// Portal faces cast too (a pack's shadow pass sees block entities whole, as Iris
|
||||
// draws them); beams do not.
|
||||
let casts = part != Part::Beam && program.is_some_and(|p| shadow.cast(&span, p));
|
||||
let casts = !matches!(part, Part::Beam | Part::WaterMask)
|
||||
&& program.is_some_and(|p| shadow.cast(&span, p));
|
||||
if !pack && !span.hidden {
|
||||
extend_without_portals(rest, run);
|
||||
}
|
||||
@@ -541,8 +567,13 @@ impl PackEntities {
|
||||
Part::Beam => beam_segments(run, &mut self.scratch),
|
||||
_ => self.scratch.extend_from_slice(run),
|
||||
}
|
||||
// A run copied whole: its vertices keep their order, for `at_velocity`.
|
||||
if !split {
|
||||
// A run copied whole keeps its vertices' order, for `at_velocity`; so does the
|
||||
// start of a split one copied as it is (a boat's hull before its water patch).
|
||||
let in_order = !split
|
||||
|| (part == Part::Plain
|
||||
&& bytemuck::cast_slice::<_, u8>(&whole[..run.len().min(whole.len())])
|
||||
== bytemuck::cast_slice::<_, u8>(run));
|
||||
if in_order {
|
||||
copied.push((run_start, posed + start, run.len() as u32));
|
||||
}
|
||||
if program == DESTROY {
|
||||
@@ -563,6 +594,7 @@ impl PackEntities {
|
||||
casts,
|
||||
overlay,
|
||||
portal,
|
||||
part == Part::WaterMask,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -634,6 +666,7 @@ impl PackEntities {
|
||||
casts: bool,
|
||||
overlay: u32,
|
||||
portal: bool,
|
||||
water_mask: bool,
|
||||
) {
|
||||
let (entity_id, block_id) = match program {
|
||||
BLOCK | BLOCK_TRANSLUCENT => (0, span.id),
|
||||
@@ -658,6 +691,7 @@ impl PackEntities {
|
||||
shadow: casts,
|
||||
slot: self.draws.len() as u32,
|
||||
portal,
|
||||
water_mask,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -692,6 +726,8 @@ enum Part {
|
||||
Plain,
|
||||
Portal,
|
||||
Beam,
|
||||
/// A boat's water patch: depth only (`Draw::water_mask`).
|
||||
WaterMask,
|
||||
}
|
||||
|
||||
/// Beam faces (`block_entity_render::beam`: layer 6, v counting the sprite's repeats up the
|
||||
@@ -1446,6 +1482,79 @@ mod gpu_span_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A boat's water patch (`RenderTypes.waterMask`) is a draw of its own, depth only, that casts
|
||||
/// no shadow; with no pack program for entities, Horizon draws it with the boat.
|
||||
#[test]
|
||||
fn a_boats_water_patch_draws_depth_only() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let layout = super::super::pack_draw::DrawUniforms::new(&device);
|
||||
let mut geometry = PackEntities::new(&device, &layout.layout);
|
||||
let mask = super::super::entity_batch::WATER_MASK_OVERLAY;
|
||||
// One boat: a hull face, then the water patch.
|
||||
let cpu: Vec<Vertex> = [0, 0, 0, 0, mask, mask, mask, mask]
|
||||
.into_iter()
|
||||
.map(|layer| Vertex {
|
||||
layer,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
})
|
||||
.collect();
|
||||
let spans = [EntitySpan {
|
||||
start: 0,
|
||||
id: 7,
|
||||
stage: render_stage("ENTITIES"),
|
||||
..Default::default()
|
||||
}];
|
||||
let mut rest = Vec::new();
|
||||
let shadows = Casters {
|
||||
entities: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut prepare = |drawn, rest: &mut Vec<Vertex>| {
|
||||
geometry.prepare(
|
||||
&device,
|
||||
&queue,
|
||||
&layout.layout,
|
||||
&cpu,
|
||||
&spans,
|
||||
drawn,
|
||||
([0.; 4], [1; 2]),
|
||||
shadows,
|
||||
rest,
|
||||
None,
|
||||
(&[], &[]),
|
||||
);
|
||||
let draws = (geometry.draws.iter())
|
||||
.map(|d| (d.base, d.program, d.pack, d.shadow, d.water_mask))
|
||||
.collect::<Vec<_>>();
|
||||
let heights: Vec<f32> = geometry.scratch.iter().map(|v| v.pos[1]).collect();
|
||||
(draws, heights)
|
||||
};
|
||||
assert_eq!(
|
||||
prepare([true; PROGRAMS], &mut rest),
|
||||
(
|
||||
vec![
|
||||
(0, ENTITY, true, true, false),
|
||||
(4, ENTITY, true, false, true)
|
||||
],
|
||||
// Where they lie: the patch at the water line, not moved.
|
||||
vec![0.0; 8]
|
||||
)
|
||||
);
|
||||
assert!(rest.is_empty());
|
||||
let mut no_entities = [true; PROGRAMS];
|
||||
no_entities[ENTITY] = false;
|
||||
let (draws, _) = prepare(no_entities, &mut rest);
|
||||
assert!(draws.iter().all(|d| !d.2), "{draws:?}");
|
||||
assert_eq!(rest.len(), 8, "the boat and its patch stay Horizon's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posed_and_cpu_draws_keep_offsets_uniforms_and_special_fallbacks() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
|
||||
@@ -53,6 +53,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ void main() {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
body: "vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); hz_Vertex=vec4(p*2.0-1.0,0.0,1.0);".into(),
|
||||
paged_maps: true,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
|
||||
@@ -19,6 +19,7 @@ use std::collections::HashMap;
|
||||
|
||||
use glam::{DMat4, DVec3};
|
||||
use horizon_shaders::{Format, Group, Kind, Post, SHADOW_COLORS};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use super::pack_draw::DrawUniforms;
|
||||
use super::pack_entities::{PROGRAMS, PackEntities};
|
||||
@@ -125,6 +126,8 @@ enum Source {
|
||||
ShadowDepth(usize, bool),
|
||||
/// `shadowcolor0/1` read outside the shadow programs (in them they are `Color`).
|
||||
ShadowColor(usize),
|
||||
/// The boats' water patches' depth (`horizon_shaders::WATER_MASK`), for translucent terrain.
|
||||
WaterMask,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -160,9 +163,11 @@ impl SamplerKind {
|
||||
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy if !filtering => {
|
||||
Self::Nearest
|
||||
}
|
||||
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(_, false) => {
|
||||
Self::Nearest
|
||||
}
|
||||
Source::Depth
|
||||
| Source::Depth1
|
||||
| Source::Depth2
|
||||
| Source::ShadowDepth(_, false)
|
||||
| Source::WaterMask => Self::Nearest,
|
||||
Source::Noise => Self::Repeat,
|
||||
Source::Shadow | Source::ShadowDepth(_, true) => Self::Comparison,
|
||||
// Blocks look as the game draws them: nearest texels. Between levels, Iris 1.11.7
|
||||
@@ -406,6 +411,9 @@ struct Pass {
|
||||
/// The same program as vanilla blends the sky's parts (`PostProgram::vanilla_sky_blends`):
|
||||
/// without blending for the disc and the void, then with `STARS_BLEND` for the stars.
|
||||
sky_parts: Option<[Slot<wgpu::RenderPipeline>; 2]>,
|
||||
/// The entity program without colour writes, for the boats' water patches
|
||||
/// (`RenderTypes.waterMask`: depth only, so no water shows inside a hull).
|
||||
water_mask: Option<Slot<wgpu::RenderPipeline>>,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
ubo: wgpu::Buffer,
|
||||
block: Vec<u8>,
|
||||
@@ -511,6 +519,8 @@ struct Targets {
|
||||
depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
/// `depthtex2`, when a program after the hand reads it without the hand.
|
||||
depth2: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
/// The boats' water patches' depth, when translucent terrain reads it (`Source::WaterMask`).
|
||||
water_mask: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
/// Both sides' mip chains of the buffers with mipmaps (`color` is their first level).
|
||||
mips: [Option<[Mips; 2]>; BUFFERS],
|
||||
/// Where a pass that both samples and stores a buffer samples it from.
|
||||
@@ -648,6 +658,11 @@ pub struct PackPost {
|
||||
depth1: bool,
|
||||
/// A program after the hand reads `depthtex2`: it is copied before the hand.
|
||||
depth2: bool,
|
||||
/// Translucent terrain reads the boats' water patches (`Targets::water_mask`).
|
||||
water_mask: bool,
|
||||
/// The water patches' depth holds patches (or was never cleared): cleared before the next
|
||||
/// frame's are drawn.
|
||||
water_mask_dirty: bool,
|
||||
/// The shadow buffers, when the shadow pass runs (2.5), and the `shadowcomp` programs.
|
||||
shadow_targets: Option<super::pack_shadow::ShadowTargets>,
|
||||
/// What draws terrain from the light (`shadow`), and translucent terrain (`shadow_water`).
|
||||
@@ -907,6 +922,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
|
||||
"lightmap" => Source::Lightmap,
|
||||
"normals" => Source::Normals,
|
||||
"specular" => Source::Specular,
|
||||
horizon_shaders::WATER_MASK => Source::WaterMask,
|
||||
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
|
||||
}
|
||||
}
|
||||
@@ -1647,9 +1663,11 @@ fn bindings(
|
||||
Source::ShadowColor(n) => shadow
|
||||
.and_then(|f| f[n].sample_type(None, Some(features)))
|
||||
.unwrap_or(float),
|
||||
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(..) => {
|
||||
wgpu::TextureSampleType::Float { filterable: false }
|
||||
}
|
||||
Source::Depth
|
||||
| Source::Depth1
|
||||
| Source::Depth2
|
||||
| Source::ShadowDepth(..)
|
||||
| Source::WaterMask => wgpu::TextureSampleType::Float { filterable: false },
|
||||
// Unfilterable on WebGPU-level devices; the lightmap is sampled at texel centres.
|
||||
Source::Lightmap => float,
|
||||
_ => float,
|
||||
@@ -1933,6 +1951,7 @@ fn build_pass(
|
||||
custom: &[(String, String)],
|
||||
draws: &wgpu::BindGroupLayout,
|
||||
pack: &Resources<'_>,
|
||||
water_mask: bool,
|
||||
) -> Option<Pass> {
|
||||
let features = device.features();
|
||||
// The frame's uniforms, then the pack's custom ones.
|
||||
@@ -2147,9 +2166,32 @@ fn build_pass(
|
||||
)
|
||||
})
|
||||
});
|
||||
(layout, pack.render_pipeline(device, job), sky_parts)
|
||||
// The water patches' pipeline: the same program, depth only.
|
||||
let water_mask = water_mask.then(|| {
|
||||
let targets = (job.targets.iter())
|
||||
.map(|t| {
|
||||
t.clone().map(|t| wgpu::ColorTargetState {
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
..t
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
pack.render_pipeline(
|
||||
device,
|
||||
RenderJob {
|
||||
targets,
|
||||
..job.clone()
|
||||
},
|
||||
)
|
||||
});
|
||||
(
|
||||
layout,
|
||||
pack.render_pipeline(device, job),
|
||||
sky_parts,
|
||||
water_mask,
|
||||
)
|
||||
});
|
||||
let (layout, pipeline, sky_parts) = match created {
|
||||
let (layout, pipeline, sky_parts, water_mask) = match created {
|
||||
Ok(created) => created,
|
||||
Err(e) => {
|
||||
notes.push(format!("{}: skipped: {e}", p.name));
|
||||
@@ -2189,6 +2231,7 @@ fn build_pass(
|
||||
group: p.group,
|
||||
pipeline: Pipeline::Render(pipeline),
|
||||
sky_parts,
|
||||
water_mask,
|
||||
layout,
|
||||
samplers,
|
||||
writes,
|
||||
@@ -2328,6 +2371,7 @@ fn build_compute(
|
||||
local,
|
||||
},
|
||||
sky_parts: None,
|
||||
water_mask: None,
|
||||
layout,
|
||||
samplers,
|
||||
writes: Vec::new(),
|
||||
@@ -2610,6 +2654,8 @@ impl PackPost {
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
// The next pass built also gets a depth-only pipeline for the boats' water patches.
|
||||
let water_mask_next = std::cell::Cell::new(false);
|
||||
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
|
||||
// Not wanted any more: the rest is skipped and the build refused below.
|
||||
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
@@ -2660,6 +2706,7 @@ impl PackPost {
|
||||
&custom_names,
|
||||
&draws.layout,
|
||||
&pack,
|
||||
water_mask_next.get(),
|
||||
)?
|
||||
};
|
||||
pass.compute.append(&mut computes);
|
||||
@@ -2835,7 +2882,11 @@ impl PackPost {
|
||||
pass
|
||||
})
|
||||
};
|
||||
let mut kind = 0;
|
||||
let entity_passes = programs.map(|p| {
|
||||
// The entity program draws the boats, and their water patches (`Draw::water_mask`).
|
||||
water_mask_next.set(kind == super::pack_entities::ENTITY);
|
||||
kind += 1;
|
||||
p.zip(entity_geometry.as_ref()).and_then(|(p, g)| {
|
||||
if p.name == "gbuffers_damagedblock" {
|
||||
let decal = Geometry {
|
||||
@@ -2858,6 +2909,7 @@ impl PackPost {
|
||||
if entity_passes[super::pack_entities::PARTICLE_TRANSLUCENT].is_some() {
|
||||
late[super::pack_entities::PARTICLE] = false;
|
||||
}
|
||||
water_mask_next.set(false);
|
||||
let shadow_entities = shadow_programs.map(|p| {
|
||||
p.zip(entity_geometry.as_ref())
|
||||
.and_then(|(p, g)| paged(build(p, Some(g))))
|
||||
@@ -3085,6 +3137,9 @@ impl PackPost {
|
||||
let depth1 = (water.is_some() || hand.is_some())
|
||||
&& (reads_depth(Source::Depth1) || (hand.is_none() && reads_depth2));
|
||||
let depth2 = hand.is_some() && reads_depth2;
|
||||
let water_mask = (water.iter())
|
||||
.flat_map(|p| &p.samplers)
|
||||
.any(|s| s.source == Source::WaterMask);
|
||||
let every_pass = || {
|
||||
(passes
|
||||
.iter()
|
||||
@@ -3340,6 +3395,8 @@ impl PackPost {
|
||||
hand_water,
|
||||
depth1,
|
||||
depth2,
|
||||
water_mask,
|
||||
water_mask_dirty: true,
|
||||
shadow_targets,
|
||||
shadow_terrain,
|
||||
portal_rect: [0.0; 4],
|
||||
@@ -3671,6 +3728,7 @@ impl PackPost {
|
||||
}
|
||||
}
|
||||
Source::Depth => &targets.depth,
|
||||
Source::WaterMask => targets.water_mask.as_ref().map_or(&self.dummy, |m| &m.1),
|
||||
// Before translucent terrain the two are the same.
|
||||
Source::Depth1 => match &targets.depth1 {
|
||||
Some(d)
|
||||
@@ -3864,6 +3922,16 @@ impl PackPost {
|
||||
&mut self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
pass: &Pass,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
self.begin_with(encoder, pass, false)
|
||||
}
|
||||
|
||||
/// `begin`, or with `water_mask` into the water patches' depth instead of the frame's.
|
||||
fn begin_with<'e>(
|
||||
&mut self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
pass: &Pass,
|
||||
water_mask: bool,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
let targets = self.targets.as_ref().expect("resized");
|
||||
let load = wgpu::Operations {
|
||||
@@ -3877,7 +3945,8 @@ impl PackPost {
|
||||
_ => (super::pack_shadow::load(), [load; SHADOW_COLORS]),
|
||||
};
|
||||
let shadow = self.shadow_targets.as_ref().filter(|_| pass.group.shadow());
|
||||
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
|
||||
let mut attachments: SmallVec<[Option<wgpu::RenderPassColorAttachment>; BUFFERS]> =
|
||||
SmallVec::new();
|
||||
for &(location, buffer) in &pass.writes {
|
||||
let l = location as usize;
|
||||
if attachments.len() <= l {
|
||||
@@ -3898,7 +3967,10 @@ impl PackPost {
|
||||
color_attachments: &attachments,
|
||||
timestamp_writes,
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: shadow.map_or(&targets.depth, |s| &s.depth.1),
|
||||
view: match &targets.water_mask {
|
||||
Some((_, view)) if water_mask => view,
|
||||
_ => shadow.map_or(&targets.depth, |s| &s.depth.1),
|
||||
},
|
||||
depth_ops: Some(depth_ops),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
@@ -4081,8 +4153,11 @@ impl PackPost {
|
||||
let mut draws = geometry
|
||||
.draws
|
||||
.iter()
|
||||
.filter(|d| d.pack && d.program == program)
|
||||
.filter(|d| d.pack && d.program == program && !d.water_mask)
|
||||
.peekable();
|
||||
let masks: Vec<_> = (geometry.draws.iter())
|
||||
.filter(|d| d.pack && d.program == program && d.water_mask)
|
||||
.collect();
|
||||
if draws.peek().is_none() {
|
||||
continue;
|
||||
}
|
||||
@@ -4124,6 +4199,35 @@ impl PackPost {
|
||||
rp.draw_indexed(0..d.quads * 6, d.base as i32, 0..1);
|
||||
}
|
||||
}
|
||||
// The boats' water patches, depth only, into their own depth: translucent terrain
|
||||
// discards what lies behind them, and the frame's depth stays the pack's.
|
||||
if let Some(mask) = pass.water_mask.as_ref().and_then(|m| m.get())
|
||||
&& !masks.is_empty()
|
||||
&& self
|
||||
.targets
|
||||
.as_ref()
|
||||
.is_some_and(|t| t.water_mask.is_some())
|
||||
{
|
||||
let mut rp = self.begin_with(encoder, pass, true);
|
||||
rp.set_pipeline(mask);
|
||||
rp.set_bind_group(0, &group, &[]);
|
||||
if pass.buffers
|
||||
&& let Some(g) = &self.buffers.group
|
||||
{
|
||||
rp.set_bind_group(4, g, &[]);
|
||||
}
|
||||
rp.set_bind_group(1, &geometry.table_group, &[]);
|
||||
rp.set_vertex_buffer(0, geometry.vertices());
|
||||
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for d in &masks {
|
||||
if pass.draw {
|
||||
rp.set_bind_group(2, &geometry.group, &[geometry.offset(d.slot)]);
|
||||
}
|
||||
rp.draw_indexed(0..d.quads * 6, d.base as i32, 0..1);
|
||||
}
|
||||
drop(rp);
|
||||
self.water_mask_dirty = true;
|
||||
}
|
||||
self.mips_valid &= !pass.written;
|
||||
}
|
||||
self.entities = Some(entities);
|
||||
@@ -4905,6 +5009,16 @@ impl PackPost {
|
||||
let view = t.create_view(&Default::default());
|
||||
(t, view)
|
||||
});
|
||||
let water_mask = self.water_mask.then(|| {
|
||||
let t = texture(
|
||||
"Shader pack water patches",
|
||||
depth32,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
);
|
||||
let view = t.create_view(&Default::default());
|
||||
(t, view)
|
||||
});
|
||||
self.water_mask_dirty = true;
|
||||
let scratch = std::array::from_fn(|i| {
|
||||
(self.copied & 1 << i != 0).then(|| {
|
||||
// A copy also read as an image before a dispatch.
|
||||
@@ -4939,6 +5053,7 @@ impl PackPost {
|
||||
depth_texture,
|
||||
depth1,
|
||||
depth2,
|
||||
water_mask,
|
||||
mips,
|
||||
});
|
||||
self.forget_groups();
|
||||
@@ -5038,7 +5153,8 @@ impl PackPost {
|
||||
}
|
||||
let group = self.bind(device, queue, encoder, pass, size);
|
||||
let t = self.shadow_targets.as_ref().expect("shadow pass");
|
||||
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
|
||||
let mut attachments: SmallVec<[Option<wgpu::RenderPassColorAttachment>; BUFFERS]> =
|
||||
SmallVec::new();
|
||||
for &(location, buffer) in &pass.writes {
|
||||
let l = location as usize;
|
||||
if attachments.len() <= l {
|
||||
@@ -5067,6 +5183,7 @@ impl PackPost {
|
||||
}
|
||||
rp.draw(0..6, 0..1);
|
||||
}
|
||||
drop(attachments);
|
||||
if let Some(t) = &mut self.shadow_targets {
|
||||
t.main ^= pass.flip;
|
||||
}
|
||||
@@ -5193,6 +5310,24 @@ impl PackPost {
|
||||
terrain: &mut TerrainDraw<'_>,
|
||||
) {
|
||||
self.resize(device, size);
|
||||
// Last frame's water patches (or a new texture's zeros) out: depth 1 is no patch.
|
||||
if self.water_mask_dirty
|
||||
&& let Some((_, view)) = self.targets.as_ref().and_then(|t| t.water_mask.as_ref())
|
||||
{
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack water patches clear"),
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(1.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
self.water_mask_dirty = false;
|
||||
}
|
||||
// A resolution scale leaves the picture smaller (or larger) than the window: the pack's
|
||||
// `final` draws it at the picture's size, and one copy takes it to the window (#9291).
|
||||
let final_view = if size != window && self.passes.iter().any(|p| p.group == Group::Final) {
|
||||
@@ -5540,7 +5675,8 @@ impl PackPost {
|
||||
.as_ref()
|
||||
.map(|t| &t[usize::from(self.main & 1 << i == 0)])
|
||||
};
|
||||
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
|
||||
let mut attachments: SmallVec<[Option<wgpu::RenderPassColorAttachment>; BUFFERS]> =
|
||||
SmallVec::new();
|
||||
// Vertex coverage and fragment discard can leave alternate pixels untouched.
|
||||
// The unused sink can still skip loading its old contents.
|
||||
let ops = if !pass.sink || pass.viewport.is_some() {
|
||||
@@ -6212,6 +6348,7 @@ mod tests {
|
||||
&[],
|
||||
&draws.layout,
|
||||
&pack,
|
||||
false,
|
||||
)
|
||||
.unwrap_or_else(|| panic!("the pass builds: {notes:?}"));
|
||||
// colortex3 holds (0.75, 0.5, 0.25, 0.5) before the pass.
|
||||
|
||||
@@ -322,7 +322,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
|
||||
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
|
||||
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
|
||||
let inputs=horizon_shaders::GameInputs {
|
||||
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false,paged_maps:false }),
|
||||
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false,paged_maps:false,water_mask:false,water_patches:false }),
|
||||
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
|
||||
};
|
||||
let mut post = gpu.load(
|
||||
@@ -720,6 +720,8 @@ fn shadow_depth_map_reads(
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -47,6 +47,8 @@ fn pack() -> Post {
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
|
||||
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
|
||||
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! uniform (set 1) that the vertex inputs read. `gbuffers_skytextured` draws the frame's sun and
|
||||
//! moon quads, textured from the shared atlas.
|
||||
|
||||
use std::ops::Range;
|
||||
use std::{ops::Range, sync::Mutex};
|
||||
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
@@ -105,6 +105,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
item_id_declared: false,
|
||||
far_depth: true,
|
||||
paged_maps: false,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +135,8 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
item_id_declared: false,
|
||||
far_depth: true,
|
||||
paged_maps: false,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +227,7 @@ pub(super) struct PackSky {
|
||||
uniform: wgpu::Buffer,
|
||||
pub layout: wgpu::BindGroupLayout,
|
||||
group: wgpu::BindGroup,
|
||||
celestial_scratch: Mutex<Vec<Celestial>>,
|
||||
/// Vertex ranges of the sky disc, the sunset fan, the stars, the void and the End's fan.
|
||||
ranges: [Range<u32>; 5],
|
||||
}
|
||||
@@ -302,6 +307,7 @@ impl PackSky {
|
||||
uniform,
|
||||
layout,
|
||||
group,
|
||||
celestial_scratch: Mutex::new(Vec::new()),
|
||||
ranges: [sky, sunset, stars, void, end_fan],
|
||||
}
|
||||
}
|
||||
@@ -359,15 +365,21 @@ impl PackSky {
|
||||
.0
|
||||
.iter()
|
||||
.take(CELESTIAL_QUADS);
|
||||
let triangles: Vec<Celestial> = quads
|
||||
.clone()
|
||||
.flat_map(|q| [0, 1, 2, 0, 2, 3].map(|i| q[i]))
|
||||
.map(|v| Celestial {
|
||||
pos: turn.transform_vector3(v.pos.into()).into(),
|
||||
uv: v.uv,
|
||||
color: v.color,
|
||||
})
|
||||
.collect();
|
||||
let mut triangles = self
|
||||
.celestial_scratch
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
triangles.clear();
|
||||
triangles.extend(
|
||||
quads
|
||||
.clone()
|
||||
.flat_map(|q| [0, 1, 2, 0, 2, 3].map(|i| q[i]))
|
||||
.map(|v| Celestial {
|
||||
pos: turn.transform_vector3(v.pos.into()).into(),
|
||||
uv: v.uv,
|
||||
color: v.color,
|
||||
}),
|
||||
);
|
||||
queue.write_buffer(&self.celestial, 0, bytemuck::cast_slice(&triangles));
|
||||
let [sky, sunset, stars, void, fan] = self.ranges.clone();
|
||||
let part = |stage, textured, range| Part {
|
||||
|
||||
@@ -374,6 +374,41 @@ pub const NAMES: &[&str] = &[
|
||||
"horizonDynamicLight13",
|
||||
"horizonDynamicLight14",
|
||||
"horizonDynamicLight15",
|
||||
// Horizon's own: the boats' water patches, for the shadow pass's water (`water_patches`):
|
||||
// how many, then each one's corner and y range, and its two horizontal edges.
|
||||
"horizonWaterPatchCount",
|
||||
"horizonWaterPatch0",
|
||||
"horizonWaterPatch1",
|
||||
"horizonWaterPatch2",
|
||||
"horizonWaterPatch3",
|
||||
"horizonWaterPatch4",
|
||||
"horizonWaterPatch5",
|
||||
"horizonWaterPatch6",
|
||||
"horizonWaterPatch7",
|
||||
"horizonWaterPatch8",
|
||||
"horizonWaterPatch9",
|
||||
"horizonWaterPatch10",
|
||||
"horizonWaterPatch11",
|
||||
"horizonWaterPatch12",
|
||||
"horizonWaterPatch13",
|
||||
"horizonWaterPatch14",
|
||||
"horizonWaterPatch15",
|
||||
"horizonWaterPatch16",
|
||||
"horizonWaterPatch17",
|
||||
"horizonWaterPatch18",
|
||||
"horizonWaterPatch19",
|
||||
"horizonWaterPatch20",
|
||||
"horizonWaterPatch21",
|
||||
"horizonWaterPatch22",
|
||||
"horizonWaterPatch23",
|
||||
"horizonWaterPatch24",
|
||||
"horizonWaterPatch25",
|
||||
"horizonWaterPatch26",
|
||||
"horizonWaterPatch27",
|
||||
"horizonWaterPatch28",
|
||||
"horizonWaterPatch29",
|
||||
"horizonWaterPatch30",
|
||||
"horizonWaterPatch31",
|
||||
"isElytraFlying",
|
||||
"cloudHeight",
|
||||
];
|
||||
@@ -870,6 +905,7 @@ impl FrameUniforms {
|
||||
),
|
||||
};
|
||||
|
||||
let patches = water_patches(frame.entities);
|
||||
let value = |name: &str| -> Value {
|
||||
let v3 = |v: Vec3| Value::f(&v.to_array());
|
||||
let m4 = |m: Mat4| Value::f(&m.to_cols_array());
|
||||
@@ -960,6 +996,11 @@ impl FrameUniforms {
|
||||
"gbufferProjectionInverse" => m4(projection_inverse),
|
||||
"gbufferPreviousModelView" => m4(previous_view),
|
||||
"gbufferPreviousProjection" => m4(previous_projection),
|
||||
"horizonWaterPatchCount" => Value::i(&[patches.len() as i32]),
|
||||
n if water_patch_index(n).is_some() => Value::f(
|
||||
&(water_patch_index(n).and_then(|k| patches.get(k / 2).map(|p| p[k % 2])))
|
||||
.unwrap_or([0.0; 4]),
|
||||
),
|
||||
"horizonDynamicLightCount" => Value::i(&[frame.dynamic_lights.len() as i32]),
|
||||
n if dynamic_light_index(n).is_some() => Value::f(
|
||||
&(dynamic_light_index(n).and_then(|k| frame.dynamic_lights.get(k)))
|
||||
@@ -1847,6 +1888,37 @@ pub(super) mod initial_history_tests {
|
||||
assert_eq!(float(&u, "horizonDynamicLight0")[..4], [0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boats_water_patch_becomes_its_box() {
|
||||
let vertex = |pos: [f32; 3], layer: u32| super::super::Vertex {
|
||||
pos,
|
||||
layer,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
// A patch 1.75 by 1 block, 0.2 thick, turned 30 degrees, at the hull's water line;
|
||||
// a hull quad on each side of it.
|
||||
let (c, s) = (30f32.to_radians().cos(), 30f32.to_radians().sin());
|
||||
let at = |x: f32, y: f32, z: f32| [2.0 + x * c - z * s, y, 3.0 + x * s + z * c];
|
||||
let mask = super::super::WATER_MASK_OVERLAY;
|
||||
let mut quads = vec![vertex([0.0; 3], 0); 4];
|
||||
for y in [-1.2, -1.0] {
|
||||
quads.extend(
|
||||
[(0.0, 0.0), (1.75, 0.0), (1.75, 1.0), (0.0, 1.0)]
|
||||
.map(|(x, z)| vertex(at(x, y, z), mask)),
|
||||
);
|
||||
}
|
||||
quads.extend(vec![vertex([5.0; 3], 0); 4]);
|
||||
let boxes = water_patches(&quads);
|
||||
assert_eq!(boxes.len(), 1);
|
||||
let [[x, z, low, high], [ax, az, bx, bz]] = boxes[0];
|
||||
let close = |a: f32, b: f32| (a - b).abs() < 1e-4;
|
||||
assert!(close(x, 2.0) && close(z, 3.0) && close(low, -1.2) && close(high, -1.0));
|
||||
// The edges: the 1-block one first (nearer), then the 1.75-block one.
|
||||
assert!(close(ax, -s) && close(az, c), "{boxes:?}");
|
||||
assert!(close(bx, 1.75 * c) && close(bz, 1.75 * s), "{boxes:?}");
|
||||
assert!(water_patches(&quads[..4]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
@@ -2642,6 +2714,67 @@ pub(super) mod initial_history_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizonWaterPatch<k>`'s `k`: patch `k / 2`, its corner and y range (even) or edges (odd).
|
||||
fn water_patch_index(name: &str) -> Option<usize> {
|
||||
let k: usize = name.strip_prefix("horizonWaterPatch")?.parse().ok()?;
|
||||
(k < 2 * horizon_shaders::WATER_PATCHES).then_some(k)
|
||||
}
|
||||
|
||||
/// The boats' water patches among the frame's entity quads (`WATER_MASK_OVERLAY`), the first
|
||||
/// `WATER_PATCHES`, each as a camera-relative box: a corner's x and z and the patch's lowest and
|
||||
/// highest y, then its two horizontal edges from that corner. A boat rocks a little: its patch's
|
||||
/// corners are told apart in x and z within 2 cm.
|
||||
pub(super) fn water_patches(entities: &[super::Vertex]) -> Vec<[[f32; 4]; 2]> {
|
||||
let mut out = Vec::new();
|
||||
let mut points: Vec<[f32; 3]> = Vec::new();
|
||||
let flush = |points: &mut Vec<[f32; 3]>, out: &mut Vec<[[f32; 4]; 2]>| {
|
||||
if let Some(b) = patch_box(points)
|
||||
&& out.len() < horizon_shaders::WATER_PATCHES
|
||||
{
|
||||
out.push(b);
|
||||
}
|
||||
points.clear();
|
||||
};
|
||||
for quad in entities.as_chunks::<4>().0 {
|
||||
if quad[0].layer & 0xffff == super::WATER_MASK_OVERLAY {
|
||||
points.extend(quad.iter().map(|v| v.pos));
|
||||
} else if !points.is_empty() {
|
||||
flush(&mut points, &mut out);
|
||||
}
|
||||
}
|
||||
flush(&mut points, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// A water patch's box (`water_patches`) from its corners.
|
||||
fn patch_box(points: &[[f32; 3]]) -> Option<[[f32; 4]; 2]> {
|
||||
let first = points.first()?;
|
||||
let (low, high) = points
|
||||
.iter()
|
||||
.fold((f32::MAX, f32::MIN), |(l, h), p| (l.min(p[1]), h.max(p[1])));
|
||||
let mut corners: Vec<[f32; 2]> = Vec::new();
|
||||
for p in points {
|
||||
let c = [p[0], p[2]];
|
||||
if !corners
|
||||
.iter()
|
||||
.any(|q| (q[0] - c[0]).abs() < 0.02 && (q[1] - c[1]).abs() < 0.02)
|
||||
{
|
||||
corners.push(c);
|
||||
}
|
||||
}
|
||||
let origin = [first[0], first[2]];
|
||||
let mut others: Vec<[f32; 2]> = corners.into_iter().filter(|c| *c != origin).collect();
|
||||
if others.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let distance = |c: &[f32; 2]| (c[0] - origin[0]).powi(2) + (c[1] - origin[1]).powi(2);
|
||||
others.sort_by(|a, b| distance(a).total_cmp(&distance(b)));
|
||||
// The two nearest corners are the edges' ends; the farthest is across.
|
||||
let edge = |c: [f32; 2]| [c[0] - origin[0], c[1] - origin[1]];
|
||||
let (a, b) = (edge(others[0]), edge(others[1]));
|
||||
Some([[origin[0], origin[1], low, high], [a[0], a[1], b[0], b[1]]])
|
||||
}
|
||||
|
||||
/// `horizonDynamicLight<k>`'s `k`, for the lights `Frame::dynamic_lights` holds.
|
||||
fn dynamic_light_index(name: &str) -> Option<usize> {
|
||||
let k: usize = name.strip_prefix("horizonDynamicLight")?.parse().ok()?;
|
||||
|
||||
@@ -47,6 +47,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
#[derive(Default)]
|
||||
pub struct Readback {
|
||||
texture: Option<wgpu::Texture>,
|
||||
/// The 8-bit texture `finish` put the frame on, where the swapchain image can't be read
|
||||
/// back; `present` then draws it instead of `texture`.
|
||||
finished: Option<wgpu::Texture>,
|
||||
blitter: Option<(wgpu::TextureFormat, wgpu::util::TextureBlitter)>,
|
||||
/// A frame drew into `texture` and still has to reach the swapchain.
|
||||
/// A frame drew into `texture` (or `finished`) and still has to reach the swapchain.
|
||||
drawn: bool,
|
||||
}
|
||||
|
||||
@@ -53,7 +56,47 @@ impl Readback {
|
||||
self.texture.clone()
|
||||
}
|
||||
|
||||
/// Draws the frame `target` gave onto the swapchain image `surface`; nothing
|
||||
/// The frame `target` gave, finished onto 8 bits of the swapchain image `surface`'s format,
|
||||
/// clipped and rounded as an 8-bit frame holds it, for what draws over it from here on
|
||||
/// (`Renderer::overlay_format`): `surface` itself, or, for a screenshot it can't be copied
|
||||
/// from, a texture of its own that `present` then draws there.
|
||||
pub fn finish(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
surface: &wgpu::Texture,
|
||||
screenshot: bool,
|
||||
) -> Option<wgpu::Texture> {
|
||||
let frame = self.texture.clone().filter(|_| self.drawn)?;
|
||||
let format = surface.format().remove_srgb_suffix();
|
||||
let target = if !screenshot || surface.usage().contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
self.drawn = false;
|
||||
surface.clone()
|
||||
} else {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon finished frame"),
|
||||
size: frame.size(),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
self.finished = Some(texture.clone());
|
||||
texture
|
||||
};
|
||||
let view = target.create_view(&wgpu::TextureViewDescriptor {
|
||||
format: Some(format),
|
||||
..Default::default()
|
||||
});
|
||||
self.blit(device, encoder, &frame, &view, format);
|
||||
Some(target)
|
||||
}
|
||||
|
||||
/// Draws the frame `target` (or `finish`) gave onto the swapchain image `surface`; nothing
|
||||
/// when this frame drew straight into it.
|
||||
pub fn present(
|
||||
&mut self,
|
||||
@@ -61,10 +104,11 @@ impl Readback {
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
surface: &wgpu::Texture,
|
||||
) {
|
||||
let finished = self.finished.take();
|
||||
if !std::mem::take(&mut self.drawn) {
|
||||
return;
|
||||
}
|
||||
let Some(texture) = self.texture.clone() else {
|
||||
let Some(texture) = finished.or_else(|| self.texture.clone()) else {
|
||||
return;
|
||||
};
|
||||
self.draw_onto(device, encoder, &texture, surface);
|
||||
@@ -346,4 +390,68 @@ mod tests {
|
||||
let float = wgpu::TextureFormat::Rgba16Float;
|
||||
reaches_an_8_bit_swapchain(&device, &queue, float, color, [0, 51, 255, 255]);
|
||||
}
|
||||
|
||||
/// `finish` puts a float frame on 8 bits before the overlays draw: on the swapchain image
|
||||
/// itself, which `present` then leaves alone, or, for a screenshot of an image that can't
|
||||
/// be copied from, on a texture of its own that the screenshot reads and `present` draws.
|
||||
#[test]
|
||||
fn a_float_frame_finishes_on_8_bits_before_the_overlays() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let float = wgpu::TextureFormat::Rgba16Float;
|
||||
let color = wgpu::Color {
|
||||
r: 1.5,
|
||||
g: 0.2,
|
||||
b: -0.5,
|
||||
a: 1.0,
|
||||
};
|
||||
let check = texture(
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let blitter = wgpu::util::TextureBlitter::new(&device, F);
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
for (usage, screenshot) in [
|
||||
(wgpu::TextureUsages::COPY_SRC, false),
|
||||
(wgpu::TextureUsages::COPY_SRC, true),
|
||||
(wgpu::TextureUsages::empty(), false),
|
||||
(wgpu::TextureUsages::empty(), true),
|
||||
] {
|
||||
let surface = texture(
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| usage,
|
||||
);
|
||||
let mut readback = Readback::default();
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let frame = readback
|
||||
.target(&device, &surface, float, screenshot)
|
||||
.unwrap();
|
||||
clear(&mut encoder, &frame, color);
|
||||
let finished = readback
|
||||
.finish(&device, &mut encoder, &surface, screenshot)
|
||||
.unwrap();
|
||||
assert_eq!(finished.format(), F);
|
||||
let own = screenshot && usage.is_empty();
|
||||
assert_eq!(finished == surface, !own, "{usage:?} {screenshot}");
|
||||
assert_eq!(readback.drawn, own);
|
||||
let shot = screenshot
|
||||
.then(|| crate::gfx::copy_frame(&device, &mut encoder, &finished, [70, 3]));
|
||||
readback.present(&device, &mut encoder, &surface);
|
||||
blitter.copy(&device, &mut encoder, &view(&surface), &view(&check));
|
||||
let shown = crate::gfx::copy_frame(&device, &mut encoder, &check, [70, 3]);
|
||||
queue.submit([encoder.finish()]);
|
||||
if let Some(error) = pollster::block_on(scope.pop()) {
|
||||
panic!("{usage:?} {screenshot}: {error}");
|
||||
}
|
||||
assert_eq!(last_pixel(&device, &shown), [0, 51, 255, 255]);
|
||||
if let Some(shot) = shot {
|
||||
assert_eq!(last_pixel(&device, &shot), [0, 51, 255, 255]);
|
||||
}
|
||||
assert!(!readback.drawn && readback.finished.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,28 +19,41 @@ pub const HDR_MARKER: &str = "horizon-hdr-pending";
|
||||
pub struct Reported {
|
||||
pub paper_white: u32,
|
||||
pub peak: u32,
|
||||
/// The display's darkest black, in hundredths of a nit.
|
||||
pub black: u32,
|
||||
}
|
||||
|
||||
static REPORTED: [AtomicU32; 3] = [AtomicU32::new(0), AtomicU32::new(0), AtomicU32::new(0)];
|
||||
static REPORTED: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
|
||||
|
||||
/// Sets what the system reports (the renderer, when it opens its surface).
|
||||
pub fn report(reported: Option<Reported>) {
|
||||
let (paper_white, peak, black) =
|
||||
reported.map_or((0, 0, 0), |r| (r.paper_white, r.peak, r.black));
|
||||
let (paper_white, peak) = reported.map_or((0, 0), |r| (r.paper_white, r.peak));
|
||||
REPORTED[0].store(paper_white, Ordering::Relaxed);
|
||||
REPORTED[1].store(peak, Ordering::Relaxed);
|
||||
REPORTED[2].store(black, Ordering::Relaxed);
|
||||
}
|
||||
/// What the system reports, if it does.
|
||||
pub fn reported() -> Option<Reported> {
|
||||
let [paper_white, peak, black] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
|
||||
(peak > 0).then_some(Reported {
|
||||
paper_white,
|
||||
peak,
|
||||
black,
|
||||
})
|
||||
let [paper_white, peak] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
|
||||
(peak > 0).then_some(Reported { paper_white, peak })
|
||||
}
|
||||
|
||||
/// The top of Peak Brightness's scale: `peak_nits_at(PEAK_STEPS)` is PQ's 10 000 nits.
|
||||
pub const PEAK_STEPS: u32 = 52;
|
||||
/// Peak Brightness's value at `step` of its scale, the slider's and calibration's: 200 to
|
||||
/// 2000 nits by 50, where most displays' peaks are, then up to PQ's 10 000 by 500.
|
||||
pub fn peak_nits_at(step: u32) -> u32 {
|
||||
match step.min(PEAK_STEPS) {
|
||||
s @ 0..=36 => 200 + s * 50,
|
||||
s => 2000 + (s - 36) * 500,
|
||||
}
|
||||
}
|
||||
/// Where `nits` sits on Peak Brightness's scale ([`peak_nits_at`]), between steps for a value
|
||||
/// off it.
|
||||
pub fn peak_step_of(nits: f32) -> f32 {
|
||||
let step = if nits <= 2000.0 {
|
||||
(nits - 200.0) / 50.0
|
||||
} else {
|
||||
36.0 + (nits - 2000.0) / 500.0
|
||||
};
|
||||
step.clamp(0.0, PEAK_STEPS as f32)
|
||||
}
|
||||
|
||||
/// Simple-level presets. `Custom` leaves every option as the player set it.
|
||||
@@ -508,6 +521,61 @@ pub fn hdr_offered() -> bool {
|
||||
HDR_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
static SYSTEM_HDR: AtomicU32 = AtomicU32::new(0);
|
||||
/// The window's current system HDR state, independent of swapchain availability.
|
||||
pub fn report_system_hdr(on: Option<bool>) {
|
||||
SYSTEM_HDR.store(on.map_or(0, |on| if on { 2 } else { 1 }), Ordering::Relaxed);
|
||||
}
|
||||
pub fn system_hdr() -> Option<bool> {
|
||||
match SYSTEM_HDR.load(Ordering::Relaxed) {
|
||||
1 => Some(false),
|
||||
2 => Some(true),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum HdrMode {
|
||||
#[default]
|
||||
Off,
|
||||
On,
|
||||
Auto,
|
||||
}
|
||||
impl HdrMode {
|
||||
pub const ALL: [Self; 3] = [Self::Off, Self::On, Self::Auto];
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Off => "false",
|
||||
Self::On => "true",
|
||||
Self::Auto => "auto",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Off => "Off",
|
||||
Self::On => "On",
|
||||
Self::Auto => "Auto",
|
||||
}
|
||||
}
|
||||
fn parse(value: &str, default: Self) -> Self {
|
||||
match value {
|
||||
"false" | "off" => Self::Off,
|
||||
"true" | "on" => Self::On,
|
||||
"auto" => Self::Auto,
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
/// On retains the old switch's intent even before a surface is opened; the renderer
|
||||
/// still gates output on availability. Auto requires both availability and a live report.
|
||||
fn wanted(self, offered: bool, system: Option<bool>) -> bool {
|
||||
match self {
|
||||
Self::Off => false,
|
||||
Self::On => true,
|
||||
Self::Auto => offered && system == Some(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static TRANSLUCENT_BATCHING_OFFERED: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the device can batch translucent quads (`Graphics::translucent_batching`): set when
|
||||
@@ -783,16 +851,15 @@ pub struct Graphics {
|
||||
/// Frame generation (Horizon setting), off by default.
|
||||
pub frame_generation: FrameGeneration,
|
||||
/// HDR output (HDR10, or scRGB), where the display path offers it.
|
||||
pub hdr: bool,
|
||||
pub hdr: HdrMode,
|
||||
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
|
||||
/// white, as the system reports it ([`Graphics::paper_white_nits`]).
|
||||
pub hdr_paper_white: u32,
|
||||
/// HDR peak brightness the world's highlights reach, in nits; 0 is Auto:
|
||||
/// HDR peak brightness the world's highlights reach, in nits (200..=10 000); 0 is Auto:
|
||||
/// what the system reports for the display ([`Graphics::peak_nits`]).
|
||||
pub hdr_peak: u32,
|
||||
/// HDR black level: the display's darkest black, in hundredths of a nit; 0 is Auto:
|
||||
/// what the system reports, else true black. Shadows are lifted onto it, and the HDR
|
||||
/// metadata says it.
|
||||
/// HDR black level: the display's darkest black, in hundredths of a nit; 0, the default,
|
||||
/// lifts nothing. Shadows are lifted onto it, and the HDR metadata says it.
|
||||
pub hdr_black_level: u32,
|
||||
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
|
||||
/// first-person hand stays at the world's.
|
||||
@@ -870,7 +937,7 @@ impl Default for Graphics {
|
||||
upscaling_sharpness: 85,
|
||||
screenshot_quality: 1,
|
||||
frame_generation: FrameGeneration::Off,
|
||||
hdr: false,
|
||||
hdr: HdrMode::Off,
|
||||
hdr_paper_white: 0,
|
||||
hdr_peak: 0,
|
||||
hdr_black_level: 0,
|
||||
@@ -902,8 +969,8 @@ impl Graphics {
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr {
|
||||
g.hdr = false;
|
||||
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr != HdrMode::Off {
|
||||
g.hdr = HdrMode::Off;
|
||||
g.save(dir);
|
||||
log::warn!(
|
||||
"the last start ended before HDR output showed a frame; HDR is off now (graphics option hdr, in {FILE})"
|
||||
@@ -1000,7 +1067,7 @@ impl Graphics {
|
||||
.unwrap_or(1)
|
||||
}
|
||||
"frameGeneration" => g.frame_generation = FrameGeneration::parse(value),
|
||||
"hdr" => g.hdr = flag(g.hdr),
|
||||
"hdr" => g.hdr = HdrMode::parse(value, g.hdr),
|
||||
"hdrLighting" => g.hdr_lighting = flag(g.hdr_lighting),
|
||||
"hdrPaperWhite" => {
|
||||
g.hdr_paper_white = match number(g.hdr_paper_white) {
|
||||
@@ -1011,7 +1078,7 @@ impl Graphics {
|
||||
"hdrPeak" => {
|
||||
g.hdr_peak = match number(g.hdr_peak) {
|
||||
0 => 0,
|
||||
n => n.clamp(200, 2000),
|
||||
n => n.clamp(200, 10_000),
|
||||
}
|
||||
}
|
||||
"hdrBlackLevel" => g.hdr_black_level = number(g.hdr_black_level).min(100),
|
||||
@@ -1082,7 +1149,7 @@ impl Graphics {
|
||||
self.upscaling_sharpness,
|
||||
self.screenshot_quality,
|
||||
self.frame_generation.name(),
|
||||
bool_text(self.hdr),
|
||||
self.hdr.key(),
|
||||
self.hdr_paper_white,
|
||||
self.hdr_peak,
|
||||
self.hdr_black_level,
|
||||
@@ -1111,10 +1178,14 @@ impl Graphics {
|
||||
pub fn save(&self, dir: &Path) {
|
||||
let _ = fs::write(dir.join(FILE), self.serialize());
|
||||
}
|
||||
/// The single decision shared by rendering and the HDR settings page.
|
||||
pub fn hdr_wanted(&self) -> bool {
|
||||
self.hdr.wanted(hdr_offered(), system_hdr())
|
||||
}
|
||||
/// Whether a renderer made now draws its frame in 16 bits (`Renderer::new`'s `wide_frame`):
|
||||
/// HDR on, with Smooth Gradients.
|
||||
pub fn wide_frame(&self) -> bool {
|
||||
self.hdr && self.hdr_smooth_gradients
|
||||
self.hdr_wanted() && self.hdr_smooth_gradients
|
||||
}
|
||||
/// Paper white, in nits: the player's, or on Auto the desktop's SDR white as the
|
||||
/// system reports it, else 203 (BT.2408's reference white).
|
||||
@@ -1132,13 +1203,10 @@ impl Graphics {
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// The black level, in nits: the player's, or on Auto what the system reports, else 0.
|
||||
/// The black level, in nits. Not the system's: files from before saved 0 for that
|
||||
/// (Auto), and a reported black would lift every display's shadows by default.
|
||||
pub fn black_level_nits(&self) -> f32 {
|
||||
let hundredths = match self.hdr_black_level {
|
||||
0 => reported().map_or(0, |r| r.black),
|
||||
n => n,
|
||||
};
|
||||
hundredths as f32 / 100.0
|
||||
self.hdr_black_level as f32 / 100.0
|
||||
}
|
||||
/// Worker threads for chunk generation and meshing.
|
||||
pub fn worker_threads(&self) -> usize {
|
||||
@@ -1207,6 +1275,53 @@ pub fn apply_preset(preset: Preset, s: &mut crate::settings::Settings) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hdr_modes_keep_boolean_files_and_round_trip_auto() {
|
||||
assert_eq!(Graphics::parse("").hdr, HdrMode::Off);
|
||||
for (value, expected) in [
|
||||
("true", HdrMode::On),
|
||||
("false", HdrMode::Off),
|
||||
("on", HdrMode::On),
|
||||
("off", HdrMode::Off),
|
||||
("auto", HdrMode::Auto),
|
||||
("unknown", HdrMode::Off),
|
||||
] {
|
||||
let g = Graphics::parse(&format!("hdr:{value}\n"));
|
||||
assert_eq!(g.hdr, expected, "{value}");
|
||||
assert_eq!(Graphics::parse(&g.serialize()).hdr, expected);
|
||||
let saved = g.serialize();
|
||||
let value = saved.lines().find_map(|l| l.strip_prefix("hdr:")).unwrap();
|
||||
// The old boolean parser keeps its Off default for an unknown value.
|
||||
assert_eq!(value == "true", expected == HdrMode::On);
|
||||
}
|
||||
assert_eq!(Graphics::parse("hdr:true\nhdr:unknown\n").hdr, HdrMode::On);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_auto_needs_a_positive_system_report_and_an_output() {
|
||||
for (offered, system, expected) in [
|
||||
(false, None, false),
|
||||
(false, Some(false), false),
|
||||
(false, Some(true), false),
|
||||
(true, None, false),
|
||||
(true, Some(false), false),
|
||||
(true, Some(true), true),
|
||||
] {
|
||||
assert!(!HdrMode::Off.wanted(offered, system));
|
||||
assert!(HdrMode::On.wanted(offered, system));
|
||||
assert_eq!(
|
||||
HdrMode::Auto.wanted(offered, system),
|
||||
expected,
|
||||
"{offered} {system:?}"
|
||||
);
|
||||
}
|
||||
let readings = [Some(false), Some(true), Some(false), None, Some(true)];
|
||||
assert_eq!(
|
||||
readings.map(|r| HdrMode::Auto.wanted(true, r)),
|
||||
[false, true, false, false, true]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_api_choice_matches_the_system() {
|
||||
let choices = GraphicsApi::choices();
|
||||
@@ -1267,7 +1382,7 @@ mod tests {
|
||||
upscaling_sharpness: 40,
|
||||
screenshot_quality: 4,
|
||||
frame_generation: FrameGeneration::Fsr31,
|
||||
hdr: true,
|
||||
hdr: HdrMode::On,
|
||||
hdr_paper_white: 240,
|
||||
hdr_peak: 1400,
|
||||
hdr_black_level: 12,
|
||||
@@ -1352,9 +1467,36 @@ mod tests {
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()).gpu_culling, mode);
|
||||
}
|
||||
// 0 is Auto; anything else is a peak the slider reaches.
|
||||
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
|
||||
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
|
||||
// 0 is Auto; anything else is a peak, up to PQ's 10 000 nits. Older files' values
|
||||
// (up to 2000) read as they were saved.
|
||||
for (file, peak) in [
|
||||
("hdrPeak:0\n", 0),
|
||||
("hdrPeak:50\n", 200),
|
||||
("hdrPeak:1234\n", 1234),
|
||||
("hdrPeak:2000\n", 2000),
|
||||
("hdrPeak:3000\n", 3000),
|
||||
("hdrPeak:10000\n", 10_000),
|
||||
("hdrPeak:25000\n", 10_000),
|
||||
] {
|
||||
let g = Graphics::parse(file);
|
||||
assert_eq!(g.hdr_peak, peak, "{file}");
|
||||
assert_eq!(Graphics::parse(&g.serialize()).hdr_peak, peak, "{file}");
|
||||
}
|
||||
assert_eq!(Graphics::parse("hdrPeak:3000\n").peak_nits(), 3000);
|
||||
// The black level is what's saved, in hundredths of a nit: 0 (Auto in older files)
|
||||
// lifts nothing, and the most is a nit.
|
||||
for (file, hundredths, nits) in [
|
||||
("", 0, 0.0),
|
||||
("hdrBlackLevel:0\n", 0, 0.0),
|
||||
("hdrBlackLevel:5\n", 5, 0.05),
|
||||
("hdrBlackLevel:100\n", 100, 1.0),
|
||||
("hdrBlackLevel:250\n", 100, 1.0),
|
||||
] {
|
||||
let g = Graphics::parse(file);
|
||||
assert_eq!(g.hdr_black_level, hundredths, "{file}");
|
||||
assert_eq!(g.black_level_nits(), nits, "{file}");
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g, "{file}");
|
||||
}
|
||||
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.
|
||||
@@ -1385,6 +1527,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peak_scale_steps_by_50_to_2000_then_by_500_to_10000() {
|
||||
let scale: Vec<u32> = (0..=PEAK_STEPS).map(peak_nits_at).collect();
|
||||
let fine: Vec<u32> = (200..=2000).step_by(50).collect();
|
||||
let coarse: Vec<u32> = (2500..=10_000).step_by(500).collect();
|
||||
assert_eq!(scale, [fine, coarse].concat());
|
||||
for (step, &nits) in scale.iter().enumerate() {
|
||||
assert_eq!(peak_step_of(nits as f32), step as f32);
|
||||
}
|
||||
// Off the scale, between its neighbours; beyond it, at its ends.
|
||||
assert_eq!(peak_step_of(1225.0), 20.5);
|
||||
assert_eq!(peak_step_of(3250.0), 38.5);
|
||||
assert_eq!(peak_step_of(100.0), 0.0);
|
||||
assert_eq!(peak_step_of(20_000.0), PEAK_STEPS as f32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_culling_round_trips_its_stable_names() {
|
||||
assert_eq!(Graphics::default().leaf_culling, LeafCullingMode::Off);
|
||||
|
||||
@@ -189,11 +189,23 @@ fn tick(batch: &mut HudBatch, atlas: &HudAtlas, x: f32, y: f32, color: u32) {
|
||||
/// A flat button with its label centred: Undo, Calibrate..., and Done in the
|
||||
/// violet (`primary`). Pointed at or focused, vanilla's white edge.
|
||||
pub fn button(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
rect: [f32; 4],
|
||||
label: &Text,
|
||||
flags: (bool, bool, bool),
|
||||
) {
|
||||
button_with_room(batch, atlas, rect, label, flags, 0.0);
|
||||
}
|
||||
/// A button whose label leaves `room` at its left, for a controller's glyph drawn there
|
||||
/// (`widgets::action_glyph_room`).
|
||||
pub fn button_with_room(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
rect: [f32; 4],
|
||||
label: &Text,
|
||||
(hover, live, primary): (bool, bool, bool),
|
||||
room: f32,
|
||||
) {
|
||||
let [x, y, w, h] = rect;
|
||||
let face = match (live, primary, hover) {
|
||||
@@ -209,7 +221,15 @@ pub fn button(
|
||||
}
|
||||
let color = if live { WHITE } else { MUTED };
|
||||
let ty = y + ((h - 8.0) / 2.0).floor();
|
||||
scrolling_label(batch, atlas, label, [x + 2.0, ty, w - 4.0], (y, h), color);
|
||||
let left = x + 2.0 + room;
|
||||
scrolling_label(
|
||||
batch,
|
||||
atlas,
|
||||
label,
|
||||
[left, ty, w - 4.0 - room],
|
||||
(y, h),
|
||||
color,
|
||||
);
|
||||
}
|
||||
/// A widget label of `[left, y, width]` inside the widget's `(top, height)`: centred when it fits,
|
||||
/// else slid (vanilla `AbstractWidget.extractScrollingStringOverContents`) and clipped to the box.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! and what they do live here.
|
||||
use crate::{
|
||||
graphics::{
|
||||
EntityCullingMode, FarDetail, FrameGeneration, GpuCullingMode, GraphicsApi,
|
||||
EntityCullingMode, FarDetail, FrameGeneration, GpuCullingMode, GraphicsApi, HdrMode,
|
||||
LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode, Preset, Upscaling,
|
||||
},
|
||||
graphics_style::Cost,
|
||||
@@ -598,7 +598,7 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
h(
|
||||
H::HdrBlackLevel,
|
||||
None,
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.",
|
||||
"Lifts shadows onto your display's darkest black. 0: true black, no lift.",
|
||||
),
|
||||
h(
|
||||
H::HdrPeak,
|
||||
@@ -608,7 +608,7 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
h(
|
||||
H::HdrPaperWhite,
|
||||
None,
|
||||
"How bright white paper and menus look. Auto: your desktop's white.",
|
||||
"How bright white looks in the world. Auto: your desktop's white.",
|
||||
),
|
||||
h(
|
||||
H::HdrUiBrightness,
|
||||
@@ -780,6 +780,9 @@ fn labelled_percent(caption: &str, percent: u32) -> Text {
|
||||
)
|
||||
}
|
||||
|
||||
/// Peak Brightness's slider: Auto, then each step of the peak's scale.
|
||||
const PEAK_POSITIONS: u32 = crate::graphics::PEAK_STEPS + 1;
|
||||
|
||||
impl H {
|
||||
fn slider(self, s: &Settings) -> Option<f32> {
|
||||
let g = &s.horizon;
|
||||
@@ -805,10 +808,10 @@ impl H {
|
||||
H::UpscalingSharpness => Some(g.upscaling_sharpness as f32 / 100.0),
|
||||
// Auto, then 10..480 by 10.
|
||||
H::HdrPaperWhite => Some(g.hdr_paper_white as f32 / 480.0),
|
||||
// Auto, then 200..2000 by 50.
|
||||
// Auto, then the peak's scale.
|
||||
H::HdrPeak => Some(match g.hdr_peak {
|
||||
0 => 0.0,
|
||||
n => (n as f32 - 150.0) / 1850.0,
|
||||
n => (1.0 + crate::graphics::peak_step_of(n as f32)) / PEAK_POSITIONS as f32,
|
||||
}),
|
||||
H::HdrBlackLevel => Some(g.hdr_black_level as f32 / 100.0),
|
||||
H::HdrUiBrightness => Some((g.hdr_ui_brightness as f32 - 25.0) / 75.0),
|
||||
@@ -823,8 +826,8 @@ impl H {
|
||||
H::Workers => Some(max_workers()),
|
||||
H::ResolutionScale => Some(30),
|
||||
H::UpscalingSharpness => Some(20),
|
||||
H::HdrPaperWhite => Some(41),
|
||||
H::HdrPeak => Some(37),
|
||||
H::HdrPaperWhite => Some(48),
|
||||
H::HdrPeak => Some(PEAK_POSITIONS),
|
||||
H::HdrBlackLevel => Some(100),
|
||||
H::HdrUiBrightness => Some(15),
|
||||
_ => None,
|
||||
@@ -924,7 +927,7 @@ impl H {
|
||||
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
|
||||
}
|
||||
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::Hdr => labelled("HDR", g.hdr.label()),
|
||||
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
|
||||
Setup::Unknown => "Calibrate...",
|
||||
Setup::Automatic => "Calibrate Anyway...",
|
||||
@@ -941,13 +944,7 @@ impl H {
|
||||
0 => labelled("Peak Brightness", lang::tr("Auto (%s)", &[&g.peak_nits()])),
|
||||
n => labelled("Peak Brightness", n.to_string()),
|
||||
},
|
||||
H::HdrBlackLevel => match g.hdr_black_level {
|
||||
0 => labelled(
|
||||
"Black Level",
|
||||
lang::tr("Auto (%s)", &[&format!("{:.2}", g.black_level_nits())]),
|
||||
),
|
||||
n => labelled("Black Level", format!("{:.2}", n as f32 / 100.0)),
|
||||
},
|
||||
H::HdrBlackLevel => labelled("Black Level", format!("{:.2}", g.black_level_nits())),
|
||||
H::HdrUiBrightness => labelled_percent("UI Brightness", g.hdr_ui_brightness),
|
||||
H::HdrLighting => labelled("HDR Lighting", on_off(g.hdr_lighting)),
|
||||
H::HdrKey => {
|
||||
@@ -995,9 +992,9 @@ impl H {
|
||||
H::UpscalingSharpness => g.upscaling_sharpness = (v * 20.0).round() as u32 * 5,
|
||||
H::HdrPaperWhite => g.hdr_paper_white = (v * 48.0).round() as u32 * 10,
|
||||
H::HdrPeak => {
|
||||
g.hdr_peak = match (v * 37.0).round() as u32 {
|
||||
g.hdr_peak = match (v * PEAK_POSITIONS as f32).round() as u32 {
|
||||
0 => 0,
|
||||
step => 150 + step * 50,
|
||||
step => crate::graphics::peak_nits_at(step - 1),
|
||||
}
|
||||
}
|
||||
H::HdrBlackLevel => g.hdr_black_level = (v * 100.0).round() as u32,
|
||||
@@ -1079,7 +1076,12 @@ impl H {
|
||||
H::FrameGeneration => {
|
||||
g.frame_generation = step(&FrameGeneration::ALL, g.frame_generation, back)
|
||||
}
|
||||
H::Hdr => g.hdr = !g.hdr,
|
||||
H::Hdr => {
|
||||
g.hdr = step(&HdrMode::ALL, g.hdr, back);
|
||||
if g.hdr == HdrMode::On && !crate::graphics::hdr_offered() {
|
||||
g.hdr = step(&HdrMode::ALL, g.hdr, back);
|
||||
}
|
||||
}
|
||||
H::HdrLighting => g.hdr_lighting = !g.hdr_lighting,
|
||||
H::BatteryMode => g.battery = !g.battery,
|
||||
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
|
||||
@@ -1112,11 +1114,9 @@ impl Item {
|
||||
fn available(self) -> bool {
|
||||
!matches!(self, Item::Vanilla(o) if !o.available())
|
||||
}
|
||||
/// Why a control can't be used now, when it can't: HDR's page works only where
|
||||
/// the display offers HDR output, whatever `horizon-graphics.txt` says (a copied game
|
||||
/// folder, a display swapped for an SDR one: the renderer leaves HDR off there, and
|
||||
/// the saved values wait for an HDR display), and its sliders do something only
|
||||
/// while it is on; Translucent Batching switches only where the device can batch
|
||||
/// Why a control can't be used now: HDR's On requires an offered output, while Auto
|
||||
/// can also be chosen on a reporting SDR display. Brightness controls need HDR output
|
||||
/// active; saved values wait for it. Translucent Batching switches where the device can batch
|
||||
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
|
||||
/// Smooth Gradients only where it can draw the 16-bit frame, the same way, and GPU Culling
|
||||
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't.
|
||||
@@ -1126,8 +1126,7 @@ impl Item {
|
||||
}
|
||||
match self {
|
||||
Item::Horizon(
|
||||
H::Hdr
|
||||
| H::HdrCalibrate
|
||||
H::HdrCalibrate
|
||||
| H::HdrBlackLevel
|
||||
| H::HdrPeak
|
||||
| H::HdrPaperWhite
|
||||
@@ -1150,7 +1149,7 @@ impl Item {
|
||||
| H::HdrUiBrightness
|
||||
| H::HdrLighting
|
||||
| H::HdrSmoothGradients,
|
||||
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
|
||||
) if !s.horizon.hdr_wanted() => Some("Turn HDR on to use it."),
|
||||
Item::Horizon(H::TranslucentBatching)
|
||||
if !crate::graphics::translucent_batching_offered() =>
|
||||
{
|
||||
@@ -1197,7 +1196,6 @@ impl Item {
|
||||
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::HdrLighting) => g.hdr_lighting,
|
||||
Item::Horizon(H::BatteryMode) => g.battery,
|
||||
Item::Horizon(H::ShadowCache) => g.shadow_cache,
|
||||
@@ -1443,6 +1441,7 @@ impl Screen for GraphicsScreen {
|
||||
use crate::graphics as g;
|
||||
let offers = (
|
||||
g::hdr_offered(),
|
||||
g::system_hdr(),
|
||||
g::smooth_gradients_offered(),
|
||||
g::translucent_batching_offered(),
|
||||
g::gpu_culling_offer(),
|
||||
@@ -1467,6 +1466,8 @@ impl Screen for GraphicsScreen {
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
listed: true,
|
||||
heading: None,
|
||||
};
|
||||
rows().into_iter().map(sheet).collect()
|
||||
}
|
||||
@@ -1514,7 +1515,7 @@ impl Screen for GraphicsScreen {
|
||||
/// settings and the vanilla options on its rows. Not the other options (the narrator, or
|
||||
/// one `horizon-ctl options-set` changed meanwhile), nor fullscreen, which F11 and the
|
||||
/// window manager change.
|
||||
fn undo(&mut self, s: &mut Settings, b: &Settings) {
|
||||
fn undo(&mut self, s: &mut Settings, b: &Settings) -> Vec<Output> {
|
||||
s.horizon = b.horizon.clone();
|
||||
s.render_distance = b.render_distance;
|
||||
s.simulation_distance = b.simulation_distance;
|
||||
@@ -1551,6 +1552,7 @@ impl Screen for GraphicsScreen {
|
||||
}
|
||||
}
|
||||
s.file.fullscreen_resolution = b.file.fullscreen_resolution.clone();
|
||||
Vec::new()
|
||||
}
|
||||
/// Calibration can change what Undo puts back, as a row does.
|
||||
fn changes(&self, outputs: &[Output]) -> bool {
|
||||
@@ -1767,14 +1769,14 @@ mod tests {
|
||||
// Calibration counts as a change too.
|
||||
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::graphics::offer_hdr(true);
|
||||
s.horizon.hdr = true;
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
let calibrate = row(&mut ui, &s, Item::Horizon(H::HdrCalibrate));
|
||||
press(&mut ui, &mut s, at(calibrate));
|
||||
assert!(ui.changed());
|
||||
// From the keyboard: Undo puts it back and goes away.
|
||||
ui.set_focus(Some(Target::Undo));
|
||||
assert_eq!(key(&mut ui, &mut s, K::Enter), [CLICK, CHANGED]);
|
||||
assert!(!s.horizon.hdr && !ui.changed());
|
||||
assert!(!s.horizon.hdr_wanted() && !ui.changed());
|
||||
assert_eq!(ui.focused(), Some(Target::Done));
|
||||
}
|
||||
|
||||
@@ -1789,7 +1791,7 @@ mod tests {
|
||||
assert!(press(&mut ui, &mut s, (r[0] + 4.0, r[1] + 4.0)).is_empty());
|
||||
assert_eq!(s.horizon.chunk_updates, 16);
|
||||
assert!(ui.changes_value());
|
||||
assert!(!ui.release());
|
||||
assert!(ui.release().is_none());
|
||||
// At the track's end: the most, then dragged to its start.
|
||||
assert_eq!(press(&mut ui, &mut s, (tx + tw, r[1] + 4.0)), [DRAGGED]);
|
||||
assert_eq!(H::ChunkUpdates.slider(&s), Some(1.0));
|
||||
@@ -1798,8 +1800,8 @@ mod tests {
|
||||
[DRAGGED]
|
||||
);
|
||||
assert_eq!(H::ChunkUpdates.slider(&s), Some(0.0));
|
||||
assert!(ui.release());
|
||||
assert!(!ui.release());
|
||||
assert!(ui.release().is_some());
|
||||
assert!(ui.release().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1807,7 +1809,7 @@ mod tests {
|
||||
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::graphics::offer_hdr(true);
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = true;
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
let item = Item::Horizon(H::HdrSmoothGradients);
|
||||
// Where the device can't, the saved On reads Off.
|
||||
crate::graphics::offer_smooth_gradients(false);
|
||||
@@ -1823,39 +1825,42 @@ mod tests {
|
||||
assert_eq!(item.blocked(&s), None);
|
||||
assert_eq!(item.label(&s).plain(), "Smooth Gradients: On");
|
||||
assert_eq!(item.switch(&s), Some(true));
|
||||
s.horizon.hdr = false;
|
||||
s.horizon.hdr = HdrMode::Off;
|
||||
assert_eq!(item.blocked(&s), Some("Turn HDR on to use it."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_waits_for_a_display_that_offers_it_and_its_sliders_for_the_switch() {
|
||||
fn hdr_auto_can_be_selected_before_querying_and_brightness_waits_for_output() {
|
||||
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut s = Settings::default();
|
||||
let mut ui = ui();
|
||||
let at = |[x, y, ..]: [f32; 4]| (x + 8.0, y + 4.0);
|
||||
crate::graphics::offer_hdr(false);
|
||||
// A 720p window opened at HDR shows only its greyed rows: Down still reaches the shortcut.
|
||||
// Off has not asked the system yet: Auto must still be reachable to start the watch.
|
||||
let short = (427.0, 240.0);
|
||||
let mut at_hdr = Ui::default();
|
||||
at_hdr.show(Page::Hdr);
|
||||
at_hdr.key(&mut s, K::ArrowDown, None, false, short);
|
||||
assert_eq!(at_hdr.focused_row(), Some(Item::Horizon(H::HdrKey)));
|
||||
assert_eq!(at_hdr.focused_row(), Some(Item::Horizon(H::Hdr)));
|
||||
let hdr = row(&mut ui, &s, Item::Horizon(H::Hdr));
|
||||
assert!(press(&mut ui, &mut s, at(hdr)).is_empty());
|
||||
// Not even with HDR saved on, from another computer: nor its sliders and calibration.
|
||||
s.horizon.hdr = true;
|
||||
for h in [H::Hdr, H::HdrCalibrate, H::HdrPeak, H::HdrPaperWhite] {
|
||||
assert_eq!(press(&mut ui, &mut s, at(hdr)), [CLICK, CHANGED]);
|
||||
assert_eq!(s.horizon.hdr, HdrMode::Auto);
|
||||
assert_eq!(press(&mut ui, &mut s, at(hdr)), [CLICK, CHANGED]);
|
||||
assert_eq!(s.horizon.hdr, HdrMode::Off);
|
||||
// Brightness still needs an output, even with On saved from another computer.
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
for h in [H::HdrCalibrate, H::HdrPeak, H::HdrPaperWhite] {
|
||||
assert!(Item::Horizon(h).blocked(&s).is_some(), "{h:?}");
|
||||
}
|
||||
s.horizon.hdr = false;
|
||||
s.horizon.hdr = HdrMode::Off;
|
||||
crate::graphics::offer_hdr(true);
|
||||
let (tx, _) = Columns::of(SIZE.0, SIZE.1).track();
|
||||
let peak = row(&mut ui, &s, Item::Horizon(H::HdrPeak));
|
||||
assert!(press(&mut ui, &mut s, (tx + 2.0, peak[1] + 4.0)).is_empty());
|
||||
// The switch turns it on, and the sliders below it work.
|
||||
// The choice turns it on, and the sliders below it work.
|
||||
let hdr = row(&mut ui, &s, Item::Horizon(H::Hdr));
|
||||
assert_eq!(press(&mut ui, &mut s, at(hdr)), [CLICK, CHANGED]);
|
||||
assert!(s.horizon.hdr);
|
||||
assert!(s.horizon.hdr_wanted());
|
||||
let peak = row(&mut ui, &s, Item::Horizon(H::HdrPeak));
|
||||
assert_eq!(press(&mut ui, &mut s, (tx + 2.0, peak[1] + 4.0)), [DRAGGED]);
|
||||
ui.release();
|
||||
@@ -1873,6 +1878,33 @@ mod tests {
|
||||
assert!(ui.changed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_auto_is_a_choice_on_a_reporting_sdr_display() {
|
||||
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::graphics::offer_hdr(false);
|
||||
crate::graphics::report_system_hdr(Some(false));
|
||||
let mut s = Settings::default();
|
||||
let item = Item::Horizon(H::Hdr);
|
||||
assert_eq!(item.switch(&s), None);
|
||||
assert_eq!(item.blocked(&s), None);
|
||||
item.cycle(&mut s, false, FB);
|
||||
assert_eq!(s.horizon.hdr, HdrMode::Auto); // On requires an offered output.
|
||||
assert!(!s.horizon.hdr_wanted());
|
||||
assert!(Item::Horizon(H::HdrPeak).blocked(&s).is_some());
|
||||
item.cycle(&mut s, true, FB);
|
||||
assert_eq!(s.horizon.hdr, HdrMode::Off);
|
||||
crate::graphics::offer_hdr(true);
|
||||
for expected in [HdrMode::On, HdrMode::Auto, HdrMode::Off] {
|
||||
item.cycle(&mut s, false, FB);
|
||||
assert_eq!(s.horizon.hdr, expected);
|
||||
}
|
||||
s.horizon.hdr = HdrMode::Auto;
|
||||
crate::graphics::report_system_hdr(Some(true));
|
||||
assert!(s.horizon.hdr_wanted());
|
||||
assert_eq!(Item::Horizon(H::HdrPeak).blocked(&s), None);
|
||||
crate::graphics::report_system_hdr(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scrollbar_can_be_held_and_dragged() {
|
||||
// #3552: AbstractScrollArea.updateScrolling holds the scrollbar on a press over its
|
||||
@@ -1891,7 +1923,7 @@ mod tests {
|
||||
assert_eq!(ui.scroll_amount(), content - (cols.bottom - cols.top));
|
||||
drag(&mut ui, &mut s, cols.top - 50.0, true);
|
||||
assert_eq!(ui.scroll_amount(), 0.0);
|
||||
assert!(!ui.release());
|
||||
assert!(ui.release().is_none());
|
||||
drag(&mut ui, &mut s, cols.bottom + 50.0, true);
|
||||
assert_eq!(ui.scroll_amount(), 0.0);
|
||||
}
|
||||
@@ -2730,8 +2762,8 @@ mod tests {
|
||||
let pos = i as f32 / steps as f32;
|
||||
let mut s1 = Settings::default();
|
||||
let mut s2 = Settings::default();
|
||||
s1.horizon.hdr = true;
|
||||
s2.horizon.hdr = true;
|
||||
s1.horizon.hdr = HdrMode::On;
|
||||
s2.horizon.hdr = HdrMode::On;
|
||||
h.set_slider(&mut s1, pos);
|
||||
|
||||
item.step_slider(&mut s1, true, 200.0);
|
||||
@@ -2747,8 +2779,8 @@ mod tests {
|
||||
let pos = i as f32 / steps as f32;
|
||||
let mut s1 = Settings::default();
|
||||
let mut s2 = Settings::default();
|
||||
s1.horizon.hdr = true;
|
||||
s2.horizon.hdr = true;
|
||||
s1.horizon.hdr = HdrMode::On;
|
||||
s2.horizon.hdr = HdrMode::On;
|
||||
h.set_slider(&mut s1, pos);
|
||||
|
||||
item.step_slider(&mut s1, false, 200.0);
|
||||
@@ -2762,6 +2794,81 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn hdr_sliders_step_through_every_value_they_can_be_set_to() {
|
||||
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::graphics::offer_hdr(true);
|
||||
let value = |h: H, s: &Settings| match h {
|
||||
H::HdrPaperWhite => s.horizon.hdr_paper_white,
|
||||
H::HdrPeak => s.horizon.hdr_peak,
|
||||
H::HdrBlackLevel => s.horizon.hdr_black_level,
|
||||
H::HdrUiBrightness => s.horizon.hdr_ui_brightness,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let fresh = || {
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
s
|
||||
};
|
||||
for h in [
|
||||
H::HdrPaperWhite,
|
||||
H::HdrPeak,
|
||||
H::HdrBlackLevel,
|
||||
H::HdrUiBrightness,
|
||||
] {
|
||||
// Everything a drag can land on, each read back where the slider puts it.
|
||||
let mut set = std::collections::BTreeSet::new();
|
||||
for i in 0..=10_000 {
|
||||
let mut s = fresh();
|
||||
h.set_slider(&mut s, i as f32 / 10_000.0);
|
||||
let v = value(h, &s);
|
||||
set.insert(v);
|
||||
let mut again = fresh();
|
||||
h.set_slider(&mut again, h.slider(&s).unwrap());
|
||||
assert_eq!(value(h, &again), v, "{h:?} round trip");
|
||||
}
|
||||
// Everything a key or the controller reaches, from the bottom to the top.
|
||||
let mut s = fresh();
|
||||
h.set_slider(&mut s, 0.0);
|
||||
let mut stepped = std::collections::BTreeSet::from([value(h, &s)]);
|
||||
for _ in 0..h.slider_steps().unwrap() {
|
||||
Item::Horizon(h).step_slider(&mut s, true, 200.0);
|
||||
stepped.insert(value(h, &s));
|
||||
}
|
||||
assert_eq!(stepped, set, "{h:?}");
|
||||
}
|
||||
// Auto, then 10..480 by 10; Auto, then 200..2000 by 50 and 2500..10 000 by 500;
|
||||
// 0..1 nit by 0.01 with 0 no lift.
|
||||
let mut s = fresh();
|
||||
let reach = |h: H, s: &mut Settings| {
|
||||
h.set_slider(s, 0.0);
|
||||
let mut all = vec![value(h, s)];
|
||||
for _ in 0..h.slider_steps().unwrap() {
|
||||
Item::Horizon(h).step_slider(s, true, 200.0);
|
||||
all.push(value(h, s));
|
||||
}
|
||||
all
|
||||
};
|
||||
let paper: Vec<u32> = std::iter::once(0).chain((10..=480).step_by(10)).collect();
|
||||
assert_eq!(reach(H::HdrPaperWhite, &mut s), paper);
|
||||
let peak: Vec<u32> = std::iter::once(0)
|
||||
.chain((200..=2000).step_by(50))
|
||||
.chain((2500..=10_000).step_by(500))
|
||||
.collect();
|
||||
assert_eq!(reach(H::HdrPeak, &mut s), peak);
|
||||
assert_eq!(
|
||||
reach(H::HdrBlackLevel, &mut s),
|
||||
(0..=100).collect::<Vec<u32>>()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn black_level_reads_as_its_lift() {
|
||||
let mut s = Settings::default();
|
||||
let label = |s: &Settings| Item::Horizon(H::HdrBlackLevel).label(s).plain();
|
||||
assert_eq!(label(&s), "Black Level: 0.00");
|
||||
s.horizon.hdr_black_level = 5;
|
||||
assert_eq!(label(&s), "Black Level: 0.05");
|
||||
}
|
||||
/// Horizon's graphics captions are English sentences that the language table keys by
|
||||
/// themselves, so a caption, description or value that is not in `en_us.json` shows in
|
||||
/// English in every language. Every text the screen shows, with each setting cycled
|
||||
@@ -2769,7 +2876,7 @@ mod tests {
|
||||
#[test]
|
||||
fn every_graphics_text_is_in_the_language_table() {
|
||||
let (_, missed) = lang::scoped(lang::Language::load(&[], "de_de"), || {
|
||||
for hdr in [false, true] {
|
||||
for hdr in HdrMode::ALL {
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = hdr;
|
||||
s.horizon.taa = true;
|
||||
|
||||
@@ -261,21 +261,27 @@ fn tone_scale(top: f32, headroom: f32, paper: f32) -> f32 {
|
||||
}
|
||||
|
||||
// Hdr::keep_world: the HUD's backdrop, the world clipped to white with alpha 0, the frame as
|
||||
// the HUD found it before.
|
||||
// the HUD found it before. The HUD's target is 8-bit, as that frame was.
|
||||
@fragment fn fs_keep(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let w = textureLoad(frame, vec2<i32>(pos.xy), 0).rgb;
|
||||
return vec4(clamp(w, vec3(0.0), vec3(1.0)), 0.0);
|
||||
}
|
||||
|
||||
// The backdrop `fs_keep` left in the HUD's 8-bit target for world `w`: where no HUD draws, the
|
||||
// target holds exactly this, so the HUD's own part below is exactly 0 there.
|
||||
fn backdrop(w: vec3<f32>) -> vec3<f32> {
|
||||
return round(clamp(w, vec3(0.0), vec3(1.0)) * 255.0) / 255.0;
|
||||
}
|
||||
|
||||
// The HUD over the world in gamma, the space the HUD blends in: its own part, what its backdrop
|
||||
// B doesn't explain (H = P - (1 - c) B, exact for the layering blends), at UI Brightness, over
|
||||
// (1 - c) of the world, light above white included. Where the world is at or below white, at
|
||||
// 100%, that is the HUD's picture as drawn.
|
||||
fn layered(at: vec2<i32>) -> vec3<f32> {
|
||||
let w = max(textureLoad(frame, at, 0).rgb, vec3(0.0));
|
||||
let p = clamp(textureLoad(hud, at, 0).rgb, vec3(0.0), vec3(1.0));
|
||||
let p = textureLoad(hud, at, 0).rgb;
|
||||
let c = textureLoad(coverage, at, 0).r;
|
||||
let h = max(p - (1.0 - c) * min(w, vec3(1.0)), vec3(0.0));
|
||||
let h = max(p - (1.0 - c) * backdrop(w), vec3(0.0));
|
||||
return h * pow(params.ui, 1.0 / 2.2) + (1.0 - c) * w;
|
||||
}
|
||||
|
||||
@@ -284,6 +290,39 @@ fn layered(at: vec2<i32>) -> vec3<f32> {
|
||||
return vec4(layered(vec2<i32>(pos.xy)), 1.0);
|
||||
}
|
||||
|
||||
// Screenshots keep midtones at their drawn brightness, with a shoulder starting at 0.6
|
||||
// paper white in linear light. Unlike the display curve (whose knee is at least white),
|
||||
// this has an unbounded source range: k + (1-k)*(top-k)/(top+1-2*k) approaches SDR
|
||||
// white smoothly, with slope 1 at k. One scale keeps the world's linear RGB ratios.
|
||||
fn screenshot_world(w: vec3<f32>) -> vec3<f32> {
|
||||
let top = pow(max(w.r, max(w.g, w.b)), 2.2);
|
||||
if top <= 0.6 {
|
||||
return w;
|
||||
}
|
||||
let mapped = 0.6 + 0.4 * (top - 0.6) / (top - 0.2);
|
||||
return w * pow(mapped / top, 1.0 / 2.2);
|
||||
}
|
||||
|
||||
override screenshot_world_only: bool = false;
|
||||
// Off where Horizon lifts nothing (HDR Lighting off, or a shader pack's picture): the world is
|
||||
// clipped at white as SDR draws it.
|
||||
override screenshot_shoulder: bool = true;
|
||||
|
||||
// The ordinary PNG, or the world's icon before the HUD: H as drawn, at 100%, over
|
||||
// the SDR world's light. This never changes the targets used by HDR output or its copy.
|
||||
@fragment fn fs_sdr_screenshot(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let at = vec2<i32>(pos.xy);
|
||||
let w = max(textureLoad(frame, at, 0).rgb, vec3(0.0));
|
||||
let mapped = select(min(w, vec3(1.0)), screenshot_world(w), screenshot_shoulder);
|
||||
if screenshot_world_only {
|
||||
return vec4(mapped, 1.0);
|
||||
}
|
||||
let p = textureLoad(hud, at, 0).rgb;
|
||||
let c = textureLoad(coverage, at, 0).r;
|
||||
let h = max(p - (1.0 - c) * backdrop(w), vec3(0.0));
|
||||
return vec4(h + (1.0 - c) * mapped, 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_hdr_layered(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
if calibrating(pos) {
|
||||
return calibration_picture(pos);
|
||||
|
||||
@@ -47,8 +47,8 @@ pub struct Calibration {
|
||||
step: usize,
|
||||
/// Nits, in the order of `STEPS`.
|
||||
values: [f32; 3],
|
||||
/// Which values were Auto (peak, paper white): saved as Auto again unless the
|
||||
/// player moved them.
|
||||
/// Which values were Auto (peak, paper white; black has none): saved as Auto again
|
||||
/// unless the player moved them.
|
||||
auto: [Option<f32>; 3],
|
||||
dragging: bool,
|
||||
}
|
||||
@@ -57,12 +57,29 @@ pub struct Calibration {
|
||||
const BAND: f32 = 84.0;
|
||||
|
||||
impl Step {
|
||||
/// Range and step of the slider, in nits: the settings' own.
|
||||
fn range(self) -> (f32, f32, f32) {
|
||||
/// The slider's steps past its first: the settings' own.
|
||||
fn steps(self) -> u32 {
|
||||
match self {
|
||||
Step::Black => (0.0, 1.0, 0.01),
|
||||
Step::Peak => (200.0, 2000.0, 50.0),
|
||||
Step::PaperWhite => (10.0, 480.0, 10.0),
|
||||
Step::Black => 100,
|
||||
Step::Peak => crate::graphics::PEAK_STEPS,
|
||||
Step::PaperWhite => 47,
|
||||
}
|
||||
}
|
||||
/// The nits at `step` of the slider: black 0..1 by 0.01, the peak's scale, paper
|
||||
/// white 10..480 by 10.
|
||||
fn nits_at(self, step: u32) -> f32 {
|
||||
match self {
|
||||
Step::Black => step as f32 / 100.0,
|
||||
Step::Peak => crate::graphics::peak_nits_at(step) as f32,
|
||||
Step::PaperWhite => 10.0 + step as f32 * 10.0,
|
||||
}
|
||||
}
|
||||
/// Where `nits` sits on the slider, in steps.
|
||||
fn step_of(self, nits: f32) -> f32 {
|
||||
match self {
|
||||
Step::Black => nits * 100.0,
|
||||
Step::Peak => crate::graphics::peak_step_of(nits),
|
||||
Step::PaperWhite => (nits - 10.0) / 10.0,
|
||||
}
|
||||
}
|
||||
/// The English name, which is also its key in the language table.
|
||||
@@ -130,7 +147,7 @@ impl Calibration {
|
||||
g.paper_white_nits() as f32,
|
||||
],
|
||||
auto: [
|
||||
(g.hdr_black_level == 0).then_some(g.black_level_nits()),
|
||||
None,
|
||||
(g.hdr_peak == 0).then_some(g.peak_nits() as f32),
|
||||
(g.hdr_paper_white == 0).then_some(g.paper_white_nits() as f32),
|
||||
],
|
||||
@@ -140,9 +157,21 @@ impl Calibration {
|
||||
fn current(&self) -> Step {
|
||||
STEPS[self.step]
|
||||
}
|
||||
fn set(&mut self, v: f32) {
|
||||
let (min, max, step) = self.current().range();
|
||||
self.values[self.step] = (min + ((v - min) / step).round() * step).clamp(min, max);
|
||||
/// The slider's position, 0..=1.
|
||||
fn position(&self) -> f32 {
|
||||
let step = self.current();
|
||||
step.step_of(self.values[self.step]) / step.steps() as f32
|
||||
}
|
||||
/// Puts the slider on its step nearest `position`.
|
||||
fn set(&mut self, position: f32) {
|
||||
let step = self.current();
|
||||
let steps = step.steps() as f32;
|
||||
self.values[self.step] = step.nits_at((position * steps).round().clamp(0.0, steps) as u32);
|
||||
}
|
||||
/// Moves the slider `by` steps from the one nearest its value.
|
||||
fn step_by(&mut self, by: f32) {
|
||||
let steps = self.current().steps() as f32;
|
||||
self.set(((self.position() * steps).round() + by) / steps);
|
||||
}
|
||||
fn label(&self) -> Text {
|
||||
let v = self.values[self.step];
|
||||
@@ -184,7 +213,6 @@ impl Calibration {
|
||||
w,
|
||||
rgba(160, 160, 160, 255),
|
||||
);
|
||||
let (min, max, _) = step.range();
|
||||
let (x, y, sw) = l.slider;
|
||||
ui::slider(
|
||||
batch,
|
||||
@@ -193,7 +221,7 @@ impl Calibration {
|
||||
y,
|
||||
sw,
|
||||
&self.label(),
|
||||
(self.values[self.step] - min) / (max - min),
|
||||
self.position(),
|
||||
over(cursor, l.slider) || self.dragging,
|
||||
true,
|
||||
self.dragging,
|
||||
@@ -238,14 +266,12 @@ impl Calibration {
|
||||
if !self.dragging {
|
||||
return Outcome::Nothing;
|
||||
}
|
||||
let (min, max, _) = self.current().range();
|
||||
self.set(min + ui::slider_value(x, sw, cursor.0) * (max - min));
|
||||
self.set(ui::slider_value(x, sw, cursor.0));
|
||||
return Outcome::Changed;
|
||||
}
|
||||
if over(cursor, l.slider) {
|
||||
self.dragging = true;
|
||||
let (min, max, _) = self.current().range();
|
||||
self.set(min + ui::slider_value(x, sw, cursor.0) * (max - min));
|
||||
self.set(ui::slider_value(x, sw, cursor.0));
|
||||
return Outcome::Changed;
|
||||
}
|
||||
if over(cursor, l.cancel) {
|
||||
@@ -273,14 +299,13 @@ impl Calibration {
|
||||
}
|
||||
/// Left and Right step the slider, Enter goes on, Escape cancels.
|
||||
pub fn key(&mut self, key: KeyCode) -> Outcome {
|
||||
let (_, _, step) = self.current().range();
|
||||
match key {
|
||||
KeyCode::ArrowLeft | KeyCode::ArrowDown => {
|
||||
self.set(self.values[self.step] - step);
|
||||
self.step_by(-1.0);
|
||||
Outcome::Changed
|
||||
}
|
||||
KeyCode::ArrowRight | KeyCode::ArrowUp => {
|
||||
self.set(self.values[self.step] + step);
|
||||
self.step_by(1.0);
|
||||
Outcome::Changed
|
||||
}
|
||||
KeyCode::Enter | KeyCode::NumpadEnter => self.next(),
|
||||
@@ -328,4 +353,73 @@ mod tests {
|
||||
assert_eq!(s.horizon.hdr_peak, before.peak_nits() + 50);
|
||||
assert_eq!(s.horizon.hdr_paper_white, before.hdr_paper_white);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_step_reaches_every_value_of_its_setting() {
|
||||
// Black Level 0..1 nit by 0.01; Peak Brightness 200..2000 by 50, then by 500 to PQ's
|
||||
// 10 000; Paper White 10..480 by 10.
|
||||
let expected: [Vec<f32>; 3] = [
|
||||
(0..=100).map(|n| n as f32 / 100.0).collect(),
|
||||
(200..=2000)
|
||||
.step_by(50)
|
||||
.chain((2500..=10_000).step_by(500))
|
||||
.map(|n| n as f32)
|
||||
.collect(),
|
||||
(10..=480).step_by(10).map(|n| n as f32).collect(),
|
||||
];
|
||||
let mut c = Calibration::new(&Settings::default());
|
||||
for (i, expected) in expected.iter().enumerate() {
|
||||
assert_eq!(c.picture(1.0).0, STEPS[i]);
|
||||
for _ in 0..200 {
|
||||
c.key(KeyCode::ArrowLeft);
|
||||
}
|
||||
let mut seen = vec![c.picture(1.0).1];
|
||||
for _ in 0..200 {
|
||||
c.key(KeyCode::ArrowRight);
|
||||
let v = c.picture(1.0).1;
|
||||
if seen.last() != Some(&v) {
|
||||
seen.push(v);
|
||||
}
|
||||
}
|
||||
assert_eq!(&seen, expected, "{:?}", STEPS[i]);
|
||||
c.key(KeyCode::Enter);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_level_saves_the_lift_it_shows_and_peak_reaches_10000() {
|
||||
let mut s = Settings::default();
|
||||
let mut c = Calibration::new(&s);
|
||||
// Untouched, no lift stays no lift.
|
||||
c.save(&mut s);
|
||||
assert_eq!(s.horizon.hdr_black_level, 0);
|
||||
c.key(KeyCode::ArrowRight);
|
||||
c.key(KeyCode::ArrowRight);
|
||||
c.save(&mut s);
|
||||
assert_eq!(s.horizon.hdr_black_level, 2);
|
||||
// Calibrated back down, 0 is no lift: what the step showed.
|
||||
let mut c = Calibration::new(&s);
|
||||
assert!((c.picture(1.0).1 - 0.02).abs() < 1e-6);
|
||||
c.key(KeyCode::ArrowLeft);
|
||||
c.key(KeyCode::ArrowLeft);
|
||||
c.key(KeyCode::Enter);
|
||||
for _ in 0..60 {
|
||||
c.key(KeyCode::ArrowRight);
|
||||
}
|
||||
c.save(&mut s);
|
||||
assert_eq!(s.horizon.hdr_black_level, 0);
|
||||
assert_eq!(s.horizon.black_level_nits(), 0.0);
|
||||
assert_eq!(s.horizon.hdr_peak, 10_000);
|
||||
let mut c = Calibration::new(&s);
|
||||
c.key(KeyCode::Enter);
|
||||
assert_eq!(c.picture(1.0).1, 10_000.0);
|
||||
// One step down from the top is 500 nits; from 2000, 50.
|
||||
c.key(KeyCode::ArrowLeft);
|
||||
assert_eq!(c.picture(1.0).1, 9500.0);
|
||||
s.horizon.hdr_peak = 2000;
|
||||
let mut c = Calibration::new(&s);
|
||||
c.key(KeyCode::Enter);
|
||||
c.key(KeyCode::ArrowLeft);
|
||||
assert_eq!(c.picture(1.0).1, 1950.0);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! GUI scale is applied when drawing).
|
||||
use crate::Result;
|
||||
use horizon_assets::animation::AnimationMetadata;
|
||||
use smallvec::SmallVec;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
@@ -902,18 +903,20 @@ impl HudAtlas {
|
||||
}
|
||||
/// Empty slots written at run time: map textures (game/maps.rs), the
|
||||
/// minimap, a native mod's own image, downloaded skins, world and pack
|
||||
/// icons and the on-screen keyboard's text.
|
||||
/// icons and the on-screen keyboard's text. Their pixels are already zero
|
||||
/// in the destination atlas, so keep the source buffers empty and avoid
|
||||
/// duplicating the atlas-sized allocation during packing.
|
||||
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32), mod_image: bool) {
|
||||
for i in 0..MAP_SLOTS {
|
||||
let name = map_slot_sprite(i).unwrap_or_default();
|
||||
images.push((name.into(), 128, 128, vec![0; 128 * 128 * 4]));
|
||||
images.push((name.into(), 128, 128, Vec::new()));
|
||||
}
|
||||
// The minimap extra's image (game/extras.rs).
|
||||
images.push((
|
||||
MINIMAP_SPRITE.into(),
|
||||
crate::minimap::SIZE as u32,
|
||||
crate::minimap::SIZE as u32,
|
||||
vec![0; crate::minimap::SIZE * crate::minimap::SIZE * 4],
|
||||
Vec::new(),
|
||||
));
|
||||
// A mod's own image (native `mod_image`), only when a mod is loaded.
|
||||
if mod_image {
|
||||
@@ -921,45 +924,29 @@ impl HudAtlas {
|
||||
MOD_IMAGE_SPRITE.into(),
|
||||
MOD_IMAGE_SIZE,
|
||||
MOD_IMAGE_SIZE,
|
||||
vec![0; (MOD_IMAGE_SIZE * MOD_IMAGE_SIZE * 4) as usize],
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
// Downloaded skins (game/scoreboard.rs).
|
||||
for i in 0..SKIN_SLOTS {
|
||||
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
|
||||
images.push((format!("horizon/skin{i}"), 64, 64, Vec::new()));
|
||||
}
|
||||
for i in 0..TRIM_SLOTS {
|
||||
images.push((
|
||||
format!("horizon/trim{i}"),
|
||||
trim.0,
|
||||
trim.1,
|
||||
vec![0; (trim.0 * trim.1 * 4) as usize],
|
||||
));
|
||||
images.push((format!("horizon/trim{i}"), trim.0, trim.1, Vec::new()));
|
||||
}
|
||||
for i in 0..WORLD_ICON_SLOTS {
|
||||
images.push((
|
||||
format!("horizon/world-icon{i}"),
|
||||
64,
|
||||
64,
|
||||
vec![0; 64 * 64 * 4],
|
||||
));
|
||||
images.push((format!("horizon/world-icon{i}"), 64, 64, Vec::new()));
|
||||
}
|
||||
let side = PACK_ICON_SIZE as usize;
|
||||
for i in 0..PACK_ICON_SLOTS {
|
||||
images.push((
|
||||
format!("horizon/pack-icon{i}"),
|
||||
PACK_ICON_SIZE,
|
||||
PACK_ICON_SIZE,
|
||||
vec![0; side * side * 4],
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
// The on-screen keyboard's text and corner masks (ui_theme), rasterized at run time.
|
||||
images.push((
|
||||
"horizon/ui-text".into(),
|
||||
2048,
|
||||
1024,
|
||||
vec![0; 2048 * 1024 * 4],
|
||||
));
|
||||
images.push(("horizon/ui-text".into(), 2048, 1024, Vec::new()));
|
||||
}
|
||||
/// Only the font's metrics, for a game that draws nothing (the
|
||||
/// integrated or dedicated server): text measures as with the full atlas,
|
||||
@@ -1528,10 +1515,12 @@ impl HudAtlas {
|
||||
{
|
||||
translucent.insert(name.clone());
|
||||
}
|
||||
for row in 0..*h {
|
||||
let src = &data[(row * w * 4) as usize..((row + 1) * w * 4) as usize];
|
||||
let dst = (((py + row) * width + px) * 4) as usize;
|
||||
pixels[dst..dst + (w * 4) as usize].copy_from_slice(src);
|
||||
if !data.is_empty() {
|
||||
for row in 0..*h {
|
||||
let src = &data[(row * w * 4) as usize..((row + 1) * w * 4) as usize];
|
||||
let dst = (((py + row) * width + px) * 4) as usize;
|
||||
pixels[dst..dst + (w * 4) as usize].copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
let b = u32::from(bordered.contains(name));
|
||||
let (px, py, w, h) = (px + b, py + b, w - 2 * b, h - 2 * b);
|
||||
@@ -1745,6 +1734,10 @@ pub enum Blend {
|
||||
Additive,
|
||||
/// Alpha blending with an isolated depth buffer and a pixel scissor.
|
||||
Depth([u32; 4]),
|
||||
/// The glint over the `Depth` run before it with the same scissor, in its
|
||||
/// pass: `RenderPipelines.GLINT`'s additive blend, depth tested against the
|
||||
/// model and never written.
|
||||
DepthGlint([u32; 4]),
|
||||
/// `RenderPipelines.VIGNETTE`: the destination darkened by the texture
|
||||
/// (`ZERO, ONE_MINUS_SRC_COLOR`).
|
||||
Vignette,
|
||||
@@ -1842,12 +1835,31 @@ impl HudBatch {
|
||||
}
|
||||
out
|
||||
}
|
||||
fn runs_in(&self, vertices: std::ops::Range<u32>) -> Vec<(std::ops::Range<u32>, Blend)> {
|
||||
let mut runs = self.runs();
|
||||
for (range, _) in &mut runs {
|
||||
*range = range.start.max(vertices.start)..range.end.min(vertices.end);
|
||||
fn runs_in(
|
||||
&self,
|
||||
vertices: std::ops::Range<u32>,
|
||||
) -> SmallVec<[(std::ops::Range<u32>, Blend); 16]> {
|
||||
let mut runs = SmallVec::new();
|
||||
let mut at = vertices.start;
|
||||
for (range, blend) in &self.blends {
|
||||
if range.start > at {
|
||||
let start = at.max(vertices.start);
|
||||
let end = range.start.min(vertices.end);
|
||||
if start < end {
|
||||
runs.push((start..end, Blend::Alpha));
|
||||
}
|
||||
}
|
||||
let start = range.start.max(vertices.start);
|
||||
let end = range.end.min(vertices.end);
|
||||
if start < end {
|
||||
runs.push((start..end, *blend));
|
||||
}
|
||||
at = at.max(range.end);
|
||||
}
|
||||
let end = (self.vertices.len() as u32).min(vertices.end);
|
||||
if at < end {
|
||||
runs.push((at.max(vertices.start)..end, Blend::Alpha));
|
||||
}
|
||||
runs.retain(|(range, _)| !range.is_empty());
|
||||
runs
|
||||
}
|
||||
/// `AbstractContainerScreen.init`: center in integer GUI coordinates.
|
||||
@@ -1901,8 +1913,39 @@ impl HudBatch {
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `RenderTypes.armorCutoutNoCullGlint` (and the items' glint types) over a
|
||||
/// [`Self::quad_3d`] face: `uv` is the face's atlas UV,
|
||||
/// which the shader masks by, and `glint_uv` the transformed glint UV.
|
||||
/// `armor` samples `enchanted_glint_armor` (`ARMOR_ENTITY_GLINT`), otherwise
|
||||
/// `enchanted_glint_item`. Drawn by [`Self::depth_glint_since`].
|
||||
pub fn quad_3d_glint(
|
||||
&mut self,
|
||||
p: [[f32; 3]; 4],
|
||||
uv: [[f32; 2]; 4],
|
||||
glint_uv: [[f32; 2]; 4],
|
||||
armor: bool,
|
||||
) {
|
||||
let marker = if armor { 4.0 } else { 2.0 };
|
||||
for i in [0, 1, 2, 0, 2, 3] {
|
||||
self.vertices.push(HudVertex {
|
||||
pos: [p[i][0], p[i][1]],
|
||||
uv: [uv[i][0] + marker, uv[i][1]],
|
||||
color: u32::MAX,
|
||||
depth: p[i][2],
|
||||
glint_uv: glint_uv[i],
|
||||
text: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Keep per-vertex depth for an orthographic GUI model, with +z toward the viewer.
|
||||
pub fn depth_since(&mut self, start: usize, clip: [f32; 4]) {
|
||||
self.depth_glint_since(start, self.vertices.len(), clip);
|
||||
}
|
||||
/// `RenderPipelines.GLINT` after a GUI model: [`Self::depth_since`] for a model whose glint faces
|
||||
/// ([`Self::quad_3d_glint`]) follow its own from `glint`: both share the
|
||||
/// depth buffer, and the glint is drawn over the model in the same pass.
|
||||
pub fn depth_glint_since(&mut self, start: usize, glint: usize, clip: [f32; 4]) {
|
||||
let glint = glint.clamp(start, self.vertices.len());
|
||||
let vertices = &mut self.vertices[start..];
|
||||
if vertices.is_empty() {
|
||||
return;
|
||||
@@ -1919,11 +1962,17 @@ impl HudBatch {
|
||||
v.depth = (max - v.depth) / (max - min).max(f32::EPSILON);
|
||||
}
|
||||
let [x0, y0, x1, y1] = clip.map(|v| (v * self.scale).max(0.0) as u32);
|
||||
let clip = [x0, y0, x1.saturating_sub(x0), y1.saturating_sub(y0)];
|
||||
// Separate previews clear depth independently, even if their scissors match.
|
||||
self.blends.push((
|
||||
start as u32..self.vertices.len() as u32,
|
||||
Blend::Depth([x0, y0, x1.saturating_sub(x0), y1.saturating_sub(y0)]),
|
||||
));
|
||||
if glint > start {
|
||||
self.blends
|
||||
.push((start as u32..glint as u32, Blend::Depth(clip)));
|
||||
}
|
||||
let end = self.vertices.len();
|
||||
if end > glint {
|
||||
self.blends
|
||||
.push((glint as u32..end as u32, Blend::DepthGlint(clip)));
|
||||
}
|
||||
}
|
||||
pub fn rect_uv(&mut self, x: f32, y: f32, w: f32, h: f32, r: Rect, color: u32) {
|
||||
self.quad(
|
||||
@@ -2537,6 +2586,8 @@ pub struct HudRenderer {
|
||||
invert_pipeline: Pipeline,
|
||||
additive_pipeline: Pipeline,
|
||||
depth_pipeline: Pipeline,
|
||||
/// `RenderPipelines.GLINT` over a GUI model (`Blend::DepthGlint`).
|
||||
depth_glint_pipeline: Pipeline,
|
||||
vignette_pipeline: Pipeline,
|
||||
end_portal_pipeline: Pipeline,
|
||||
/// The layout of `group`, which the CIT glint pipelines share.
|
||||
@@ -2562,6 +2613,7 @@ impl HudRenderer {
|
||||
format: wgpu::TextureFormat,
|
||||
view: &wgpu::TextureView,
|
||||
glint_view: &wgpu::TextureView,
|
||||
glint_armor_view: &wgpu::TextureView,
|
||||
glint_sampler: &wgpu::Sampler,
|
||||
coverage: bool,
|
||||
) -> Result<Self> {
|
||||
@@ -2636,6 +2688,16 @@ impl HudRenderer {
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 6,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
@@ -2666,6 +2728,10 @@ impl HudRenderer {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::Sampler(&linear_sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::TextureView(glint_armor_view),
|
||||
},
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
@@ -2675,7 +2741,8 @@ impl HudRenderer {
|
||||
});
|
||||
let attributes =
|
||||
wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32,4=>Float32x2];
|
||||
let create = |label, blend, fragment_entry, depth: bool| {
|
||||
// `depth`: the model's depth test, and whether the run writes depth.
|
||||
let create = |label, blend, fragment_entry, depth: Option<bool>| {
|
||||
let desc = wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&pipeline_layout),
|
||||
@@ -2690,9 +2757,9 @@ impl HudRenderer {
|
||||
})],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: depth.then_some(wgpu::DepthStencilState {
|
||||
depth_stencil: depth.map(|write| wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_write_enabled: Some(write),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
@@ -2714,12 +2781,12 @@ impl HudRenderer {
|
||||
let source = wgpu::ShaderSource::Wgsl(include_str!("hud.wgsl").into());
|
||||
Pipeline::new(device, &desc, source, coverage).expect("the HUD's coverage")
|
||||
};
|
||||
let pipeline = create("HUD", wgpu::BlendState::ALPHA_BLENDING, "fs_main", false);
|
||||
let pipeline = create("HUD", wgpu::BlendState::ALPHA_BLENDING, "fs_main", None);
|
||||
let depth_pipeline = create(
|
||||
"HUD model",
|
||||
wgpu::BlendState::ALPHA_BLENDING,
|
||||
"fs_main",
|
||||
true,
|
||||
Some(true),
|
||||
);
|
||||
// BlendFunction.INVERT: ONE_MINUS_DST_COLOR, ONE_MINUS_SRC_COLOR,
|
||||
// ONE, ZERO.
|
||||
@@ -2738,7 +2805,7 @@ impl HudRenderer {
|
||||
},
|
||||
},
|
||||
"fs_crosshair",
|
||||
false,
|
||||
None,
|
||||
);
|
||||
// BlendFunction.ADDITIVE: ONE, ONE.
|
||||
let add = wgpu::BlendComponent {
|
||||
@@ -2753,7 +2820,18 @@ impl HudRenderer {
|
||||
alpha: add,
|
||||
},
|
||||
"fs_main",
|
||||
false,
|
||||
None,
|
||||
);
|
||||
// RenderPipelines.GLINT tests depth EQUAL and writes none; the faces are
|
||||
// the model's own, so LEQUAL passes the same pixels.
|
||||
let depth_glint_pipeline = create(
|
||||
"HUD model glint",
|
||||
wgpu::BlendState {
|
||||
color: add,
|
||||
alpha: add,
|
||||
},
|
||||
"fs_main",
|
||||
Some(false),
|
||||
);
|
||||
// RenderPipelines.VIGNETTE: ZERO, ONE_MINUS_SRC_COLOR, ZERO, ONE.
|
||||
let vignette_pipeline = create(
|
||||
@@ -2771,14 +2849,14 @@ impl HudRenderer {
|
||||
},
|
||||
},
|
||||
"fs_vignette",
|
||||
false,
|
||||
None,
|
||||
);
|
||||
// RenderPipelines.END_PORTAL: opaque.
|
||||
let end_portal_pipeline = create(
|
||||
"HUD end portal",
|
||||
wgpu::BlendState::REPLACE,
|
||||
"fs_end_portal",
|
||||
false,
|
||||
None,
|
||||
);
|
||||
let capacity = 1 << 16;
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
@@ -2794,6 +2872,7 @@ impl HudRenderer {
|
||||
invert_pipeline,
|
||||
additive_pipeline,
|
||||
depth_pipeline,
|
||||
depth_glint_pipeline,
|
||||
vignette_pipeline,
|
||||
end_portal_pipeline,
|
||||
layout,
|
||||
@@ -3001,7 +3080,8 @@ impl HudRenderer {
|
||||
if vertices.is_empty() || size.contains(&0) {
|
||||
return;
|
||||
}
|
||||
let mut attachments = vec![Some(wgpu::RenderPassColorAttachment {
|
||||
let mut attachments = [None, None];
|
||||
attachments[0] = Some(wgpu::RenderPassColorAttachment {
|
||||
view: target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
@@ -3009,29 +3089,38 @@ impl HudRenderer {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
});
|
||||
let attachment_count;
|
||||
if let Some(view) = coverage {
|
||||
attachments.push(Some(hud_coverage::attachment(view)));
|
||||
attachments[1] = Some(hud_coverage::attachment(view));
|
||||
attachment_count = 2;
|
||||
} else {
|
||||
attachment_count = 1;
|
||||
}
|
||||
let runs = batch.runs_in(vertices);
|
||||
let mut at = 0;
|
||||
while at < runs.len() {
|
||||
let clip = match runs[at].1 {
|
||||
Blend::Depth(clip) => Some(clip),
|
||||
Blend::Depth(clip) | Blend::DepthGlint(clip) => Some(clip),
|
||||
_ => None,
|
||||
};
|
||||
let end = if clip.is_some() {
|
||||
let end = if let Some(clip) = clip {
|
||||
// The model's glint shares its pass and depth buffer.
|
||||
at + 1
|
||||
+ runs[at + 1..]
|
||||
.iter()
|
||||
.take_while(|(_, b)| *b == Blend::DepthGlint(clip))
|
||||
.count()
|
||||
} else {
|
||||
runs[at..]
|
||||
.iter()
|
||||
.position(|(_, b)| matches!(b, Blend::Depth(_)))
|
||||
.position(|(_, b)| matches!(b, Blend::Depth(_) | Blend::DepthGlint(_)))
|
||||
.map_or(runs.len(), |n| at + n)
|
||||
};
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("HUD"),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("HUD")),
|
||||
color_attachments: &attachments,
|
||||
color_attachments: &attachments[..attachment_count],
|
||||
depth_stencil_attachment: clip.map(|_| wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
@@ -3060,6 +3149,7 @@ impl HudRenderer {
|
||||
Blend::Invert => &self.invert_pipeline,
|
||||
Blend::Additive => &self.additive_pipeline,
|
||||
Blend::Depth(_) => &self.depth_pipeline,
|
||||
Blend::DepthGlint(_) => &self.depth_glint_pipeline,
|
||||
Blend::Vignette => &self.vignette_pipeline,
|
||||
Blend::EndPortal => &self.end_portal_pipeline,
|
||||
Blend::CitGlint(blend) => {
|
||||
@@ -4601,6 +4691,44 @@ texture=wide",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gui_models_glint_follows_it_in_its_depth_pass() {
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
batch.scale = 2.0;
|
||||
let face = |z: f32| [[0.0, 0.0, z], [0.0, 1.0, z], [1.0, 1.0, z], [1.0, 0.0, z]];
|
||||
let uv = [[0.25, 0.5]; 4];
|
||||
let start = batch.vertices.len();
|
||||
batch.quad_3d(face(1.0), uv, u32::MAX);
|
||||
batch.quad_3d(face(3.0), uv, u32::MAX);
|
||||
let glint = batch.vertices.len();
|
||||
batch.quad_3d_glint(face(3.0), uv, [[7.0, 8.0]; 4], true);
|
||||
batch.quad_3d_glint(face(1.0), uv, [[1.0, 2.0]; 4], false);
|
||||
batch.depth_glint_since(start, glint, [1.0, 2.0, 4.0, 6.0]);
|
||||
assert_eq!(
|
||||
batch.runs(),
|
||||
vec![
|
||||
(0..12, Blend::Depth([2, 4, 6, 8])),
|
||||
(12..24, Blend::DepthGlint([2, 4, 6, 8])),
|
||||
]
|
||||
);
|
||||
// The glint shares its face's depth, normalised with the model's.
|
||||
assert_eq!(batch.vertices[12].depth, batch.vertices[6].depth);
|
||||
assert_eq!(batch.vertices[18].depth, batch.vertices[0].depth);
|
||||
// The shader picks the armour glint by u >= 4, the item's by u >= 2,
|
||||
// and masks by the atlas UV under the marker.
|
||||
assert_eq!(batch.vertices[12].uv, [4.25, 0.5]);
|
||||
assert_eq!(batch.vertices[12].glint_uv, [7.0, 8.0]);
|
||||
assert_eq!(batch.vertices[18].uv, [2.25, 0.5]);
|
||||
// A model with no glint keeps a single depth run.
|
||||
let start = batch.vertices.len();
|
||||
batch.quad_3d(face(1.0), uv, u32::MAX);
|
||||
batch.depth_glint_since(start, batch.vertices.len(), [1.0, 2.0, 4.0, 6.0]);
|
||||
assert_eq!(
|
||||
batch.runs().last(),
|
||||
Some(&(24..30, Blend::Depth([2, 4, 6, 8])))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hud_shader_validates_both_fragment_entries() {
|
||||
let module = naga::front::wgsl::parse_str(include_str!("hud.wgsl")).unwrap();
|
||||
|
||||
@@ -12,6 +12,8 @@ struct Hud {
|
||||
@group(0) @binding(3) var glint_tex: texture_2d<f32>;
|
||||
@group(0) @binding(4) var glint_sampler: sampler;
|
||||
@group(0) @binding(5) var linear_sampler: sampler;
|
||||
// enchanted_glint_armor, the armour layers' GlintSampler (entity.wgsl's glint_armor_tex).
|
||||
@group(0) @binding(6) var glint_armor_tex: texture_2d<f32>;
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@@ -32,11 +34,16 @@ struct Out {
|
||||
}
|
||||
@fragment fn fs_main(in: Out) -> @location(0) vec4<f32> {
|
||||
// u >= 2: the GLINT_TEXTURING sample over the item, masked by the item's
|
||||
// texture alpha (item.fsh's ALPHA_CUTOUT and GLINT path).
|
||||
// texture alpha (item.fsh's ALPHA_CUTOUT and GLINT path). u >= 4: the same
|
||||
// over a GUI model's armour, from enchanted_glint_armor (ARMOR_ENTITY_GLINT).
|
||||
if in.uv.x >= 2.0 {
|
||||
let mask = textureSample(atlas, atlas_sampler, in.uv - vec2(2.0, 0.0)).a;
|
||||
let armor = in.uv.x >= 4.0;
|
||||
let offset = select(2.0, 4.0, armor);
|
||||
let mask = textureSample(atlas, atlas_sampler, in.uv - vec2(offset, 0.0)).a;
|
||||
let item_sample = textureSample(glint_tex, glint_sampler, in.glint_uv);
|
||||
let armor_sample = textureSample(glint_armor_tex, glint_sampler, in.glint_uv);
|
||||
if mask < 0.1 { discard; }
|
||||
var glint = hud.size.w * textureSample(glint_tex, glint_sampler, in.glint_uv);
|
||||
var glint = hud.size.w * select(item_sample, armor_sample, armor);
|
||||
let tint = hud.end_portal[2].yzw;
|
||||
if tint.x >= 0.0 {
|
||||
glint = vec4(max(glint.r, max(glint.g, glint.b)) * tint, glint.a);
|
||||
|
||||
@@ -43,7 +43,8 @@ impl Gpu {
|
||||
let view = atlas.create_view(&Default::default());
|
||||
let hud = HudRenderer::new(
|
||||
&device,
|
||||
wgpu::TextureFormat::Rgba16Float,
|
||||
hud_coverage::HUD_FORMAT,
|
||||
&view,
|
||||
&view,
|
||||
&view,
|
||||
&device.create_sampler(&Default::default()),
|
||||
@@ -70,8 +71,16 @@ impl Gpu {
|
||||
})
|
||||
}
|
||||
|
||||
fn draw(&self, batch: &HudBatch, initial: f64, coverage: bool) -> (Vec<u8>, Vec<u8>) {
|
||||
let frame = self.texture(wgpu::TextureFormat::Rgba16Float);
|
||||
/// `batch` drawn on the HUD's target cleared to `backdrop`, with coverage cleared to
|
||||
/// `initial`: the target's and the coverage's 8-bit texels.
|
||||
fn draw(
|
||||
&self,
|
||||
batch: &HudBatch,
|
||||
backdrop: f64,
|
||||
initial: f64,
|
||||
coverage: bool,
|
||||
) -> (Vec<u8>, Vec<u8>) {
|
||||
let frame = self.texture(hud_coverage::HUD_FORMAT);
|
||||
let mask = self.texture(hud_coverage::FORMAT);
|
||||
let depth = self.texture(wgpu::TextureFormat::Depth32Float);
|
||||
let [frame_view, mask_view, depth_view] =
|
||||
@@ -84,7 +93,7 @@ impl Gpu {
|
||||
bytemuck::bytes_of(&HudUniform::new([4.0, 2.0, 1.0, 0.75], batch.end_portal, 0)),
|
||||
);
|
||||
let mut encoder = self.device.create_command_encoder(&Default::default());
|
||||
for (view, value) in [(&frame_view, 0.3), (&mask_view, initial)] {
|
||||
for (view, value) in [(&frame_view, backdrop), (&mask_view, initial)] {
|
||||
let mut attachment = hud_coverage::attachment(view);
|
||||
attachment.ops.load = wgpu::LoadOp::Clear(wgpu::Color {
|
||||
r: value,
|
||||
@@ -139,7 +148,7 @@ impl Gpu {
|
||||
(
|
||||
color
|
||||
.chunks(256)
|
||||
.flat_map(|r| r[..32].iter().copied())
|
||||
.flat_map(|r| r[..16].iter().copied())
|
||||
.collect(),
|
||||
mask.chunks(256)
|
||||
.flat_map(|r| r[..4].iter().copied())
|
||||
@@ -148,8 +157,8 @@ impl Gpu {
|
||||
}
|
||||
|
||||
fn check(&self, batch: &HudBatch, initial: f64, expected: [u8; 4]) {
|
||||
let (plain, _) = self.draw(batch, initial, false);
|
||||
let (color, mask) = self.draw(batch, initial, true);
|
||||
let (plain, _) = self.draw(batch, 0.3, initial, false);
|
||||
let (color, mask) = self.draw(batch, 0.3, initial, true);
|
||||
assert_eq!(color, plain, "coverage changed the primary colour or alpha");
|
||||
for (got, want) in mask.iter().zip(expected.repeat(2)) {
|
||||
assert!(
|
||||
@@ -219,6 +228,46 @@ fn coverage_follows_hud_blends_discard_scissor_and_depth_without_changing_colour
|
||||
gpu.check(&batch, 0.0, [0, 128, 0, 0]);
|
||||
}
|
||||
|
||||
/// The HUD's target is 8-bit as vanilla's frame is, so each draw clips at white before the
|
||||
/// next blends over it: a text highlight added over a light backdrop, then a 50% black panel.
|
||||
#[test]
|
||||
fn each_hud_draw_clips_at_white_as_on_an_8_bit_frame() {
|
||||
let Some(gpu) = Gpu::new() else {
|
||||
return;
|
||||
};
|
||||
let mut batch = HudBatch::new([4, 2]);
|
||||
quad(
|
||||
&mut batch,
|
||||
Blend::Additive,
|
||||
rgba(128, 128, 128, 255),
|
||||
0.0,
|
||||
0.25,
|
||||
);
|
||||
quad(&mut batch, Blend::Alpha, rgba(0, 0, 0, 128), 0.0, 0.25);
|
||||
// Vanilla's frame in integers: 204 + 128 clips at 255, then the panel keeps 127/255 of it.
|
||||
let (backdrop, highlight, panel) = (204u32, 128u32, 128u32);
|
||||
let clipped = (backdrop + highlight).min(255);
|
||||
let want = ((clipped * (255 - panel)) as f64 / 255.0).round() as u8;
|
||||
assert_eq!(want, 127);
|
||||
// The HUD layer's target, and the 8-bit frame a 0.2 pack finishes on in SDR, where the
|
||||
// HUD draws without coverage.
|
||||
for coverage in [true, false] {
|
||||
let (color, mask) = gpu.draw(&batch, f64::from(backdrop) / 255.0, 0.0, coverage);
|
||||
// Unclipped, the panel would have kept 127/255 of 332: 165.
|
||||
for pixel in [0, 1, 4, 5] {
|
||||
assert_eq!(
|
||||
color[pixel * 4..pixel * 4 + 3],
|
||||
[want; 3],
|
||||
"{coverage}: {color:?}"
|
||||
);
|
||||
}
|
||||
// The highlight hides nothing; the panel hides half of what is under it.
|
||||
if coverage {
|
||||
assert_eq!(mask[0], 128, "{mask:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_preserves_each_cit_blend_and_its_destination_alpha() {
|
||||
let Some(mut gpu) = Gpu::new() else {
|
||||
|
||||
@@ -4,8 +4,26 @@
|
||||
//!
|
||||
//! - `room`: what of a view an overlay may use, clear of the boss bars.
|
||||
//! - `plate`: the box behind a notice's text.
|
||||
//! - `tabs`: a collapsed page strip, the name between arrows with a pip per page.
|
||||
//!
|
||||
//! Vanilla's own display lists (`hud_layout`, `screen_draw`) never use the kit: they stay
|
||||
//! exactly vanilla's.
|
||||
pub mod plate;
|
||||
pub mod room;
|
||||
pub mod tabs;
|
||||
|
||||
/// `0xRRGGBB` and an alpha as the batch's colour (`hud::rgba`), for named colour constants.
|
||||
pub const fn colour(rgb: u32, alpha: u8) -> u32 {
|
||||
(alpha as u32) << 24 | (rgb & 0xFF) << 16 | (rgb >> 8 & 0xFF) << 8 | (rgb >> 16 & 0xFF)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_named_colour_is_the_batch_s_rgba() {
|
||||
assert_eq!(
|
||||
super::colour(0x7656FA, 140),
|
||||
crate::hud::rgba(0x76, 0x56, 0xFA, 140)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! A collapsed page strip: the selected page's name between `‹ ›` arrows, with a pip per page
|
||||
//! under it. A page list with more pages than room (the settings shell's, `settings_shell`)
|
||||
//! shows them this way instead of leaving some out.
|
||||
//!
|
||||
//! [`collapsed`] only lays out (pure, testable); [`draw`] paints a laid-out strip. It knows
|
||||
//! nothing of clicks or bumpers: the screen using it decides what each part does.
|
||||
//!
|
||||
//! Sizes are GUI px. The name is cut to the room between the arrows, and when the pips don't all
|
||||
//! fit across the area, the ones shown are a window around the selected page's.
|
||||
//!
|
||||
//! ```
|
||||
//! use horizon_client_render::hud::HudAtlas;
|
||||
//! use horizon_client_render::hud_kit::tabs;
|
||||
//!
|
||||
//! let atlas = HudAtlas::build(std::path::Path::new("/nonexistent")).unwrap();
|
||||
//! let names = ["General", "Camera", "Build"].map(String::from);
|
||||
//! let strip = tabs::collapsed(&atlas, &names, 0, [8.0, 100.0, 112.0, 16.0]);
|
||||
//! assert_eq!(strip.label, "General");
|
||||
//! assert!(!strip.has_prev && strip.has_next);
|
||||
//! assert_eq!(strip.pips.len(), 3);
|
||||
//! ```
|
||||
use crate::hud::{HudAtlas, HudBatch};
|
||||
use crate::hud_kit::colour;
|
||||
|
||||
/// A pip under the strip: its size and the step from one to the next, in GUI px.
|
||||
pub const PIP: [f32; 2] = [3.0, 2.0];
|
||||
pub const PIP_STEP: f32 = 5.0;
|
||||
/// How far, in GUI px, the arrows sit from the name.
|
||||
pub const ARROW_GAP: f32 = 4.0;
|
||||
/// An arrow with a page that way, and one at an end, with none.
|
||||
pub const ARROW: u32 = colour(0x9FA3AE, 255);
|
||||
pub const ARROW_OFF: u32 = colour(0x9FA3AE, 77);
|
||||
/// The pips (the selected one is white).
|
||||
pub const PIP_OFF: u32 = colour(0xFFFFFF, 89);
|
||||
pub const PIP_ON: u32 = colour(0xFFFFFF, 255);
|
||||
/// The name's colour.
|
||||
pub const NAME: u32 = colour(0xFFFFFF, 255);
|
||||
/// The line of text's height in GUI px (vanilla's font, without its shadow).
|
||||
const LINE: f32 = 8.0;
|
||||
|
||||
/// A laid-out strip.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Strip {
|
||||
/// The area it was laid out in, `[x, y, w, h]` GUI px.
|
||||
pub area: [f32; 4],
|
||||
/// The selected page's index in the names, and its name, cut to fit.
|
||||
pub selected: usize,
|
||||
pub label: String,
|
||||
/// Where the name is drawn, `[x, y, w, h]`.
|
||||
pub label_area: [f32; 4],
|
||||
/// The arrows' text positions `[x, y]` (before, after).
|
||||
pub arrows: [[f32; 2]; 2],
|
||||
/// Whether there is a page before and after the selected one: an arrow without is dimmed.
|
||||
/// A screen may change these (a search skipping pages without matches).
|
||||
pub has_prev: bool,
|
||||
pub has_next: bool,
|
||||
/// The pips `[x, y, w, h]` under the area, and whether each is the selected page's. At most
|
||||
/// as many as fit across the area.
|
||||
pub pips: Vec<([f32; 4], bool)>,
|
||||
}
|
||||
|
||||
/// The strip for `names` with `selected` in `area` (`[x, y, w, h]`, GUI px): the selected name
|
||||
/// between `‹ ›` arrows, cut to fit, with pips under the area. Past the end, the last page.
|
||||
pub fn collapsed(atlas: &HudAtlas, names: &[String], selected: usize, area: [f32; 4]) -> Strip {
|
||||
let [x, y, w, h] = area;
|
||||
let n = names.len();
|
||||
let selected = selected.min(n.saturating_sub(1));
|
||||
let text_y = y + ((h - LINE) / 2.0).floor();
|
||||
let centre = x + w / 2.0;
|
||||
let arrow_w = atlas.font.width("‹").max(atlas.font.width("›"));
|
||||
let room = (w - 2.0 * (arrow_w + ARROW_GAP)).max(0.0);
|
||||
let name = names.get(selected).map_or("", String::as_str);
|
||||
let label = crate::graphics_style::fit(atlas, name, room);
|
||||
let label_w = atlas.font.width(&label).ceil();
|
||||
let left = (centre - label_w / 2.0).floor();
|
||||
let arrows = [
|
||||
[left - ARROW_GAP - arrow_w, text_y],
|
||||
[left + label_w + ARROW_GAP, text_y],
|
||||
];
|
||||
// As many pips as fit across the area, a window round the selected one.
|
||||
let fit = (((w - PIP[0]) / PIP_STEP).floor() as usize + 1).min(n);
|
||||
let first = selected.saturating_sub(fit / 2).min(n - fit.min(n));
|
||||
let pips_x = (centre - (fit as f32 * PIP_STEP - (PIP_STEP - PIP[0])) / 2.0).floor();
|
||||
let pips = (0..fit)
|
||||
.map(|i| {
|
||||
let px = pips_x + i as f32 * PIP_STEP;
|
||||
([px, y + h + 1.0, PIP[0], PIP[1]], first + i == selected)
|
||||
})
|
||||
.collect();
|
||||
Strip {
|
||||
area,
|
||||
selected,
|
||||
label,
|
||||
label_area: [left, y, label_w, h],
|
||||
arrows,
|
||||
has_prev: selected > 0,
|
||||
has_next: selected + 1 < n,
|
||||
pips,
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints `strip`: the name, its arrows (dimmed at an end) and the pips.
|
||||
pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, strip: &Strip) {
|
||||
let [lx, ly, ..] = strip.label_area;
|
||||
let text_y = ly + ((strip.area[3] - LINE) / 2.0).floor();
|
||||
batch.text(atlas, &strip.label, lx, text_y, NAME, true);
|
||||
let lit = [strip.has_prev, strip.has_next];
|
||||
for (([ax, ay], arrow), on) in strip.arrows.into_iter().zip(["‹", "›"]).zip(lit) {
|
||||
batch.text(
|
||||
atlas,
|
||||
arrow,
|
||||
ax,
|
||||
ay,
|
||||
if on { ARROW } else { ARROW_OFF },
|
||||
true,
|
||||
);
|
||||
}
|
||||
for (pip, on) in &strip.pips {
|
||||
let [px, py, pw, ph] = *pip;
|
||||
batch.fill(atlas, px, py, pw, ph, if *on { PIP_ON } else { PIP_OFF });
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
fn names(list: &[&str]) -> Vec<String> {
|
||||
list.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
const AREA: [f32; 4] = [8.0, 100.0, 112.0, 16.0];
|
||||
|
||||
#[test]
|
||||
fn the_name_sits_centred_between_its_arrows_with_a_pip_per_page() {
|
||||
let atlas = atlas();
|
||||
let pages = names(&["General", "Sky", "Water", "Fog"]);
|
||||
let strip = collapsed(&atlas, &pages, 1, AREA);
|
||||
assert_eq!(strip.label, "Sky");
|
||||
let [nx, _, nw, _] = strip.label_area;
|
||||
assert!((nx + nw / 2.0 - 64.0).abs() <= 1.0, "{nx} {nw}");
|
||||
let [[before, _], [after, _]] = strip.arrows;
|
||||
assert!(before < nx && after >= nx + nw);
|
||||
let on: Vec<bool> = strip.pips.iter().map(|(_, on)| *on).collect();
|
||||
assert_eq!(on, [false, true, false, false]);
|
||||
// The pips sit under the row, centred.
|
||||
let (first, last) = (strip.pips[0].0, strip.pips[3].0);
|
||||
assert!(first[1] >= AREA[1] + AREA[3]);
|
||||
assert!(((first[0] + last[0] + last[2]) / 2.0 - 64.0).abs() <= 1.0);
|
||||
// Past the end, the last page; no pages, nothing to the arrows' sides.
|
||||
assert_eq!(collapsed(&atlas, &pages, 9, AREA).selected, 3);
|
||||
let none = collapsed(&atlas, &[], 0, AREA);
|
||||
assert!(none.label.is_empty() && none.pips.is_empty());
|
||||
assert!(!none.has_prev && !none.has_next);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_name_is_cut_to_stay_between_its_arrows() {
|
||||
let atlas = atlas();
|
||||
let long = names(&["A page with a very long name", "B"]);
|
||||
let strip = collapsed(&atlas, &long, 0, [0.0, 0.0, 80.0, 12.0]);
|
||||
let [_, [after, _]] = strip.arrows;
|
||||
assert!(after + atlas.font.width("›") <= 80.0, "{after}");
|
||||
assert!(strip.label.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ends_have_no_page_beyond_and_draw_their_arrow_dimmed() {
|
||||
let atlas = atlas();
|
||||
let pages = names(&["A", "B", "C"]);
|
||||
let ends = [(0, false, true), (1, true, true), (2, true, false)];
|
||||
for (selected, prev, next) in ends {
|
||||
let strip = collapsed(&atlas, &pages, selected, AREA);
|
||||
assert_eq!((strip.has_prev, strip.has_next), (prev, next));
|
||||
}
|
||||
// The first page: its `‹` is drawn in the dimmed colour, its `›` in the arrow's.
|
||||
let alpha = |strip: &Strip, i: usize| {
|
||||
let mut batch = HudBatch::new([854, 480]);
|
||||
draw(&mut batch, &atlas, strip);
|
||||
let [ax, _] = strip.arrows[i];
|
||||
(batch.vertices.iter())
|
||||
.filter(|v| {
|
||||
(v.pos[0] - ax).abs() < 4.0 && v.color & 0x00FF_FFFF == ARROW & 0x00FF_FFFF
|
||||
})
|
||||
.map(|v| v.color >> 24)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let first = collapsed(&atlas, &pages, 0, AREA);
|
||||
assert_eq!(alpha(&first, 0), ARROW_OFF >> 24);
|
||||
assert_eq!(alpha(&first, 1), ARROW >> 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_pages_show_as_many_pips_as_fit_round_the_selected_one() {
|
||||
let atlas = atlas();
|
||||
let pages: Vec<String> = (0..60).map(|i| format!("Page {i}")).collect();
|
||||
let [x, _, w, _] = AREA;
|
||||
for selected in [0, 1, 30, 58, 59] {
|
||||
let strip = collapsed(&atlas, &pages, selected, AREA);
|
||||
assert!(strip.pips.len() < pages.len());
|
||||
for ([px, _, pw, _], _) in &strip.pips {
|
||||
assert!(*px >= x && px + pw <= x + w, "{selected}: {px}");
|
||||
}
|
||||
assert_eq!(strip.pips.iter().filter(|p| p.1).count(), 1, "{selected}");
|
||||
}
|
||||
// At the ends, the lit pip is at the window's end too.
|
||||
let first = collapsed(&atlas, &pages, 0, AREA);
|
||||
assert!(first.pips[0].1);
|
||||
let last = collapsed(&atlas, &pages, 59, AREA);
|
||||
assert!(last.pips.last().unwrap().1);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer,
|
||||
//! the HUD, menus and settings. Gameplay (horizon-client) builds on it; nothing here reaches gameplay.
|
||||
#![forbid(unsafe_code)]
|
||||
pub mod android_gamepad;
|
||||
pub mod assets;
|
||||
pub mod atlas_sources;
|
||||
pub mod audio;
|
||||
@@ -82,7 +83,9 @@ pub mod options_fixes;
|
||||
pub mod options_ui;
|
||||
pub mod overlay;
|
||||
pub mod pack_browser;
|
||||
pub mod pack_options_ui;
|
||||
pub mod pack_screen;
|
||||
pub mod pack_tags;
|
||||
pub mod packs;
|
||||
pub mod pad_cursor;
|
||||
pub mod pad_glyph;
|
||||
@@ -90,6 +93,7 @@ pub mod paged_maps;
|
||||
pub mod par;
|
||||
pub mod particles;
|
||||
pub mod poi_sections;
|
||||
pub mod polytone_post;
|
||||
pub mod post_chain;
|
||||
pub mod post_glsl;
|
||||
pub mod potent_sulfur;
|
||||
@@ -125,7 +129,6 @@ pub mod streaming;
|
||||
pub mod structure_block;
|
||||
pub mod studio_ui;
|
||||
pub mod tab_list;
|
||||
pub mod telemetry;
|
||||
pub mod test_block;
|
||||
pub mod test_instance_block;
|
||||
pub mod testgen;
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::{
|
||||
};
|
||||
pub mod bake;
|
||||
pub mod cache;
|
||||
mod kept;
|
||||
mod mesh;
|
||||
pub mod packed;
|
||||
mod samples;
|
||||
@@ -202,6 +203,15 @@ pub const MAX_CHUNKS: u32 = 1 << 22;
|
||||
/// structures) rather than sampled: cells of up to 4 blocks, out to about
|
||||
/// 1024 blocks at Full far detail.
|
||||
const DETAIL_LEVEL: u8 = 2;
|
||||
/// Kept tiles come back a level at a time (`Lod::bring_back`) while at most
|
||||
/// this many drawn tiles wait for their meshes.
|
||||
const RETURN_BACKLOG: usize = 32;
|
||||
/// Kept tiles of cells a chunk wide or less (levels up to this) come back once
|
||||
/// the near terrain is in and the seam with it sampled, or this long after the
|
||||
/// return: their thousands of meshes held up the near chunks' and the seam's,
|
||||
/// showing caves through unmeshed ground meanwhile.
|
||||
const RETURN_FINE_LEVEL: u8 = 4;
|
||||
const RETURN_SEAM_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
/// The most chunks one detail request asks for, one request each 250 ms:
|
||||
/// generated ones arrive at the decoration threads' pace, cached ones at once.
|
||||
const DETAIL_REQUEST: usize = 512;
|
||||
@@ -1321,6 +1331,12 @@ pub struct Lod {
|
||||
saved: Saved,
|
||||
reduced: Reduced,
|
||||
saved_order: VecDeque<(i32, i32)>,
|
||||
/// Tiles the plan let go, taken back on a return instead of sampled again.
|
||||
kept: kept::KeptTiles,
|
||||
/// Kept tiles the plan wants again, not taken back yet (`bring_back`).
|
||||
returning: Vec<TileKey>,
|
||||
/// When the plan last asked kept tiles back (`RETURN_SEAM_WAIT`).
|
||||
returned_at: std::time::Instant,
|
||||
/// `LodSource::keeps_observed`: tiles sampled without an observed chunk
|
||||
/// are sampled again (`resample_over`).
|
||||
keeps_observed: bool,
|
||||
@@ -1688,6 +1704,9 @@ impl Lod {
|
||||
saved: FxHashMap::default(),
|
||||
reduced: FxHashMap::default(),
|
||||
saved_order: VecDeque::new(),
|
||||
kept: kept::KeptTiles::new(kept::KEPT_FOR, kept::KEPT_BYTES),
|
||||
returning: Vec::new(),
|
||||
returned_at: std::time::Instant::now(),
|
||||
keeps_observed,
|
||||
}
|
||||
}
|
||||
@@ -1713,6 +1732,10 @@ impl Lod {
|
||||
pub fn stats(&self) -> (usize, usize) {
|
||||
(self.active.len(), self.ready.len())
|
||||
}
|
||||
/// The tiles kept for a return, and about how many bytes they hold.
|
||||
pub fn kept(&self) -> (usize, usize) {
|
||||
(self.kept.len(), self.kept.bytes())
|
||||
}
|
||||
pub fn progress(&self) -> (usize, usize) {
|
||||
let mut desired = 0;
|
||||
let mut sampled = 0;
|
||||
@@ -2030,6 +2053,8 @@ impl Lod {
|
||||
if self.retry.len() != retrying {
|
||||
self.wanted_stale = true;
|
||||
}
|
||||
// Kept tiles there are as old: sampled again if the plan wants them.
|
||||
self.kept.forget(|key, _| key.refreshed_by(area, widths));
|
||||
// A first sample in flight read the old data too.
|
||||
let keys: Vec<_> = self
|
||||
.ready
|
||||
@@ -2137,6 +2162,21 @@ impl Lod {
|
||||
for key in resampled_over(&self.ready, &self.pending, chunk) {
|
||||
self.mark_stale(key);
|
||||
}
|
||||
// Kept tiles with the chunk's hole: sampled again if the plan wants them.
|
||||
let (cx, cz) = chunk;
|
||||
self.kept.forget(|key, samples| {
|
||||
let size = key.size();
|
||||
let stride = size / CELLS as i32;
|
||||
let [ox, _, oz] = key.origin();
|
||||
stride > 16
|
||||
&& (ox..ox + size).contains(&(cx * 16))
|
||||
&& (oz..oz + size).contains(&(cz * 16))
|
||||
&& samples
|
||||
.get(
|
||||
((cz * 16 - oz) / stride * CELLS as i32 + (cx * 16 - ox) / stride) as usize,
|
||||
)
|
||||
.is_some_and(|c| *c == Column::default())
|
||||
});
|
||||
}
|
||||
/// A chunk's own columns as its level-0 tile; the same columns again
|
||||
/// (asked again after a `refresh`) mesh nothing.
|
||||
@@ -2464,6 +2504,8 @@ impl Lod {
|
||||
self.area = Some(area);
|
||||
self.replan(camera, detail, distance, elevation, aim);
|
||||
});
|
||||
} else if self.kept.due() {
|
||||
self.kept.trim();
|
||||
}
|
||||
let mut meshes = Vec::new();
|
||||
crate::spans::time("lod_results", || self.receive(&mut meshes));
|
||||
@@ -2473,6 +2515,7 @@ impl Lod {
|
||||
// and the nearest first, where far terrain meets the near chunks.
|
||||
meshes.sort_by_key(|(key, _)| (key.level, key.distance(camera)));
|
||||
crate::spans::time("lod_covered", || self.cover(covered));
|
||||
crate::spans::time("lod_return", || self.bring_back());
|
||||
if std::mem::take(&mut self.reselect) {
|
||||
self.rebuild_selection();
|
||||
}
|
||||
@@ -2513,7 +2556,41 @@ impl Lod {
|
||||
.copied()
|
||||
.collect();
|
||||
self.plan = plan;
|
||||
self.ready.retain(|key, _| self.plan.nodes.contains(key));
|
||||
self.kept.fit_to_view(self.plan.nodes.len());
|
||||
// Tiles leaving the plan are kept for a return, with their detail. Not
|
||||
// stale ones (their source has more), nor ones of cells a chunk wide or
|
||||
// less over the full-detail columns: never drawn there, they came back
|
||||
// as flat grey ground around the near terrain until it loaded.
|
||||
let leaving: Vec<_> = self
|
||||
.ready
|
||||
.keys()
|
||||
.filter(|key| !self.plan.nodes.contains(key))
|
||||
.copied()
|
||||
.collect();
|
||||
for key in leaving {
|
||||
let columns = self.ready.remove(&key).unwrap();
|
||||
if !self.stale.contains(&key) && !self.over_covered(key) {
|
||||
let mask = self.detail_masks.get(&key).copied();
|
||||
self.kept.keep(key, columns, mask);
|
||||
}
|
||||
}
|
||||
self.kept.trim();
|
||||
// Those the plan wants again come back (`bring_back`) instead of being
|
||||
// sampled: a level at a time, coarsest first. Not at the seam with the
|
||||
// near chunks (`Plan::stands_in`), sampled first and fresh there: kept
|
||||
// ones drew grey ground and the near chunks' caves until they loaded.
|
||||
let (returning, seam): (Vec<_>, Vec<_>) = self
|
||||
.plan
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|key| self.kept.contains(key) && !self.ready.contains_key(key))
|
||||
.copied()
|
||||
.partition(|key| self.plan.stands_in(*key));
|
||||
for key in seam {
|
||||
self.kept.take(&key);
|
||||
}
|
||||
self.returning = returning;
|
||||
self.returned_at = std::time::Instant::now();
|
||||
let nodes = &self.plan.nodes;
|
||||
self.mesh_samples.retain(|key| nodes.contains(key));
|
||||
// Queued tiles the view left are not sampled (a running one is
|
||||
@@ -2542,6 +2619,81 @@ impl Lod {
|
||||
self.mark_side_neighbors_of(key);
|
||||
}
|
||||
}
|
||||
/// Whether a tile of cells a chunk wide or less lies over a covered column.
|
||||
fn over_covered(&self, key: TileKey) -> bool {
|
||||
if key.size() > 16 * CELLS as i32 {
|
||||
return false;
|
||||
}
|
||||
let [ox, _, oz] = key.origin();
|
||||
let size = key.size();
|
||||
if let Some([x0, z0, x1, z1]) = self.covered_bounds
|
||||
&& (ox >= x1 || oz >= z1 || ox + size <= x0 || oz + size <= z0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let chunks = (size / 16).max(1);
|
||||
(0..chunks * chunks).any(|i| {
|
||||
self.covered.contains(&(
|
||||
ox.div_euclid(16) + i % chunks,
|
||||
oz.div_euclid(16) + i / chunks,
|
||||
))
|
||||
})
|
||||
}
|
||||
/// Whether a tile at the seam with the near chunks (`Plan::stands_in`)
|
||||
/// still waits for its samples.
|
||||
fn seam_open(&self) -> bool {
|
||||
self.plan.nodes.iter().any(|key| {
|
||||
!self.plan.stands_in(*key)
|
||||
&& !self.plan.split.contains(key)
|
||||
&& !self.ready.contains_key(key)
|
||||
})
|
||||
}
|
||||
/// Takes kept tiles back into the plan, the coarsest level first, the next
|
||||
/// once the meshes are in: all of them at once left the sky showing
|
||||
/// through every tile not meshed yet, where sampling fills a view coarse
|
||||
/// first and refines it.
|
||||
fn bring_back(&mut self) {
|
||||
if self.returning.is_empty() || self.unmeshed.len() > RETURN_BACKLOG {
|
||||
return;
|
||||
}
|
||||
let level = self
|
||||
.returning
|
||||
.iter()
|
||||
.map(|key| key.level)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
if level <= RETURN_FINE_LEVEL
|
||||
&& self.returned_at.elapsed() < RETURN_SEAM_WAIT
|
||||
&& (self.near_loading || self.seam_open())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let (now, later): (Vec<_>, Vec<_>) =
|
||||
self.returning.drain(..).partition(|key| key.level == level);
|
||||
self.returning = later;
|
||||
// Tiles forgotten meanwhile (`refresh`) are sampled: the queue skipped them.
|
||||
self.wanted_stale = true;
|
||||
let mut returned = Vec::new();
|
||||
for key in now {
|
||||
if self.ready.contains_key(&key) || self.pending.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
if let Some((columns, mask)) = self.kept.take(&key) {
|
||||
self.ready.insert(key, columns);
|
||||
if let Some(mask) = mask {
|
||||
self.detail_masks.insert(key, mask);
|
||||
}
|
||||
returned.push(key);
|
||||
}
|
||||
}
|
||||
if returned.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.rebuild_selection();
|
||||
for key in returned {
|
||||
self.refresh_path(key);
|
||||
}
|
||||
}
|
||||
/// Recomputes every active tile after a plan change, then marks only the
|
||||
/// tiles whose selection changed (plus their neighbours).
|
||||
fn rebuild_selection(&mut self) {
|
||||
@@ -3020,6 +3172,7 @@ impl Lod {
|
||||
|| (self.plan.split.contains(&key) && wanted.class != BACKDROP)
|
||||
|| !self.plan.nodes.contains(&key)
|
||||
|| self.retry.contains_key(&key)
|
||||
|| self.kept.contains(&key)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -3947,6 +4100,90 @@ mod tests {
|
||||
}
|
||||
/// Detail read before a refresh over its chunk is dropped (asked
|
||||
/// again); elsewhere, or read after, it is kept.
|
||||
/// Out and home again: the tiles the trip let go come back, the coarsest
|
||||
/// level first and a level once the meshes are in, without a sample job.
|
||||
#[test]
|
||||
fn a_return_takes_kept_tiles_back_coarse_first_instead_of_sampling() {
|
||||
let mut lod = Lod::scheduler_for_test();
|
||||
let filled = Arc::new(vec![
|
||||
Column::from_surface(Some(Surface {
|
||||
height: 70,
|
||||
state: BlockStateId(1),
|
||||
water: None,
|
||||
biome: 0,
|
||||
}));
|
||||
CELLS * CELLS
|
||||
]);
|
||||
lod.replan([0, 0], 2, 64, 0, None);
|
||||
let home: Vec<_> = lod.plan.nodes.iter().copied().collect();
|
||||
for &key in &home {
|
||||
lod.ready.insert(key, filled.clone());
|
||||
}
|
||||
lod.replan([200_000, 0], 2, 64, 0, None);
|
||||
let left: Vec<_> = home
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|key| !lod.plan.nodes.contains(key))
|
||||
.collect();
|
||||
assert!(!left.is_empty() && left.iter().all(|key| !lod.ready.contains_key(key)));
|
||||
lod.replan([0, 0], 2, 64, 0, None);
|
||||
// Those at the seam with the near chunks are sampled fresh instead.
|
||||
let (wanted, seam): (Vec<_>, Vec<_>) = left
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|key| lod.plan.nodes.contains(key))
|
||||
.partition(|key| lod.plan.stands_in(*key));
|
||||
assert!(!wanted.is_empty() && !seam.is_empty());
|
||||
assert!(seam.iter().all(|key| !lod.kept.contains(key)));
|
||||
// Nothing else to sample: they are on their way back.
|
||||
lod.request_samples([0, 0]);
|
||||
assert!(wanted.iter().all(|key| !lod.pending.contains(key)));
|
||||
assert!(seam.iter().any(|key| lod.pending.contains(key)));
|
||||
let coarsest = wanted.iter().map(|key| key.level).max().unwrap();
|
||||
// Meshes outstanding: nothing comes back yet.
|
||||
lod.unmeshed
|
||||
.extend((0..=RETURN_BACKLOG as i32).map(|x| TileKey {
|
||||
x,
|
||||
z: 9_999,
|
||||
level: 0,
|
||||
}));
|
||||
lod.bring_back();
|
||||
assert!(wanted.iter().all(|key| !lod.ready.contains_key(key)));
|
||||
lod.unmeshed.clear();
|
||||
lod.bring_back();
|
||||
for key in &wanted {
|
||||
assert_eq!(
|
||||
lod.ready.contains_key(key),
|
||||
key.level == coarsest,
|
||||
"{key:?}"
|
||||
);
|
||||
}
|
||||
// Finer ones wait for the seam's samples.
|
||||
for _ in 0..64 {
|
||||
lod.bring_back();
|
||||
}
|
||||
assert!(
|
||||
!lod.returning.is_empty()
|
||||
&& lod
|
||||
.returning
|
||||
.iter()
|
||||
.all(|key| key.level <= RETURN_FINE_LEVEL)
|
||||
);
|
||||
for key in &seam {
|
||||
lod.ready.insert(*key, filled.clone());
|
||||
}
|
||||
assert!(!lod.seam_open());
|
||||
while !lod.returning.is_empty() {
|
||||
lod.unmeshed.clear();
|
||||
lod.bring_back();
|
||||
}
|
||||
assert!(
|
||||
wanted
|
||||
.iter()
|
||||
.all(|key| lod.ready.get(key).is_some_and(|s| Arc::ptr_eq(s, &filled)))
|
||||
);
|
||||
assert!(lod.pending.iter().all(|key| seam.contains(key)));
|
||||
}
|
||||
#[test]
|
||||
fn only_detail_a_later_refresh_covers_is_dropped() {
|
||||
let mut lod = Lod::scheduler_for_test();
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Far tiles the plan let go, kept a while for a return: a teleport back, a
|
||||
//! trip out and home again, the far distance lowered and raised. Taken back
|
||||
//! (`Lod::replan`) instead of sampled again, which costs every far tile its
|
||||
//! noise, biomes and surface rules a second time.
|
||||
//!
|
||||
//! Bounded in time (`KEPT_FOR`) and in memory (`KEPT_BYTES`); past the bytes
|
||||
//! the finest go first, so the coarse shape of every place nearby stays
|
||||
//! longest. Nothing is written to disk: the tiles leave with the `Lod`, that
|
||||
//! is with the world.
|
||||
use super::{Column, Span, TileKey};
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use std::{
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// How long a tile is kept after the plan let it go.
|
||||
pub(super) const KEPT_FOR: Duration = Duration::from_secs(600);
|
||||
/// About as many bytes as a 512-chunk view's tiles hold, so one whole place
|
||||
/// is kept; a far tile is some 20 to 60 KB. The most kept at any far distance.
|
||||
pub(super) const KEPT_BYTES: usize = 512 << 20;
|
||||
/// The tiles a 512-chunk view plans: a smaller view keeps a share of
|
||||
/// `KEPT_BYTES` as large as its share of these (`KeptTiles::fit_to_view`), so
|
||||
/// travelling with a short far distance doesn't fill the whole budget.
|
||||
const VIEW_512_TILES: usize = 4700;
|
||||
|
||||
struct Kept {
|
||||
columns: Arc<Vec<Column>>,
|
||||
/// Its detail (`Lod::detail_masks`), so refined cells are not asked again.
|
||||
mask: Option<[u64; 4]>,
|
||||
at: Instant,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
pub(super) struct KeptTiles {
|
||||
tiles: FxHashMap<TileKey, Kept>,
|
||||
bytes: usize,
|
||||
checked: Instant,
|
||||
time: Duration,
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
impl KeptTiles {
|
||||
pub(super) fn new(time: Duration, budget: usize) -> Self {
|
||||
Self {
|
||||
tiles: FxHashMap::default(),
|
||||
bytes: 0,
|
||||
checked: Instant::now(),
|
||||
time,
|
||||
budget,
|
||||
}
|
||||
}
|
||||
|
||||
/// The budget for a view that plans `tiles`: `KEPT_BYTES` for a 512-chunk
|
||||
/// view, a share of it for a smaller one.
|
||||
pub(super) fn fit_to_view(&mut self, tiles: usize) {
|
||||
self.budget = (KEPT_BYTES / VIEW_512_TILES)
|
||||
.saturating_mul(tiles)
|
||||
.min(KEPT_BYTES);
|
||||
}
|
||||
|
||||
pub(super) fn len(&self) -> usize {
|
||||
self.tiles.len()
|
||||
}
|
||||
|
||||
pub(super) fn bytes(&self) -> usize {
|
||||
self.bytes
|
||||
}
|
||||
|
||||
pub(super) fn contains(&self, key: &TileKey) -> bool {
|
||||
self.tiles.contains_key(key)
|
||||
}
|
||||
|
||||
pub(super) fn keep(&mut self, key: TileKey, columns: Arc<Vec<Column>>, mask: Option<[u64; 4]>) {
|
||||
// Spans past the inline four live on the heap.
|
||||
let bytes = std::mem::size_of_val(columns.as_slice())
|
||||
+ columns
|
||||
.iter()
|
||||
.filter(|column| column.spans.len() > 4)
|
||||
.map(|column| column.spans.len() * std::mem::size_of::<Span>())
|
||||
.sum::<usize>();
|
||||
let kept = Kept {
|
||||
columns,
|
||||
mask,
|
||||
at: Instant::now(),
|
||||
bytes,
|
||||
};
|
||||
self.bytes += bytes;
|
||||
if let Some(old) = self.tiles.insert(key, kept) {
|
||||
self.bytes -= old.bytes;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn take(&mut self, key: &TileKey) -> Option<(Arc<Vec<Column>>, Option<[u64; 4]>)> {
|
||||
let kept = self.tiles.remove(key)?;
|
||||
self.bytes -= kept.bytes;
|
||||
Some((kept.columns, kept.mask))
|
||||
}
|
||||
|
||||
/// Forgets the tiles `stale` picks: their source has more than they hold.
|
||||
pub(super) fn forget(&mut self, mut stale: impl FnMut(&TileKey, &[Column]) -> bool) {
|
||||
let bytes = &mut self.bytes;
|
||||
self.tiles.retain(|key, kept| {
|
||||
let keep = !stale(key, &kept.columns);
|
||||
if !keep {
|
||||
*bytes -= kept.bytes;
|
||||
}
|
||||
keep
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether a minute went by since the last `trim`.
|
||||
pub(super) fn due(&self) -> bool {
|
||||
!self.tiles.is_empty() && self.checked.elapsed() >= Duration::from_secs(60)
|
||||
}
|
||||
|
||||
/// Drops the tiles kept past their time, then, past the budget, the
|
||||
/// finest and the oldest first.
|
||||
pub(super) fn trim(&mut self) {
|
||||
self.checked = Instant::now();
|
||||
let (bytes, time) = (&mut self.bytes, self.time);
|
||||
self.tiles.retain(|_, kept| {
|
||||
let keep = kept.at.elapsed() < time;
|
||||
if !keep {
|
||||
*bytes -= kept.bytes;
|
||||
}
|
||||
keep
|
||||
});
|
||||
if self.bytes <= self.budget {
|
||||
return;
|
||||
}
|
||||
let mut order: Vec<_> = self
|
||||
.tiles
|
||||
.iter()
|
||||
.map(|(key, kept)| (key.level, kept.at, *key))
|
||||
.collect();
|
||||
order.sort_unstable();
|
||||
for (_, _, key) in order {
|
||||
if self.bytes <= self.budget {
|
||||
break;
|
||||
}
|
||||
self.take(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::lod::{CELLS, Surface};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
fn tile(height: i32) -> Arc<Vec<Column>> {
|
||||
Arc::new(vec![
|
||||
Column::from_surface(Some(Surface {
|
||||
height,
|
||||
state: BlockStateId(1),
|
||||
water: None,
|
||||
biome: 0,
|
||||
}));
|
||||
CELLS * CELLS
|
||||
])
|
||||
}
|
||||
|
||||
fn key(level: u8) -> TileKey {
|
||||
TileKey { x: 3, z: -2, level }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_tile_comes_back_with_its_detail_once() {
|
||||
let mut kept = KeptTiles::new(KEPT_FOR, usize::MAX);
|
||||
kept.keep(key(0), tile(64), Some([u64::MAX; 4]));
|
||||
let (columns, mask) = kept.take(&key(0)).unwrap();
|
||||
assert_eq!(columns[0].surface.unwrap().height, 64);
|
||||
assert_eq!(mask, Some([u64::MAX; 4]));
|
||||
assert!(kept.take(&key(0)).is_none());
|
||||
assert_eq!(kept.bytes(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_smaller_view_keeps_a_share_of_the_budget() {
|
||||
let mut kept = KeptTiles::new(KEPT_FOR, KEPT_BYTES);
|
||||
kept.fit_to_view(VIEW_512_TILES);
|
||||
assert_eq!(kept.budget, KEPT_BYTES / VIEW_512_TILES * VIEW_512_TILES);
|
||||
// A 128-chunk view plans about a sixteenth of the tiles.
|
||||
kept.fit_to_view(VIEW_512_TILES / 16);
|
||||
assert!(kept.budget <= KEPT_BYTES / 16);
|
||||
kept.fit_to_view(VIEW_512_TILES * 4);
|
||||
assert_eq!(kept.budget, KEPT_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn past_the_budget_the_finest_go_first_and_past_the_time_all() {
|
||||
let mut kept = KeptTiles::new(KEPT_FOR, usize::MAX);
|
||||
kept.keep(key(4), tile(80), None);
|
||||
let one = kept.bytes();
|
||||
kept.keep(key(0), tile(64), None);
|
||||
kept.keep(key(1), tile(70), None);
|
||||
kept.budget = one * 2;
|
||||
kept.trim();
|
||||
assert!(kept.contains(&key(4)) && kept.contains(&key(1)) && !kept.contains(&key(0)));
|
||||
assert_eq!(kept.bytes(), one * 2);
|
||||
kept.time = Duration::ZERO;
|
||||
kept.trim();
|
||||
assert_eq!((kept.len(), kept.bytes()), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgotten_tiles_are_not_taken_back() {
|
||||
let mut kept = KeptTiles::new(KEPT_FOR, usize::MAX);
|
||||
kept.keep(key(2), tile(64), None);
|
||||
kept.keep(key(3), tile(64), None);
|
||||
kept.forget(|key, _| key.level == 2);
|
||||
assert!(kept.take(&key(2)).is_none());
|
||||
assert!(kept.take(&key(3)).is_some());
|
||||
assert_eq!(kept.bytes(), 0);
|
||||
}
|
||||
}
|
||||
@@ -1334,6 +1334,8 @@ pub enum Page {
|
||||
ShaderPacks,
|
||||
/// The picked pack's options: the shader screen (`shader_ui`) at the pack's main screen.
|
||||
ShaderOptions,
|
||||
/// The selected resource packs' Polytone options (`pack_options_ui`), from the pack screen.
|
||||
PackOptions,
|
||||
/// Horizon's graphics settings (`graphics_ui`).
|
||||
Graphics,
|
||||
GraphicsAdvanced,
|
||||
@@ -1486,6 +1488,12 @@ fn matches_focus(id: &FocusId, i: usize, wd: &Widget) -> bool {
|
||||
/// What the game must do after an interaction.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Event {
|
||||
/// The Pack Options page closed with changes: the player's choices of its entries, the
|
||||
/// ones that differ from their defaults (the others went back to theirs).
|
||||
PackOptions {
|
||||
entries: Vec<String>,
|
||||
chosen: std::collections::BTreeMap<String, horizon_polytone::pack::ConfigValue>,
|
||||
},
|
||||
/// An accepted button press or the release of a slider.
|
||||
ButtonClick,
|
||||
/// Horizon's own: download the Modrinth hit at this index of the pack
|
||||
@@ -1662,7 +1670,12 @@ pub struct OptionsUi {
|
||||
controller: shell::Shell<crate::controller_ui::ControllerScreen>,
|
||||
/// Horizon Extras, on the settings shell.
|
||||
extras: shell::Shell<crate::extras_ui::ExtrasScreen>,
|
||||
pub shader: crate::shader_ui::ShaderUi,
|
||||
/// Shaders: the shader packs and the picked one's options, on the settings shell.
|
||||
shader: shell::Shell<crate::shader_ui::ShaderScreen>,
|
||||
/// The Pack Options page, and the packs' Polytone config entries by pack id (the game
|
||||
/// fills them beside `pack_infos`).
|
||||
pub pack_options: crate::pack_options_ui::PackOptionsUi,
|
||||
pub pack_configs: std::collections::BTreeMap<String, horizon_polytone::pack::Configs>,
|
||||
pub split_profiles: crate::split_profiles::SplitProfiles,
|
||||
/// HDR calibration while its page is open.
|
||||
pub calibration: Option<crate::hdr_calibration::Calibration>,
|
||||
@@ -1741,6 +1754,8 @@ impl Default for OptionsUi {
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
shader: Default::default(),
|
||||
pack_options: Default::default(),
|
||||
pack_configs: Default::default(),
|
||||
split_profiles: Default::default(),
|
||||
calibration: None,
|
||||
calibration_fullscreen: None,
|
||||
@@ -2098,7 +2113,6 @@ impl OptionsUi {
|
||||
.pack_screen
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.search_focused()),
|
||||
_ if Self::shader_page(self.page()).is_some() => self.shader.typing(),
|
||||
_ => match self.shell() {
|
||||
Some(shell) => shell.typing(),
|
||||
None => self.settings_search_active(),
|
||||
@@ -2209,12 +2223,12 @@ impl OptionsUi {
|
||||
if page == Page::Packs {
|
||||
self.pack_screen = None;
|
||||
}
|
||||
if let Some(at) = Self::shader_page(page) {
|
||||
self.shader.open(at);
|
||||
}
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.open();
|
||||
}
|
||||
if let Some(at) = Self::shader_page(page) {
|
||||
self.shader.show(at);
|
||||
}
|
||||
if let Some(at) = Self::graphics_page(page) {
|
||||
self.graphics.show(at);
|
||||
}
|
||||
@@ -2239,6 +2253,7 @@ impl OptionsUi {
|
||||
Page::Extras => Some(&self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
|
||||
page if Self::controller_page(page).is_some() => Some(&self.controller),
|
||||
page if Self::shader_page(page).is_some() => Some(&self.shader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -2247,6 +2262,7 @@ impl OptionsUi {
|
||||
Page::Extras => Some(&mut self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
|
||||
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
|
||||
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -2291,38 +2307,6 @@ impl OptionsUi {
|
||||
}
|
||||
events
|
||||
}
|
||||
/// What the shader screen's actions do to these options.
|
||||
fn shader_events(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
actions: Vec<crate::shader_ui::Action>,
|
||||
) -> Vec<Event> {
|
||||
use crate::shader_ui::Action;
|
||||
let mut events = Vec::new();
|
||||
for action in actions {
|
||||
match action {
|
||||
Action::ButtonClick => events.push(Event::ButtonClick),
|
||||
Action::Changed => events.push(Event::Changed),
|
||||
Action::SliderDrag => events.push(Event::SliderDrag),
|
||||
Action::Picked => {
|
||||
// The last pack's changed options are kept before the next one's are read.
|
||||
if s.shader_options.as_ref().is_some_and(|o| o.changed) {
|
||||
events.push(Event::ShaderOptions);
|
||||
}
|
||||
events.push(Event::ShaderPack);
|
||||
events.push(Event::OpenShaderOptions);
|
||||
}
|
||||
Action::OpenFolder => events.push(Event::OpenShaderPackFolder),
|
||||
Action::Apply => {
|
||||
events.push(Event::ShaderOptions);
|
||||
events.push(Event::OpenShaderOptions);
|
||||
}
|
||||
Action::NativeChanged(pack) => events.push(Event::NativePackOptions(pack)),
|
||||
Action::Done => events.extend(self.back(s)),
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
/// Where the shader screen opens for `page`, if it is one of its pages.
|
||||
fn shader_page(page: Page) -> Option<crate::shader_ui::Tab> {
|
||||
match page {
|
||||
@@ -2378,6 +2362,7 @@ impl OptionsUi {
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs",
|
||||
Page::ShaderOptions => "horizon.pages.shader_options",
|
||||
Page::PackOptions => "horizon.pack.options.title",
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
|
||||
"options.videoTitle"
|
||||
}
|
||||
@@ -3680,8 +3665,8 @@ impl OptionsUi {
|
||||
c.draw(batch, atlas, cursor);
|
||||
return;
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
self.shader.draw(s, batch, atlas, cursor, self.in_world);
|
||||
if self.page() == Page::PackOptions {
|
||||
self.pack_options.draw(batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
let waiting = self.waiting();
|
||||
@@ -4059,9 +4044,19 @@ impl OptionsUi {
|
||||
});
|
||||
return self.shell_events(s, outputs);
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
let actions = self.shader.click(s, (x, y), size, drag, right, self.shift);
|
||||
return self.shader_events(s, actions);
|
||||
if self.page() == Page::PackOptions {
|
||||
let mut events = Vec::new();
|
||||
for action in self
|
||||
.pack_options
|
||||
.click((x, y), size, drag, right || self.shift)
|
||||
{
|
||||
match action {
|
||||
crate::pack_options_ui::Action::ButtonClick => events.push(Event::ButtonClick),
|
||||
crate::pack_options_ui::Action::SliderDrag => {}
|
||||
crate::pack_options_ui::Action::Done => events.extend(self.back(s)),
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
if self.page() == Page::Packs {
|
||||
let mut events = Vec::new();
|
||||
@@ -4091,6 +4086,14 @@ impl OptionsUi {
|
||||
crate::pack_screen::Action::BrowsePick(i) => {
|
||||
events.push(Event::DownloadPack(i));
|
||||
}
|
||||
crate::pack_screen::Action::Options(id) => {
|
||||
let mut configs = horizon_polytone::pack::Configs::default();
|
||||
if let Some(own) = self.pack_configs.get(&id) {
|
||||
configs.merge(own);
|
||||
}
|
||||
self.pack_options = crate::pack_options_ui::PackOptionsUi::open(configs);
|
||||
self.push(Page::PackOptions);
|
||||
}
|
||||
crate::pack_screen::Action::None => {}
|
||||
}
|
||||
return events;
|
||||
@@ -4329,15 +4332,19 @@ impl OptionsUi {
|
||||
// AbstractSliderButton suppresses the press sound and plays once on
|
||||
// release, even if the pointer has left the widget or its value stayed.
|
||||
let slider = self.dragging.take().is_some();
|
||||
let (shader_slider, shader_pack) = self.shader.release();
|
||||
let shell_slider = self.shell_mut().is_some_and(|shell| shell.release());
|
||||
let graphics_slider = shader_slider | shell_slider;
|
||||
let shell_slider = self.shell_mut().and_then(|shell| shell.release());
|
||||
self.pack_options.release();
|
||||
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
|
||||
let split_drop = self.split_setup.release();
|
||||
if slider || graphics_slider || calibration_slider {
|
||||
if slider || shell_slider.is_some() || calibration_slider {
|
||||
let mut events = vec![Event::ButtonClick, Event::Changed];
|
||||
// A pack's option is saved, and the pack rebuilt, once per drag.
|
||||
events.extend(shader_pack.map(Event::NativePackOptions));
|
||||
// What the screen does once a drag ends: a shader pack's option is saved, and the
|
||||
// pack rebuilt, once per drag.
|
||||
let done = shell_slider.into_iter().flatten();
|
||||
events.extend(done.filter_map(|output| match output {
|
||||
shell::Output::Event(event) => Some(event),
|
||||
_ => None,
|
||||
}));
|
||||
events
|
||||
} else if split_drop {
|
||||
vec![Event::ButtonClick]
|
||||
@@ -4420,8 +4427,8 @@ impl OptionsUi {
|
||||
self.studio.wheel(amount);
|
||||
return Vec::new();
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
self.shader.scroll(s, size, amount);
|
||||
if self.page() == Page::PackOptions {
|
||||
self.pack_options.scroll(size, amount);
|
||||
return Vec::new();
|
||||
}
|
||||
let plain = self.resource_error.is_none()
|
||||
@@ -4507,8 +4514,8 @@ impl OptionsUi {
|
||||
self.studio.wheel(amount);
|
||||
return;
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
self.shader.scroll(s, size, amount);
|
||||
if self.page() == Page::PackOptions {
|
||||
self.pack_options.scroll(size, amount);
|
||||
return;
|
||||
}
|
||||
match &mut self.pack_screen {
|
||||
@@ -4576,7 +4583,19 @@ impl OptionsUi {
|
||||
self.pack_screen = Some(screen);
|
||||
}
|
||||
}
|
||||
self.pack_screen.as_mut().expect("made above")
|
||||
// The cards of packs with Polytone options carry their button (data packs have none).
|
||||
let with_options = if self.data_pack_dir.is_none() {
|
||||
self.pack_configs
|
||||
.iter()
|
||||
.filter(|(_, c)| !c.entries().is_empty())
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect()
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let screen = self.pack_screen.as_mut().expect("made above");
|
||||
screen.with_options = with_options;
|
||||
screen
|
||||
}
|
||||
/// Escape or Done: leave the current page, applying page results.
|
||||
/// After an input on HDR calibration: Save keeps what it landed on, Cancel what was
|
||||
@@ -4633,6 +4652,16 @@ impl OptionsUi {
|
||||
self.language_search.focused = false;
|
||||
self.can_change_value = false;
|
||||
match page {
|
||||
Page::PackOptions => {
|
||||
let ui = std::mem::take(&mut self.pack_options);
|
||||
if ui.changed {
|
||||
let entries = ui.configs.entries().keys().cloned().collect();
|
||||
events.push(Event::PackOptions {
|
||||
entries,
|
||||
chosen: ui.configs.chosen(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Page::ShaderOptions | Page::ShaderPacks => {
|
||||
if s.shader_options.as_ref().is_some_and(|o| o.changed) {
|
||||
events.push(Event::ShaderOptions);
|
||||
@@ -4695,8 +4724,6 @@ impl OptionsUi {
|
||||
if shell.typing() {
|
||||
shell.type_text(text);
|
||||
}
|
||||
} else if Self::shader_page(self.page()).is_some() && self.shader.typing() {
|
||||
self.shader.type_text(text);
|
||||
} else if self.settings_search_active() {
|
||||
self.settings_search.insert_text(text);
|
||||
self.scroll = 0.0;
|
||||
@@ -4731,9 +4758,6 @@ impl OptionsUi {
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
return shell.shortcut(shortcut, clipboard);
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
return self.shader.shortcut(shortcut, clipboard);
|
||||
}
|
||||
if self.settings_search_active() {
|
||||
if self.settings_search.shortcut(shortcut, clipboard) {
|
||||
self.scroll = 0.0;
|
||||
@@ -4855,16 +4879,6 @@ impl OptionsUi {
|
||||
let outputs = shell.key(s, key, text, shift, gui);
|
||||
return self.shell_events(s, outputs);
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
|
||||
let (actions, handled) = self.shader.key_modifiers(s, key, text, shift, size);
|
||||
let mut events = self.shader_events(s, actions);
|
||||
// Escape leaves a screen a link opened first, then the shader screen.
|
||||
if key == K::Escape && !handled {
|
||||
events.extend(self.back(s));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
if key == K::Escape {
|
||||
return self.back(s);
|
||||
@@ -5525,8 +5539,20 @@ mod tests {
|
||||
/// read, and leaving the shader screen applies them.
|
||||
#[test]
|
||||
fn the_shader_screen_saves_changed_options_before_reading_the_next_packs() {
|
||||
use crate::shader_ui::Action;
|
||||
let mut s = Settings::default();
|
||||
use crate::shader_pack_list::{Entry, Kind};
|
||||
let entry = |file: &str| Entry {
|
||||
file: file.into(),
|
||||
key: file.into(),
|
||||
title: file.into(),
|
||||
kind: Kind::Classic,
|
||||
options: Vec::new(),
|
||||
values: Vec::new(),
|
||||
pages: Vec::new(),
|
||||
};
|
||||
let mut s = Settings {
|
||||
shader_packs: vec![entry("A.zip"), entry("B.zip")],
|
||||
..Settings::default()
|
||||
};
|
||||
s.horizon.shader_pack = "A.zip".into();
|
||||
let mut options = crate::shader_options::ShaderOptions::new(
|
||||
"A.zip".into(),
|
||||
@@ -5540,15 +5566,21 @@ mod tests {
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.open(false);
|
||||
ui.push(Page::ShaderPacks);
|
||||
let events = ui.shader_events(&mut s, vec![Action::Picked]);
|
||||
let size = (854.0, 480.0);
|
||||
let (x, y) = ui
|
||||
.shader
|
||||
.point_at(&s, size, crate::shader_ui::Item::Pack(1));
|
||||
let events = ui.click(&mut s, &atlas(), x, y, size, [1708, 960], false, false);
|
||||
assert!(matches!(
|
||||
events[..],
|
||||
[
|
||||
Event::ButtonClick,
|
||||
Event::ShaderOptions,
|
||||
Event::ShaderPack,
|
||||
Event::OpenShaderOptions
|
||||
]
|
||||
));
|
||||
assert_eq!(s.horizon.shader_pack, "B.zip");
|
||||
let events = ui.back(&mut s);
|
||||
assert!(matches!(events[0], Event::ShaderOptions));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
//! The Pack Options page (#11486): the config entries of the selected resource packs' Polytone
|
||||
//! `config_entries` (wiki "Polytone Configs"), in the frame of Horizon's settings screens
|
||||
//! (`settings_shell`). Numbers are sliders, texts cycle, booleans switch; the entries sit in
|
||||
//! their `section`s, named by the packs' own translations. Leaving with Done keeps what changed,
|
||||
//! and the packs load again with it.
|
||||
use crate::widgets::Action as Declared;
|
||||
use crate::{
|
||||
graphics_style as style,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
lang,
|
||||
settings_shell::{self as shell, Columns, Line, Lines},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use horizon_polytone::pack::{ConfigKind, ConfigValue, Configs};
|
||||
|
||||
/// What a click asks of the options screen.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
ButtonClick,
|
||||
SliderDrag,
|
||||
/// Done: leave, keeping what changed.
|
||||
Done,
|
||||
}
|
||||
|
||||
/// The page's state: the entries being edited, how they are listed, and the pointer's hold.
|
||||
#[derive(Default)]
|
||||
pub struct PackOptionsUi {
|
||||
/// The selected packs' entries and the values being picked.
|
||||
pub configs: Configs,
|
||||
/// Entry ids, by the row index the list uses.
|
||||
ids: Vec<String>,
|
||||
/// The sections in order: the `section` id (none for entries without one) and its rows.
|
||||
sections: Vec<(Option<String>, Vec<u16>)>,
|
||||
scroll: ui::Scroll,
|
||||
dragging: Option<u16>,
|
||||
/// A value changed since the page opened.
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
/// The presets' row (and its section): past every entry's index.
|
||||
const PRESETS: u16 = u16::MAX;
|
||||
|
||||
/// A translation, or `fallback` where no language has the key.
|
||||
fn translated(key: &str, fallback: impl FnOnce() -> String) -> String {
|
||||
if lang::has(key) {
|
||||
lang::get(key)
|
||||
} else {
|
||||
fallback()
|
||||
}
|
||||
}
|
||||
|
||||
/// `namespace:path` split, `minecraft` when there is no namespace.
|
||||
fn split(id: &str) -> (&str, &str) {
|
||||
id.split_once(':').unwrap_or(("minecraft", id))
|
||||
}
|
||||
|
||||
/// A name for an id without a translation: `fog_density` is "Fog density".
|
||||
fn readable(path: &str) -> String {
|
||||
let words = path.rsplit('/').next().unwrap_or(path).replace('_', " ");
|
||||
let mut chars = words.chars();
|
||||
chars
|
||||
.next()
|
||||
.map(|c| c.to_uppercase().chain(chars).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
impl PackOptionsUi {
|
||||
/// The page opens on `configs`, the selected packs' entries with the player's values.
|
||||
pub fn open(configs: Configs) -> Self {
|
||||
let mut ids: Vec<(String, Option<String>, i64)> = configs
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|(id, e)| (id.clone(), e.section.clone(), e.display_order))
|
||||
.collect();
|
||||
ids.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
|
||||
// A section's place: the smallest `section_order` among its entries, else its name.
|
||||
let mut sections: Vec<(Option<String>, Option<i64>, Vec<u16>)> = Vec::new();
|
||||
for (row, (id, section, _)) in ids.iter().enumerate() {
|
||||
let order = configs.entries()[id].section_order;
|
||||
match sections.iter_mut().find(|s| &s.0 == section) {
|
||||
Some(s) => {
|
||||
s.1 = match (s.1, order) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
(a, b) => a.or(b),
|
||||
};
|
||||
s.2.push(row as u16);
|
||||
}
|
||||
None => sections.push((section.clone(), order, vec![row as u16])),
|
||||
}
|
||||
}
|
||||
sections.sort_by(|a, b| match (a.1, b.1) {
|
||||
(Some(x), Some(y)) => x.cmp(&y),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => a.0.cmp(&b.0),
|
||||
});
|
||||
Self {
|
||||
configs,
|
||||
ids: ids.into_iter().map(|(id, ..)| id).collect(),
|
||||
sections: sections.into_iter().map(|(s, _, rows)| (s, rows)).collect(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(&self, w: f32, h: f32) -> (Columns, Vec<Line<u16, u16>>, f32) {
|
||||
let cols = Columns::of(w, h);
|
||||
let mut lines = Lines::new();
|
||||
// The pack-wide presets first, when the entries have some.
|
||||
if !self.configs.presets().is_empty() {
|
||||
lines.titled(PRESETS, vec![PRESETS]);
|
||||
}
|
||||
for (i, (_, rows)) in self.sections.iter().enumerate() {
|
||||
lines.titled(i as u16, rows.clone());
|
||||
}
|
||||
let (lines, content) = lines.finish();
|
||||
(cols, lines, content)
|
||||
}
|
||||
|
||||
/// The namespace the presets' translations are under: of the first entry with presets.
|
||||
fn presets_namespace(&self) -> &str {
|
||||
self.configs
|
||||
.entries()
|
||||
.iter()
|
||||
.find(|(_, e)| !e.presets.is_empty())
|
||||
.map_or("minecraft", |(id, _)| split(id).0)
|
||||
}
|
||||
|
||||
/// The pack-wide preset every entry's value matches, if one does.
|
||||
fn current_preset(&self) -> Option<String> {
|
||||
self.configs.presets().into_iter().find(|preset| {
|
||||
self.configs
|
||||
.entries()
|
||||
.iter()
|
||||
.all(|(id, e)| match e.presets.get(preset) {
|
||||
Some(value) => e.accept(value).as_ref() == self.configs.get(id),
|
||||
None => true,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// A preset's label (`<namespace>.presets.<name>`), or the label of none (`.none`).
|
||||
fn preset_label(&self, preset: Option<&str>) -> String {
|
||||
let namespace = self.presets_namespace();
|
||||
match preset {
|
||||
Some(name) => translated(&format!("{namespace}.presets.{name}"), || readable(name)),
|
||||
None => translated(&format!("{namespace}.presets.none"), || {
|
||||
lang::get("horizon.pack.options.presets.custom")
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The next pack-wide preset after the current one (`back`: before it), applied.
|
||||
fn cycle_preset(&mut self, back: bool) {
|
||||
let presets = self.configs.presets();
|
||||
if presets.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = presets.len();
|
||||
let next = match self
|
||||
.current_preset()
|
||||
.and_then(|c| presets.iter().position(|p| *p == c))
|
||||
{
|
||||
Some(i) if back => (i + n - 1) % n,
|
||||
Some(i) => (i + 1) % n,
|
||||
None if back => n - 1,
|
||||
None => 0,
|
||||
};
|
||||
let before = self.configs.clone();
|
||||
self.configs.apply_preset(&presets[next]);
|
||||
if self.configs != before {
|
||||
self.changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// A section's heading: `config.<namespace>.section.<id>`, by its first entry's namespace.
|
||||
fn section_name(&self, section: u16) -> String {
|
||||
if section == PRESETS {
|
||||
let key = format!("{}.presets", self.presets_namespace());
|
||||
return translated(&key, || lang::get("horizon.pack.options.presets"));
|
||||
}
|
||||
let Some((id, rows)) = self.sections.get(usize::from(section)) else {
|
||||
return String::new();
|
||||
};
|
||||
let Some(id) = id else {
|
||||
return lang::get("horizon.pack.options.general");
|
||||
};
|
||||
let namespace = rows
|
||||
.first()
|
||||
.and_then(|&r| self.ids.get(usize::from(r)))
|
||||
.map_or("minecraft", |entry| split(entry).0);
|
||||
translated(&format!("config.{namespace}.section.{id}"), || readable(id))
|
||||
}
|
||||
|
||||
/// An entry's name (`config.<namespace>.<name>`) and its value's label.
|
||||
fn parts(&self, row: u16) -> (String, Option<String>) {
|
||||
if row == PRESETS {
|
||||
let label = self.preset_label(self.current_preset().as_deref());
|
||||
return (self.section_name(PRESETS), Some(label));
|
||||
}
|
||||
let Some(id) = self.ids.get(usize::from(row)) else {
|
||||
return (String::new(), None);
|
||||
};
|
||||
let (namespace, path) = split(id);
|
||||
let name = translated(&format!("config.{namespace}.{path}"), || readable(path));
|
||||
let entry = &self.configs.entries()[id];
|
||||
let value = match self.configs.get(id) {
|
||||
Some(ConfigValue::Number(n)) => {
|
||||
let step = match entry.kind {
|
||||
ConfigKind::Number { step, .. } => step,
|
||||
_ => 1.0,
|
||||
};
|
||||
let text = if step >= 1.0 && step.fract() == 0.0 {
|
||||
format!("{n:.0}")
|
||||
} else {
|
||||
format!("{n:.2}")
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
.to_owned()
|
||||
};
|
||||
Some(text)
|
||||
}
|
||||
Some(ConfigValue::Text(t)) => Some(t.clone()),
|
||||
Some(ConfigValue::Bool(_)) | None => None,
|
||||
};
|
||||
let value = value.map(|v| match &entry.value_translation {
|
||||
Some(key) if lang::has(key) => lang::tr(key, &[&v]),
|
||||
_ => v,
|
||||
});
|
||||
(name, value)
|
||||
}
|
||||
|
||||
/// The tooltip (`config.<namespace>.<name>.tooltip`) and the performance hint.
|
||||
fn description(&self, row: u16) -> String {
|
||||
let Some(id) = self.ids.get(usize::from(row)) else {
|
||||
return String::new();
|
||||
};
|
||||
let (namespace, path) = split(id);
|
||||
let key = format!("config.{namespace}.{path}.tooltip");
|
||||
let mut text = if lang::has(&key) {
|
||||
lang::get(&key)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if let Some(impact) = &self.configs.entries()[id].performance_impact {
|
||||
let level = lang::get(&format!("horizon.pack.options.impact.{impact}"));
|
||||
let line = lang::tr("horizon.pack.options.impact", &[&level]);
|
||||
text = if text.is_empty() {
|
||||
line
|
||||
} else {
|
||||
format!("{text} {line}")
|
||||
};
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// A slider's place (0 to 1) for a number entry.
|
||||
fn slider(&self, row: u16) -> Option<f32> {
|
||||
let id = self.ids.get(usize::from(row))?;
|
||||
let ConfigKind::Number { min, max, .. } = self.configs.entries()[id].kind else {
|
||||
return None;
|
||||
};
|
||||
let Some(ConfigValue::Number(n)) = self.configs.get(id) else {
|
||||
return None;
|
||||
};
|
||||
Some(if max > min {
|
||||
((n - min) / (max - min)) as f32
|
||||
} else {
|
||||
0.0
|
||||
})
|
||||
}
|
||||
|
||||
fn switch(&self, row: u16) -> Option<bool> {
|
||||
match self.configs.get(self.ids.get(usize::from(row))?)? {
|
||||
ConfigValue::Bool(b) => Some(*b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
in_world: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let (cols, lines, content) = self.layout(w, h);
|
||||
self.scroll.clamp(cols.top, cols.bottom, content);
|
||||
shell::backdrop(batch, atlas, in_world);
|
||||
shell::mark(
|
||||
batch,
|
||||
atlas,
|
||||
&cols,
|
||||
&lang::get("horizon.pack.options.title"),
|
||||
);
|
||||
shell::plate(
|
||||
batch,
|
||||
atlas,
|
||||
&cols,
|
||||
&lang::get("horizon.pack.options.title"),
|
||||
);
|
||||
let over_list = cursor.1 >= cols.top && cursor.1 < cols.bottom;
|
||||
let mut described = None;
|
||||
shell::list(
|
||||
batch,
|
||||
atlas,
|
||||
&cols,
|
||||
&lines,
|
||||
self.scroll.amount,
|
||||
|section| self.section_name(section),
|
||||
|batch, row: u16, y| {
|
||||
let [rx, ry, rw, rh] = cols.row(y);
|
||||
let hover = over_list && ui::hovered(cursor, rx, ry, rw, rh);
|
||||
if hover || self.dragging == Some(row) {
|
||||
described = Some(row);
|
||||
}
|
||||
let (name, value) = self.parts(row);
|
||||
let label = match &value {
|
||||
Some(v) => format!("{name}: {v}"),
|
||||
None => name.clone(),
|
||||
};
|
||||
let view = shell::RowView {
|
||||
label: &label,
|
||||
name: &name,
|
||||
value: value.as_deref(),
|
||||
switch: self.switch(row),
|
||||
slider: self.slider(row),
|
||||
live: true,
|
||||
hover,
|
||||
focused: false,
|
||||
held: self.dragging == Some(row),
|
||||
key: None,
|
||||
};
|
||||
shell::row(batch, atlas, &cols, y, &view);
|
||||
},
|
||||
);
|
||||
shell::scroller(batch, atlas, &cols, content, self.scroll.amount);
|
||||
// The strip: the pointed-at entry's tooltip.
|
||||
let [x, y, ..] = cols.dock;
|
||||
style::panel(batch, atlas, cols.dock);
|
||||
if let Some(row) = described {
|
||||
let name = Text::literal(self.parts(row).0);
|
||||
batch.text_styled(atlas, &name, x + 12.0, y + 8.0, style::WHITE, true);
|
||||
shell::dock_note(batch, atlas, &cols, self.description(row), style::GREY);
|
||||
}
|
||||
let mut foot = vec![(cols.button(0), "gui.done", Some(Declared::Done))];
|
||||
if self
|
||||
.configs
|
||||
.chosen()
|
||||
.iter()
|
||||
.any(|(id, _)| self.ids.contains(id))
|
||||
{
|
||||
foot.push((cols.button(1), "controls.reset", None));
|
||||
}
|
||||
for (rect, key, declared) in foot {
|
||||
if let Some(declared) = declared {
|
||||
crate::widgets::declare_action(declared, rect, true);
|
||||
}
|
||||
let label = Text::literal(lang::get(key));
|
||||
shell::foot_button(batch, atlas, rect, &label, cursor, (false, true));
|
||||
}
|
||||
}
|
||||
|
||||
/// A press (or a drag with `drag`) at `(x, y)`; `back` turns a cycle backwards.
|
||||
pub fn click(
|
||||
&mut self,
|
||||
(x, y): (f32, f32),
|
||||
(w, h): (f32, f32),
|
||||
drag: bool,
|
||||
back: bool,
|
||||
) -> Vec<Action> {
|
||||
let (cols, lines, content) = self.layout(w, h);
|
||||
let at = |[rx, ry, rw, rh]: [f32; 4]| ui::hovered((x, y), rx, ry, rw, rh);
|
||||
if drag {
|
||||
if self.scroll.held() {
|
||||
self.scroll.drag(y, cols.top, cols.bottom, content);
|
||||
} else if let Some(row) = self.dragging {
|
||||
self.set_slider(row, cols.slider_at(x));
|
||||
return vec![Action::SliderDrag];
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
if cols.scrolls()
|
||||
&& self
|
||||
.scroll
|
||||
.press((x, y), cols.bar_x(), cols.top, cols.bottom, content)
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
if at(cols.button(0)) {
|
||||
return vec![Action::ButtonClick, Action::Done];
|
||||
}
|
||||
if at(cols.button(1)) {
|
||||
self.reset();
|
||||
return vec![Action::ButtonClick];
|
||||
}
|
||||
if y < cols.top || y >= cols.bottom {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(row) = lines.iter().find_map(|line| {
|
||||
let row = line.setting?;
|
||||
at(cols.row(cols.top + line.y - self.scroll.amount)).then_some(row)
|
||||
}) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if row == PRESETS {
|
||||
self.cycle_preset(back);
|
||||
return vec![Action::ButtonClick];
|
||||
}
|
||||
let Some(id) = self.ids.get(usize::from(row)).cloned() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let entry = self.configs.entries()[&id].clone();
|
||||
match (&entry.kind, self.configs.get(&id).cloned()) {
|
||||
(ConfigKind::Number { .. }, _) => {
|
||||
self.dragging = Some(row);
|
||||
self.set_slider(row, cols.slider_at(x));
|
||||
return vec![Action::SliderDrag];
|
||||
}
|
||||
(ConfigKind::Bool, Some(ConfigValue::Bool(b))) => {
|
||||
self.set(&id, ConfigValue::Bool(!b));
|
||||
}
|
||||
(ConfigKind::Text { allowed }, Some(ConfigValue::Text(t))) if !allowed.is_empty() => {
|
||||
let i = allowed.iter().position(|a| *a == t).unwrap_or(0);
|
||||
let n = allowed.len();
|
||||
let next = if back { (i + n - 1) % n } else { (i + 1) % n };
|
||||
self.set(&id, ConfigValue::Text(allowed[next].clone()));
|
||||
}
|
||||
_ => return Vec::new(),
|
||||
}
|
||||
vec![Action::ButtonClick]
|
||||
}
|
||||
|
||||
fn set(&mut self, id: &str, value: ConfigValue) {
|
||||
let before = self.configs.get(id).cloned();
|
||||
self.configs.set(id, value);
|
||||
if self.configs.get(id).cloned() != before {
|
||||
self.changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Number entry `row` at slider place `at` (0 to 1).
|
||||
fn set_slider(&mut self, row: u16, at: f32) {
|
||||
let Some(id) = self.ids.get(usize::from(row)).cloned() else {
|
||||
return;
|
||||
};
|
||||
if let ConfigKind::Number { min, max, .. } = self.configs.entries()[&id].kind {
|
||||
self.set(&id, ConfigValue::Number(min + f64::from(at) * (max - min)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Every entry back to its default.
|
||||
fn reset(&mut self) {
|
||||
let defaults: Vec<_> = self
|
||||
.configs
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|(id, e)| (id.clone(), e.default.clone()))
|
||||
.collect();
|
||||
for (id, value) in defaults {
|
||||
self.set(&id, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// The pointer lets go.
|
||||
pub fn release(&mut self) {
|
||||
self.scroll.release();
|
||||
self.dragging = None;
|
||||
}
|
||||
|
||||
/// The mouse wheel scrolls the list.
|
||||
pub fn scroll(&mut self, (w, h): (f32, f32), amount: f32) {
|
||||
let (cols, _, content) = self.layout(w, h);
|
||||
self.scroll
|
||||
.wheel(amount, 18.0, cols.top, cols.bottom, content);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn configs() -> Configs {
|
||||
let mut c = Configs::default();
|
||||
c.add_entry(
|
||||
"p:fog".into(),
|
||||
&serde_json::json!({"default_value": 0.5, "section": "sky", "section_order": 2}),
|
||||
);
|
||||
c.add_entry("p:rain".into(), &serde_json::json!({"default_value": "drops", "allowed_values": ["drops", "streaks"], "section": "weather", "section_order": 1}));
|
||||
c.add_entry(
|
||||
"p:birds".into(),
|
||||
&serde_json::json!({"default_value": true}),
|
||||
);
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_sort_by_their_order_and_the_unsectioned_last() {
|
||||
let ui = PackOptionsUi::open(configs());
|
||||
let order: Vec<_> = ui.sections.iter().map(|(s, _)| s.as_deref()).collect();
|
||||
assert_eq!(order, [Some("weather"), Some("sky"), None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_switch_cycle_and_slide() {
|
||||
let mut ui = PackOptionsUi::open(configs());
|
||||
let size = (480.0, 270.0);
|
||||
let (cols, lines, _) = ui.layout(size.0, size.1);
|
||||
let ids = ui.ids.clone();
|
||||
let point = |id: &str| {
|
||||
let row = ids.iter().position(|i| i == id).unwrap() as u16;
|
||||
let line = lines.iter().find(|l| l.setting == Some(row)).unwrap();
|
||||
let [x, y, w, h] = cols.row(cols.top + line.y);
|
||||
(x + w - 4.0, y + h / 2.0)
|
||||
};
|
||||
let (birds, rain) = (point("p:birds"), point("p:rain"));
|
||||
ui.click(birds, size, false, false);
|
||||
assert_eq!(ui.configs.get("p:birds"), Some(&ConfigValue::Bool(false)));
|
||||
ui.click(rain, size, false, false);
|
||||
assert_eq!(
|
||||
ui.configs.get("p:rain"),
|
||||
Some(&ConfigValue::Text("streaks".into()))
|
||||
);
|
||||
// The slider's far right end is its maximum.
|
||||
let fog = point("p:fog");
|
||||
let (tx, tw) = cols.track();
|
||||
ui.click((tx + tw, fog.1), size, false, false);
|
||||
ui.release();
|
||||
assert_eq!(ui.configs.get("p:fog"), Some(&ConfigValue::Number(1.0)));
|
||||
assert!(ui.changed);
|
||||
assert_eq!(ui.configs.chosen().len(), 3);
|
||||
ui.reset();
|
||||
assert!(ui.configs.chosen().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_presets_row_cycles_the_pack_wide_presets_and_shows_custom_otherwise() {
|
||||
let mut c = Configs::default();
|
||||
let entry = |default: f64, fast: f64, pretty: f64| serde_json::json!({"default_value": default, "presets": {"fast": fast, "pretty": pretty}});
|
||||
c.add_entry("p:fog".into(), &entry(0.5, 0.0, 1.0));
|
||||
c.add_entry("p:dust".into(), &entry(0.5, 0.0, 0.8));
|
||||
let mut ui = PackOptionsUi::open(c);
|
||||
// The defaults are no preset.
|
||||
assert_eq!(ui.current_preset(), None);
|
||||
ui.cycle_preset(false);
|
||||
assert_eq!(ui.current_preset().as_deref(), Some("fast"));
|
||||
assert_eq!(ui.configs.get("p:fog"), Some(&ConfigValue::Number(0.0)));
|
||||
ui.cycle_preset(false);
|
||||
assert_eq!(ui.current_preset().as_deref(), Some("pretty"));
|
||||
ui.cycle_preset(true);
|
||||
assert_eq!(ui.current_preset().as_deref(), Some("fast"));
|
||||
// One value off the preset: custom again.
|
||||
ui.set("p:dust", ConfigValue::Number(0.3));
|
||||
assert_eq!(ui.current_preset(), None);
|
||||
assert!(ui.changed);
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,8 @@ pub enum Action {
|
||||
OpenFolder,
|
||||
/// The Done button (`onClose`: commit).
|
||||
Done,
|
||||
/// Horizon's own: the options button on the card of this pack, which has Polytone options.
|
||||
Options(String),
|
||||
/// Horizon's own: a Modrinth hit the player picked, to download into the
|
||||
/// packs folder. The screen does not know where that is.
|
||||
BrowsePick(usize),
|
||||
@@ -359,6 +361,8 @@ pub struct PackScreen {
|
||||
browser_icons: Vec<String>,
|
||||
/// Whether that button is drawn at all.
|
||||
pub browsing_allowed: bool,
|
||||
/// The packs with Polytone options (#11486): their cards carry an options button.
|
||||
pub with_options: std::collections::BTreeSet<String>,
|
||||
/// The browser's own search box, separate from the one that filters the
|
||||
/// player's installed packs.
|
||||
browser_search: TextField,
|
||||
@@ -374,6 +378,8 @@ fn tr(key: &str) -> Text {
|
||||
/// height of 20; drawn shorter, neither it nor its label sits right, so the
|
||||
/// button keeps that height and is centred on the shorter search box.
|
||||
const ADD_SIZE: i32 = 20;
|
||||
/// The options button on a pack's card.
|
||||
const OPTIONS_SIZE: i32 = 16;
|
||||
/// A card in the grid: its icon beside its name, author and description.
|
||||
const ROW_H: i32 = 74;
|
||||
/// The air around a card's contents.
|
||||
@@ -440,6 +446,7 @@ impl PackScreen {
|
||||
confirm: None,
|
||||
browser: None,
|
||||
browsing_allowed: false,
|
||||
with_options: Default::default(),
|
||||
backdrop: Backdrop::None,
|
||||
browser_icons: Vec::new(),
|
||||
browser_search: {
|
||||
@@ -632,6 +639,12 @@ impl PackScreen {
|
||||
}
|
||||
let l = &self.lists[side];
|
||||
let hit = l.entry_at(x as i32, y as i32);
|
||||
if let Some(i) = hit
|
||||
&& let Some((id, bx, by)) = self.options_button(side, i)
|
||||
&& d::inside((x as i32, y as i32), bx, by, OPTIONS_SIZE, OPTIONS_SIZE)
|
||||
{
|
||||
return Action::Options(id);
|
||||
}
|
||||
let mut took = false;
|
||||
if let Some(i) = hit {
|
||||
took = true;
|
||||
@@ -1362,6 +1375,45 @@ impl PackScreen {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// The options button on the card of row `i` of list `side`, when its pack has
|
||||
/// Polytone options: the pack and the button's corner, at the card's right edge.
|
||||
fn options_button(&self, side: usize, i: usize) -> Option<(String, i32, i32)> {
|
||||
let l = &self.lists[side];
|
||||
let e = l.entries.get(i)?;
|
||||
let Row::Pack(id) = &e.row else { return None };
|
||||
if !self.with_options.contains(id) {
|
||||
return None;
|
||||
}
|
||||
let x = l.row_left() + l.entry_width() - OPTIONS_SIZE - 2;
|
||||
let y = e.y + (e.height - OPTIONS_SIZE) / 2;
|
||||
Some((id.clone(), x, y))
|
||||
}
|
||||
/// The options button of a card: a small button with a sliders glyph.
|
||||
fn draw_options_button(out: &mut Vec<Draw>, x: i32, y: i32, hover: bool) {
|
||||
crate::widgets::narratable(|| {
|
||||
crate::widgets::Narratable::button(
|
||||
tr("horizon.pack.options").plain(),
|
||||
[x as f32, y as f32, OPTIONS_SIZE as f32, OPTIONS_SIZE as f32],
|
||||
true,
|
||||
)
|
||||
});
|
||||
let sprite = if hover {
|
||||
"widget/button_highlighted"
|
||||
} else {
|
||||
"widget/button"
|
||||
};
|
||||
d::sprite(out, sprite, x, y, OPTIONS_SIZE, OPTIONS_SIZE);
|
||||
let (line, knob) = if hover {
|
||||
(0xFF_C6_C6_C6u32 as i32, -1)
|
||||
} else {
|
||||
(0xFF_A0_A0_A0u32 as i32, 0xFF_E0_E0_E0u32 as i32)
|
||||
};
|
||||
// Three sliders, their knobs at different places.
|
||||
for (row, at) in [(4, 8), (8, 4), (12, 9)] {
|
||||
d::fill(out, x + 3, y + row, x + 13, y + row + 1, line);
|
||||
d::fill(out, x + at, y + row - 1, x + at + 2, y + row + 2, knob);
|
||||
}
|
||||
}
|
||||
/// The Add button, just right of the search box.
|
||||
fn add_button(&self, m: &Measure) -> (i32, i32) {
|
||||
let (sx, sy) = self.header(m)[2];
|
||||
@@ -1470,6 +1522,7 @@ impl PackScreen {
|
||||
});
|
||||
let (left, w) = (l.row_left(), l.entry_width());
|
||||
let narrow = if l.scrollable() { 6 } else { 0 };
|
||||
let mut tooltip = None;
|
||||
for (i, e) in l.entries.iter().enumerate() {
|
||||
if e.y + e.height < l.y || e.y > l.bottom() {
|
||||
continue;
|
||||
@@ -1560,7 +1613,16 @@ impl PackScreen {
|
||||
}
|
||||
}
|
||||
}
|
||||
let max = 157 - narrow;
|
||||
let mut max = 157 - narrow;
|
||||
if let Some((_, bx, by)) = self.options_button(side, i) {
|
||||
let hover = hovered == Some(i)
|
||||
&& d::inside(mouse, bx, by, OPTIONS_SIZE, OPTIONS_SIZE);
|
||||
Self::draw_options_button(out, bx, by, hover);
|
||||
if hover {
|
||||
tooltip = Some(tr("horizon.pack.optionsInfo"));
|
||||
}
|
||||
max -= OPTIONS_SIZE + 2;
|
||||
}
|
||||
d::string_widget(out, m, &name, cx + 34, cy + 1, max);
|
||||
let lines = d::label_lines(m, &desc, max, 2, &desc_style);
|
||||
d::label(out, &lines, cx + 34, cy + 12, max, false);
|
||||
@@ -1568,6 +1630,9 @@ impl PackScreen {
|
||||
}
|
||||
}
|
||||
out.push(Draw::NoScissor);
|
||||
if let Some(t) = tooltip {
|
||||
d::tooltip(out, m, &t, mouse);
|
||||
}
|
||||
d::blit(
|
||||
out,
|
||||
&format!("textures/gui/{prefix}header_separator.png"),
|
||||
@@ -1652,6 +1717,45 @@ mod icon_tests {
|
||||
assert!(load_icon(b"not a png").is_none());
|
||||
}
|
||||
|
||||
/// A pack with Polytone options carries an options button on its card, which opens
|
||||
/// that pack's options and nothing else; a pack without options has none.
|
||||
#[test]
|
||||
fn a_card_with_options_opens_its_own_pack_options() {
|
||||
let atlas =
|
||||
crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"))
|
||||
.unwrap();
|
||||
let m = Measure(&atlas.font);
|
||||
let packs = vec![pack("file/Configurable", None), pack("file/Plain", None)];
|
||||
let mut screen = PackScreen::new(&m, tr("resourcePack.title"), 480, 320, false, packs, &[]);
|
||||
screen.with_options.insert("file/Configurable".into());
|
||||
let row = |screen: &PackScreen, id: &str| {
|
||||
screen.lists[0]
|
||||
.entries
|
||||
.iter()
|
||||
.position(|e| e.row == Row::Pack(id.into()))
|
||||
.unwrap()
|
||||
};
|
||||
assert!(
|
||||
screen
|
||||
.options_button(0, row(&screen, "file/Plain"))
|
||||
.is_none()
|
||||
);
|
||||
let (id, bx, by) = screen
|
||||
.options_button(0, row(&screen, "file/Configurable"))
|
||||
.unwrap();
|
||||
assert_eq!(id, "file/Configurable");
|
||||
let (cx, cy) = (bx + OPTIONS_SIZE / 2, by + OPTIONS_SIZE / 2);
|
||||
// Hovered, it explains itself.
|
||||
let draws = screen.draw(&m, (cx, cy), 0, 0);
|
||||
assert!(draws.iter().any(|d| matches!(d, Draw::Tooltip { .. })));
|
||||
assert_eq!(
|
||||
screen.click(&m, cx as f64, cy as f64),
|
||||
Action::Options("file/Configurable".into())
|
||||
);
|
||||
// The press opened the options, it did not move the pack.
|
||||
assert!(screen.commit().is_empty());
|
||||
}
|
||||
|
||||
/// Programmer Art and High Contrast draw their own `pack.png`; a pack
|
||||
/// without one keeps `unknown_pack`.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Tags as resource packs' files name them (`#minecraft:logs`): the ids
|
||||
//! `data/<namespace>/tags/<registry>/<path>.json` holds, nested tags included, read from a pack
|
||||
//! overlay root (which links the vanilla data) and kept once read.
|
||||
use serde_json::Value as Json;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// `minecraft:` for an id without a namespace.
|
||||
fn full_id(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
|
||||
/// The tags under one root, read on first use.
|
||||
#[derive(Debug)]
|
||||
pub struct Tags {
|
||||
root: PathBuf,
|
||||
cache: Mutex<HashMap<(String, String), Arc<HashSet<String>>>>,
|
||||
}
|
||||
|
||||
impl Tags {
|
||||
pub fn new(root: &Path) -> Self {
|
||||
Self {
|
||||
root: root.to_path_buf(),
|
||||
cache: Mutex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The ids in `tag` (`minecraft:logs`, without the `#`) of `registry` (`block`,
|
||||
/// `entity_type`, `worldgen/biome`...); empty for a tag no file defines.
|
||||
pub fn members(&self, registry: &str, tag: &str) -> Arc<HashSet<String>> {
|
||||
let key = (registry.to_owned(), full_id(tag));
|
||||
if let Some(hit) = self
|
||||
.cache
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(&key)
|
||||
{
|
||||
return hit.clone();
|
||||
}
|
||||
let mut out = HashSet::new();
|
||||
self.collect(registry, &key.1, &mut out, &mut HashSet::new());
|
||||
let out = Arc::new(out);
|
||||
self.cache
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(key, out.clone());
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `id` is in `tag` of `registry`.
|
||||
pub fn contains(&self, registry: &str, tag: &str, id: &str) -> bool {
|
||||
self.members(registry, tag).contains(id)
|
||||
}
|
||||
|
||||
/// The tag's ids into `out`; `seen` stops a tag that includes itself.
|
||||
fn collect(
|
||||
&self,
|
||||
registry: &str,
|
||||
tag: &str,
|
||||
out: &mut HashSet<String>,
|
||||
seen: &mut HashSet<String>,
|
||||
) {
|
||||
if !seen.insert(tag.to_owned()) {
|
||||
return;
|
||||
}
|
||||
let (namespace, path) = tag.split_once(':').unwrap_or(("minecraft", tag));
|
||||
let file = self
|
||||
.root
|
||||
.join(format!("data/{namespace}/tags/{registry}/{path}.json"));
|
||||
let Ok(json) = horizon_world::json_file::read::<Json>(&file) else {
|
||||
return;
|
||||
};
|
||||
for value in json["values"].as_array().into_iter().flatten() {
|
||||
// An entry is an id or `{"id": ..., "required": false}`.
|
||||
let id = value
|
||||
.as_str()
|
||||
.or_else(|| value["id"].as_str())
|
||||
.unwrap_or_default();
|
||||
match id.strip_prefix('#') {
|
||||
Some(inner) => self.collect(registry, &full_id(inner), out, seen),
|
||||
None if !id.is_empty() => {
|
||||
out.insert(full_id(id));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nested_tags_optional_entries_and_cycles_resolve() {
|
||||
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-pack-tags-{}-{n}", std::process::id()));
|
||||
let write = |path: &str, json: serde_json::Value| {
|
||||
let file = root.join(path);
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
std::fs::write(file, json.to_string()).unwrap();
|
||||
};
|
||||
write(
|
||||
"data/minecraft/tags/block/logs.json",
|
||||
serde_json::json!({"values": ["oak_log", "#pack:fancy", {"id": "mod:log", "required": false}]}),
|
||||
);
|
||||
// A tag that includes the one including it.
|
||||
write(
|
||||
"data/pack/tags/block/fancy.json",
|
||||
serde_json::json!({"values": ["minecraft:cherry_log", "#minecraft:logs"]}),
|
||||
);
|
||||
let tags = Tags::new(&root);
|
||||
let mut logs: Vec<String> = tags.members("block", "logs").iter().cloned().collect();
|
||||
logs.sort();
|
||||
assert_eq!(
|
||||
logs,
|
||||
["minecraft:cherry_log", "minecraft:oak_log", "mod:log"]
|
||||
);
|
||||
assert!(tags.contains("block", "minecraft:logs", "minecraft:oak_log"));
|
||||
// Another registry's tag of the same name, and a tag no file defines, are empty.
|
||||
assert!(tags.members("entity_type", "logs").is_empty());
|
||||
assert!(tags.members("block", "pack:none").is_empty());
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -522,8 +522,85 @@ pub fn polytone_configs(source: &Source) -> horizon_polytone::pack::Configs {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, value) in polytone_choices() {
|
||||
if configs.entries().contains_key(&id) {
|
||||
configs.set(&id, value);
|
||||
}
|
||||
}
|
||||
configs
|
||||
}
|
||||
|
||||
/// A pack's Polytone config entries and one's value, for the game's Pack Options.
|
||||
pub use horizon_polytone::pack::{ConfigValue, Configs};
|
||||
|
||||
/// The player's Polytone config choices (#11486): what they changed from the packs' defaults,
|
||||
/// by entry id, for every pack. `polytone_configs` lays them over each pack's defaults.
|
||||
static POLYTONE_CHOICES: std::sync::RwLock<BTreeMap<String, horizon_polytone::pack::ConfigValue>> =
|
||||
std::sync::RwLock::new(BTreeMap::new());
|
||||
|
||||
pub fn polytone_choices() -> BTreeMap<String, horizon_polytone::pack::ConfigValue> {
|
||||
POLYTONE_CHOICES
|
||||
.read()
|
||||
.map(|c| c.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Replaces the player's Polytone config choices (packs read them when they load again).
|
||||
pub fn set_polytone_choices(choices: BTreeMap<String, horizon_polytone::pack::ConfigValue>) {
|
||||
if let Ok(mut c) = POLYTONE_CHOICES.write() {
|
||||
*c = choices;
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the choices are kept: `polytone-configs.json` beside `options.txt`.
|
||||
pub fn polytone_choices_file() -> std::path::PathBuf {
|
||||
crate::logging::player_dir().join("polytone-configs.json")
|
||||
}
|
||||
|
||||
/// Reads the choices from `path` (none when it is missing or broken).
|
||||
pub fn load_polytone_choices(path: &Path) -> BTreeMap<String, horizon_polytone::pack::ConfigValue> {
|
||||
use horizon_polytone::pack::ConfigValue;
|
||||
let Ok(bytes) = fs::read(path) else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
let Ok(serde_json::Value::Object(map)) = serde_json::from_slice(&bytes) else {
|
||||
log::warn!("{}: not a JSON object", path.display());
|
||||
return BTreeMap::new();
|
||||
};
|
||||
map.into_iter()
|
||||
.filter_map(|(id, v)| {
|
||||
let value = match v {
|
||||
serde_json::Value::Number(n) => ConfigValue::Number(n.as_f64()?),
|
||||
serde_json::Value::String(s) => ConfigValue::Text(s),
|
||||
serde_json::Value::Bool(b) => ConfigValue::Bool(b),
|
||||
_ => return None,
|
||||
};
|
||||
Some((id, value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Writes the choices to `path`.
|
||||
pub fn save_polytone_choices(
|
||||
path: &Path,
|
||||
choices: &BTreeMap<String, horizon_polytone::pack::ConfigValue>,
|
||||
) -> std::io::Result<()> {
|
||||
use horizon_polytone::pack::ConfigValue;
|
||||
let map: serde_json::Map<String, serde_json::Value> = choices
|
||||
.iter()
|
||||
.map(|(id, v)| {
|
||||
let json = match v {
|
||||
ConfigValue::Number(n) => serde_json::json!(n),
|
||||
ConfigValue::Text(s) => serde_json::json!(s),
|
||||
ConfigValue::Bool(b) => serde_json::json!(b),
|
||||
};
|
||||
(id.clone(), json)
|
||||
})
|
||||
.collect();
|
||||
let text = serde_json::to_string_pretty(&serde_json::Value::Object(map))
|
||||
.map_err(std::io::Error::other)?;
|
||||
fs::write(path, text)
|
||||
}
|
||||
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
pack_for(id, title, source, RESOURCE_FORMAT)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ pub use horizon_items::firework::{DYE_FIREWORK_COLORS, FireworkExplosion, Firewo
|
||||
mod native;
|
||||
pub mod polytone;
|
||||
mod polytone_gpu;
|
||||
mod polytone_model;
|
||||
pub use native::{ParticleController, ParticleUpdate};
|
||||
|
||||
/// `ParticleOptions` with the parameters of the option classes used here.
|
||||
@@ -66,6 +67,9 @@ pub struct ParticleOptions {
|
||||
/// `nextInt(40) + 10` after constructing the particle. Set by the client for an older
|
||||
/// server only, never read from the wire.
|
||||
pub legacy_trail_lifetime: bool,
|
||||
/// The block or item a `BlockParticleOption` or `ItemParticleOption` carries
|
||||
/// (`minecraft:stone`), for resource packs' particle modifier filters.
|
||||
pub source: Option<std::sync::Arc<str>>,
|
||||
/// What the Polytone emitter that spawned this custom particle sets on it.
|
||||
pub polytone_seed: Option<std::sync::Arc<polytone::Seed>>,
|
||||
}
|
||||
@@ -85,14 +89,7 @@ impl ParticleOptions {
|
||||
legacy_entity_effect: None,
|
||||
legacy_trail_lifetime: false,
|
||||
polytone_seed: 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)
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
/// `GeyserParticleOptions(type, waterBlocks)`.
|
||||
@@ -226,6 +223,8 @@ enum Light {
|
||||
AgeEmission4,
|
||||
/// `FireflyParticle.getLightCoords`: block light fading in and out, no sky.
|
||||
Firefly,
|
||||
/// A Polytone particle's `light_level`: block light at least this level.
|
||||
MinBlock(u8),
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Behaviour {
|
||||
@@ -527,8 +526,10 @@ pub struct Particle {
|
||||
/// `Particle.random`: the particle's own generator (`RandomSource.create()`).
|
||||
random: LegacyRandom,
|
||||
controller: Option<Box<dyn native::Controller>>,
|
||||
/// A resource pack's Polytone custom particle type, for its `limit`.
|
||||
/// A resource pack's Polytone custom particle type, for its `limit` and `exclusion_radius`.
|
||||
polytone_type: Option<std::sync::Arc<str>>,
|
||||
/// A Polytone model particle's faces, drawn instead of its sprite quad.
|
||||
model: Option<std::sync::Arc<polytone_model::ParticleModel>>,
|
||||
/// How a Polytone custom particle's quad turns (`rotation_mode`).
|
||||
orientation: polytone_gpu::Orientation,
|
||||
/// Blended additively (a Polytone `additive_translucent` particle).
|
||||
@@ -895,6 +896,7 @@ impl Particle {
|
||||
random: LegacyRandom::new(0),
|
||||
controller: None,
|
||||
polytone_type: None,
|
||||
model: None,
|
||||
orientation: polytone_gpu::Orientation::Camera,
|
||||
additive: false,
|
||||
}
|
||||
@@ -1098,6 +1100,7 @@ impl Particle {
|
||||
let b = (b * b) * (b * b);
|
||||
(sky, (block + (b.clamp(0.0, 1.0) * 240.0) as u32).min(240))
|
||||
}
|
||||
Light::MinBlock(level) => (sky, block.max(u32::from(level) * 16)),
|
||||
Light::Firefly => (
|
||||
0,
|
||||
(255.0 * fade_amount(self.lifetime_progress(self.age as f32 + partial), 0.1, 0.3))
|
||||
@@ -1191,6 +1194,27 @@ pub struct QuadSnapshot {
|
||||
pub light: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TrackingTarget {
|
||||
LocalPlayer,
|
||||
Entity(i32),
|
||||
}
|
||||
|
||||
/// The entity position and current dimensions that `getX/Y/Z(double)` use.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct TrackingBody {
|
||||
pub feet: [f64; 3],
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
struct TrackingEmitter {
|
||||
options: ParticleOptions,
|
||||
target: Option<TrackingTarget>,
|
||||
body: TrackingBody,
|
||||
remaining: u32,
|
||||
}
|
||||
|
||||
/// World access for particle physics.
|
||||
pub struct ParticleLevel<'a> {
|
||||
pub world: &'a World,
|
||||
@@ -1265,6 +1289,9 @@ impl ParticleLevel<'_> {
|
||||
pub struct ParticleEngine {
|
||||
particles: Vec<Particle>,
|
||||
pending: Vec<Particle>,
|
||||
/// While the live particles tick (out of `particles`), what their emitters spawn waits
|
||||
/// here, so a spawn's `limit` and `exclusion_radius` see every live particle.
|
||||
spawned_in_tick: Option<Vec<(ParticleOptions, [f64; 3], [f64; 3])>>,
|
||||
/// `ParticleEngine.random`, handed to every `ParticleProvider`.
|
||||
random: LegacyRandom,
|
||||
/// `ClientLevel.random` as particle constructors use it (`level.getRandom()`).
|
||||
@@ -1293,13 +1320,15 @@ pub struct ParticleEngine {
|
||||
set_sprites: Vec<Vec<u32>>,
|
||||
/// Resource packs' Polytone custom particle types (#11486).
|
||||
polytone: Option<std::sync::Arc<polytone::Registry>>,
|
||||
/// Vanilla particle types a pack replaces with a custom one of the same id
|
||||
/// (`assets/minecraft/polytone/custom_particles/rain.json` is `minecraft:rain`).
|
||||
polytone_overrides: HashMap<ParticleType, String>,
|
||||
/// The packs' GPU particles' records, one store per type, and the ticks they count in.
|
||||
polytone_gpu: Vec<polytone_gpu::Store>,
|
||||
polytone_ticks: u64,
|
||||
/// The last frame's additively blended quads (`take_additive`).
|
||||
additive: std::sync::Mutex<Vec<Vertex>>,
|
||||
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
|
||||
emitters: Vec<(ParticleOptions, [f64; 3], f64, f64, u32)>,
|
||||
emitters: Vec<TrackingEmitter>,
|
||||
/// Camera position for the 32-block spawn limiter.
|
||||
pub camera: [f64; 3],
|
||||
/// The Particles option (`ParticleStatus`): 0 all, 1 decreased, 2 minimal.
|
||||
@@ -1477,6 +1506,7 @@ impl ParticleEngine {
|
||||
let mut engine = Self {
|
||||
particles: Vec::new(),
|
||||
pending: Vec::new(),
|
||||
spawned_in_tick: None,
|
||||
random: LegacyRandom::new(0x7061_7274),
|
||||
level_random: LegacyRandom::new(0x6c65_7665),
|
||||
spell_random: LegacyRandom::new(0x7370_656c),
|
||||
@@ -1491,6 +1521,7 @@ impl ParticleEngine {
|
||||
mod_sets: HashMap::new(),
|
||||
set_sprites: Vec::new(),
|
||||
polytone: None,
|
||||
polytone_overrides: HashMap::new(),
|
||||
polytone_gpu: Vec::new(),
|
||||
polytone_ticks: 0,
|
||||
additive: std::sync::Mutex::default(),
|
||||
@@ -1724,6 +1755,15 @@ impl ParticleEngine {
|
||||
};
|
||||
if !same {
|
||||
self.polytone_gpu.clear();
|
||||
self.polytone_overrides = registry
|
||||
.iter()
|
||||
.flat_map(|r| {
|
||||
ParticleType::ALL.iter().filter_map(|&ty| {
|
||||
let id = format!("minecraft:{}", ty.name());
|
||||
r.get(&id).is_some().then_some((ty, id))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
self.polytone = registry;
|
||||
}
|
||||
@@ -1910,27 +1950,51 @@ impl ParticleEngine {
|
||||
pub fn tracking_emitter(
|
||||
&mut self,
|
||||
options: ParticleOptions,
|
||||
feet: [f64; 3],
|
||||
width: f64,
|
||||
height: f64,
|
||||
target: TrackingTarget,
|
||||
body: TrackingBody,
|
||||
) {
|
||||
self.tracking_emitter_for(options, feet, width, height, 3);
|
||||
self.tracking_emitter_for(options, target, body, 3);
|
||||
}
|
||||
/// `createTrackingEmitter(entity, type, lifetime)`.
|
||||
pub fn tracking_emitter_for(
|
||||
&mut self,
|
||||
options: ParticleOptions,
|
||||
feet: [f64; 3],
|
||||
width: f64,
|
||||
height: f64,
|
||||
target: TrackingTarget,
|
||||
body: TrackingBody,
|
||||
life: u32,
|
||||
) {
|
||||
self.emit(&options, feet, width, height);
|
||||
self.emit(&options, body);
|
||||
if life > 1 {
|
||||
self.emitters.push((options, feet, width, height, life - 1));
|
||||
self.emitters.push(TrackingEmitter {
|
||||
options,
|
||||
target: Some(target),
|
||||
body,
|
||||
remaining: life - 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
fn emit(&mut self, options: &ParticleOptions, feet: [f64; 3], width: f64, height: f64) {
|
||||
/// Vanilla keeps the old entity reference until the emitter expires. Keep
|
||||
/// its final body if it leaves the level, even when the id is paired again.
|
||||
pub fn detach_tracking_target(
|
||||
&mut self,
|
||||
target: TrackingTarget,
|
||||
mut last_body: impl FnMut() -> Option<TrackingBody>,
|
||||
) {
|
||||
for emitter in &mut self.emitters {
|
||||
if emitter.target == Some(target) {
|
||||
emitter.body = last_body().unwrap_or(emitter.body);
|
||||
emitter.target = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `TrackingEmitter.tick`: the spawn attempts use the tracked entity's
|
||||
/// current feet and dimensions.
|
||||
fn emit(&mut self, options: &ParticleOptions, body: TrackingBody) {
|
||||
let TrackingBody {
|
||||
feet,
|
||||
width,
|
||||
height,
|
||||
} = body;
|
||||
for _ in 0..16 {
|
||||
let r = &mut self.random;
|
||||
let xa = f64::from(r.next_float() * 2.0 - 1.0);
|
||||
@@ -1938,9 +2002,9 @@ impl ParticleEngine {
|
||||
let za = f64::from(r.next_float() * 2.0 - 1.0);
|
||||
if xa * xa + ya * ya + za * za <= 1.0 {
|
||||
let p = [
|
||||
feet[0] + width * xa / 4.0,
|
||||
feet[0] + width * (xa / 4.0),
|
||||
feet[1] + height * (0.5 + ya / 4.0),
|
||||
feet[2] + width * za / 4.0,
|
||||
feet[2] + width * (za / 4.0),
|
||||
];
|
||||
self.add(options, p, [xa, ya + 0.2, za]);
|
||||
}
|
||||
@@ -1993,8 +2057,18 @@ impl ParticleEngine {
|
||||
return;
|
||||
}
|
||||
let seed = self.seeds.next_long();
|
||||
if let Some(particle) = self.make(options, p, v, seed) {
|
||||
if let Some(mut particle) = self.make(options, p, v, seed) {
|
||||
*self.made.entry(options.ty.name()).or_default() += 1;
|
||||
// Resource packs' Polytone particle modifiers (#11486), once as it spawns.
|
||||
if let Some(registry) = &self.polytone
|
||||
&& registry.modifies_particles()
|
||||
{
|
||||
let id = match &options.mod_particle {
|
||||
Some(name) => name.clone(),
|
||||
None => format!("minecraft:{}", options.ty.name()),
|
||||
};
|
||||
polytone::modify(registry, &id, options.source.as_deref(), &mut particle);
|
||||
}
|
||||
// The provider has drawn, so rejecting here keeps vanilla's RNG order.
|
||||
// shortcut: counts live plus queued particles by scanning instead of
|
||||
// vanilla's counted map; use a counter if spore spawning shows in profiles.
|
||||
@@ -2139,12 +2213,17 @@ impl ParticleEngine {
|
||||
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
|
||||
// A pack's custom particle: a new type, or one replacing the vanilla type of its id.
|
||||
let custom = match &o.mod_particle {
|
||||
Some(name) => Some(name.as_str()),
|
||||
None => self.polytone_overrides.get(&ty).map(String::as_str),
|
||||
};
|
||||
if let Some(name) = custom
|
||||
&& let Some(registry) = &self.polytone
|
||||
&& let Some(def) = registry.get(name)
|
||||
{
|
||||
let (registry, def) = (registry.clone(), def.clone());
|
||||
let particle = polytone::make(self, ®istry, name, &def, o, p, v, seed)?;
|
||||
let (registry, def, name) = (registry.clone(), def.clone(), name.to_owned());
|
||||
let particle = polytone::make(self, ®istry, &name, &def, o, p, v, seed)?;
|
||||
if def.gpu.is_some() {
|
||||
// A GPU particle is only a record: it never becomes an engine particle.
|
||||
let now = self.polytone_ticks;
|
||||
@@ -3793,7 +3872,16 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A tick where no emitter follows an entity's live body (tests, fixtures, benches).
|
||||
pub fn tick(&mut self, level: &ParticleLevel) {
|
||||
self.tick_tracking(level, |_| None);
|
||||
}
|
||||
/// A tick whose tracking emitters read each followed entity's live body.
|
||||
pub fn tick_tracking(
|
||||
&mut self,
|
||||
level: &ParticleLevel,
|
||||
mut tracking_body: impl FnMut(TrackingTarget) -> Option<TrackingBody>,
|
||||
) {
|
||||
self.polytone_ticks += 1;
|
||||
for store in &mut self.polytone_gpu {
|
||||
store.expire(self.polytone_ticks);
|
||||
@@ -3804,18 +3892,29 @@ impl ParticleEngine {
|
||||
}
|
||||
self.tick_starters();
|
||||
self.tick_explosions(level);
|
||||
for (options, feet, width, height, life) in std::mem::take(&mut self.emitters) {
|
||||
self.emit(&options, feet, width, height);
|
||||
if life > 1 {
|
||||
self.emitters.push((options, feet, width, height, life - 1));
|
||||
let mut emitters = std::mem::take(&mut self.emitters);
|
||||
emitters.retain_mut(|emitter| {
|
||||
if let Some(target) = emitter.target {
|
||||
match tracking_body(target) {
|
||||
Some(body) => emitter.body = body,
|
||||
None => emitter.target = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
self.emit(&emitter.options, emitter.body);
|
||||
emitter.remaining -= 1;
|
||||
emitter.remaining > 0
|
||||
});
|
||||
self.emitters = emitters;
|
||||
let mut particles = std::mem::take(&mut self.particles);
|
||||
self.spawned_in_tick = Some(Vec::new());
|
||||
for p in &mut particles {
|
||||
self.tick_particle(p, level);
|
||||
}
|
||||
particles.retain(|p| !p.removed);
|
||||
self.particles = particles;
|
||||
for (options, pos, velocity) in self.spawned_in_tick.take().unwrap_or_default() {
|
||||
self.add(&options, pos, velocity);
|
||||
}
|
||||
let pending = std::mem::take(&mut self.pending);
|
||||
for mut p in pending {
|
||||
if p.pre_tick {
|
||||
@@ -3923,8 +4022,12 @@ impl ParticleEngine {
|
||||
if let Some(frame) = update.frame {
|
||||
self.set_sprite_frame(p, frame);
|
||||
}
|
||||
for (options, pos, velocity) in update.spawned {
|
||||
self.add(&options, pos, velocity);
|
||||
if let Some(later) = &mut self.spawned_in_tick {
|
||||
later.extend(update.spawned);
|
||||
} else {
|
||||
for (options, pos, velocity) in update.spawned {
|
||||
self.add(&options, pos, velocity);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
p.remove();
|
||||
@@ -4868,7 +4971,10 @@ impl ParticleEngine {
|
||||
(pos.as_vec3(), p.quad_size(partial))
|
||||
});
|
||||
let rotation = if p.orientation != polytone_gpu::Orientation::Camera {
|
||||
let velocity = FVec3::new(
|
||||
// A custom particle's `movement_aligned` quad has its texture's top against
|
||||
// its motion (measured in Java: a light ray rising toward the noon sun hangs
|
||||
// below the leaves it starts at); a GPU particle's has it along its motion.
|
||||
let velocity = -FVec3::new(
|
||||
p.velocity[0] as f32,
|
||||
p.velocity[1] as f32,
|
||||
p.velocity[2] as f32,
|
||||
@@ -4958,6 +5064,35 @@ impl ParticleEngine {
|
||||
r.v0 + dv * p.uv[3],
|
||||
);
|
||||
let target: &mut Vec<Vertex> = if p.additive { &mut additive } else { &mut *out };
|
||||
let overlay = if p.additive {
|
||||
crate::gfx::ADDITIVE_PARTICLE_OVERLAY
|
||||
} else if layer {
|
||||
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// A Polytone model particle: its model's faces, scaled by its size and turned as
|
||||
// its quad would be.
|
||||
if let Some(model) = &p.model {
|
||||
for quad in &model.quads {
|
||||
let Some(&(r, _)) = self.atlas.get(&quad.sprite) else {
|
||||
continue;
|
||||
};
|
||||
let (du, dv) = (r.u1 - r.u0, r.v1 - r.v0);
|
||||
for (corner, uv) in quad.corners.iter().zip(quad.uv) {
|
||||
target.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: (center + rotation * (*corner * size)).to_array(),
|
||||
uv: [r.u0 + du * uv[0], r.v0 + dv * uv[1]],
|
||||
layer: overlay | u32::from(hdr_tier(p.ty)) << 16,
|
||||
color,
|
||||
light,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for rotation in rotations.into_iter().flatten() {
|
||||
for (n, uv) in [
|
||||
((1.0, -1.0), [u1, v1]),
|
||||
@@ -4970,11 +5105,7 @@ impl ParticleEngine {
|
||||
normal: [0; 4],
|
||||
pos: v.to_array(),
|
||||
uv,
|
||||
layer: if layer {
|
||||
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
|
||||
} else {
|
||||
0
|
||||
} | u32::from(hdr_tier(p.ty)) << 16,
|
||||
layer: overlay | u32::from(hdr_tier(p.ty)) << 16,
|
||||
color,
|
||||
light,
|
||||
|
||||
@@ -5062,6 +5193,8 @@ const CREEPER: [[f64; 2]; 12] = [
|
||||
[0.0, -0.4],
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tick_bench;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -37,6 +37,17 @@ impl Clone for Box<dyn Controller> {
|
||||
}
|
||||
}
|
||||
|
||||
impl super::ParticleOptions {
|
||||
/// 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(super::ParticleType::Smoke)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::*;
|
||||
|
||||
@@ -11,8 +11,8 @@ use crate::environment::Frame;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_polytone::expr::{Host, Program, Root, Value as V};
|
||||
use horizon_polytone::particles::{
|
||||
BlockEmitters, CustomParticle, Emitter, EntityEmitters, LiquidAffinity, RenderType,
|
||||
SoundEmitter,
|
||||
BlockEmitters, CustomParticle, Emitter, EntityEmitters, LiquidAffinity, ParticleModifier,
|
||||
RenderType, SoundEmitter,
|
||||
};
|
||||
use horizon_world::registry::BlockRegistry;
|
||||
use serde_json::Value as Json;
|
||||
@@ -37,10 +37,17 @@ pub struct Registry {
|
||||
blocks: &'static BlockRegistry,
|
||||
/// Each custom particle type's colormap (`colormap`), by type id.
|
||||
tints: HashMap<String, Arc<Tint>>,
|
||||
/// The tags modifiers' `targets` and emitters' `biomes` name.
|
||||
tags: crate::pack_tags::Tags,
|
||||
/// Each model particle type's faces (`model`), by type id.
|
||||
models: HashMap<String, Arc<super::polytone_model::ParticleModel>>,
|
||||
/// Sound ids as the engine's sound queue holds them, interned once.
|
||||
sound_names: Mutex<HashMap<String, &'static str>>,
|
||||
/// Sounds the custom particles played this tick (`ParticleEngine::tick` plays them).
|
||||
sounds: Mutex<Vec<Sound>>,
|
||||
/// The particle modifiers with their colormaps, and by particle type the ones aimed at it.
|
||||
particle_modifiers: Vec<(ParticleModifier, Option<Tint>, Option<Filter>)>,
|
||||
modifiers_by_type: Mutex<HashMap<String, Arc<[usize]>>>,
|
||||
}
|
||||
|
||||
/// A sound for the engine's queue: id, position, volume, pitch, delayed by the distance.
|
||||
@@ -140,6 +147,15 @@ fn json_files(root: &Path, dir: &Path) -> Vec<(String, Json)> {
|
||||
out
|
||||
}
|
||||
|
||||
impl super::ParticleOptions {
|
||||
/// These options with the block or item they carry (`source`), for particle modifier
|
||||
/// filters.
|
||||
pub fn with_source(mut self, id: &str) -> Self {
|
||||
self.source = Some(Arc::from(id));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
/// Every `assets/<namespace>/polytone/{custom_particles,block_modifiers,entity_modifiers}/**.json` under a
|
||||
/// pack overlay root.
|
||||
@@ -151,6 +167,7 @@ impl Registry {
|
||||
let mut types = HashMap::new();
|
||||
let mut modifiers = Vec::new();
|
||||
let mut entity_modifiers = Vec::new();
|
||||
let mut particle_modifiers = Vec::new();
|
||||
let namespaces = std::fs::read_dir(pack_root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -176,6 +193,24 @@ impl Registry {
|
||||
Err(e) => log::warn!("{relative}: {e}"),
|
||||
}
|
||||
}
|
||||
for (relative, json) in json_files(pack_root, &polytone.join("particle_modifiers")) {
|
||||
let implicit = horizon_polytone::implicit_id(&relative, "particle_modifiers");
|
||||
match ParticleModifier::parse(&json, implicit.as_deref()) {
|
||||
Ok(Some(m)) => {
|
||||
let tint = m.colormap.as_ref().and_then(|json| {
|
||||
let tint = Tint::read(pack_root, json);
|
||||
if tint.is_none() {
|
||||
log::warn!("{relative}: colormap {json} not found");
|
||||
}
|
||||
tint
|
||||
});
|
||||
let filter = m.filter.as_ref().map(Filter::read);
|
||||
particle_modifiers.push((m, tint, filter));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => log::warn!("{relative}: {e}"),
|
||||
}
|
||||
}
|
||||
for (relative, json) in json_files(pack_root, &polytone.join("block_modifiers")) {
|
||||
let implicit = horizon_polytone::implicit_id(&relative, "block_modifiers");
|
||||
match BlockEmitters::parse(&json, implicit.as_deref()) {
|
||||
@@ -185,7 +220,8 @@ impl Registry {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Each block's modifiers: the highest priority ones (block tags aren't resolved here).
|
||||
// Each block's modifiers: the highest priority ones.
|
||||
let tags = crate::pack_tags::Tags::new(pack_root);
|
||||
let mut by_name: HashMap<&str, Option<Arc<[Arc<BlockEmitters>]>>> = HashMap::new();
|
||||
let block_emitters = (0..blocks.len() as u32)
|
||||
.map(|i| {
|
||||
@@ -198,7 +234,10 @@ impl Registry {
|
||||
.or_insert_with(|| {
|
||||
let aimed: Vec<_> = modifiers
|
||||
.iter()
|
||||
.filter(|m| m.targets.matches(name, |_| false))
|
||||
.filter(|m| {
|
||||
m.targets
|
||||
.matches(name, |tag| tags.contains("block", tag, name))
|
||||
})
|
||||
.collect();
|
||||
let top = aimed.iter().map(|m| m.priority).max()?;
|
||||
Some(
|
||||
@@ -212,6 +251,12 @@ impl Registry {
|
||||
.clone()
|
||||
})
|
||||
.collect();
|
||||
let models = super::polytone_model::read_models(
|
||||
pack_root,
|
||||
types
|
||||
.iter()
|
||||
.filter_map(|(id, def)| Some((id, def.render.get("model")?))),
|
||||
);
|
||||
let tints = types
|
||||
.iter()
|
||||
.filter_map(|(id, def)| {
|
||||
@@ -235,8 +280,12 @@ impl Registry {
|
||||
configs,
|
||||
blocks,
|
||||
tints,
|
||||
models,
|
||||
tags,
|
||||
sound_names: Mutex::default(),
|
||||
sounds: Mutex::default(),
|
||||
particle_modifiers,
|
||||
modifiers_by_type: Mutex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +293,34 @@ impl Registry {
|
||||
self.types.is_empty()
|
||||
&& self.block_emitters.iter().all(Option::is_none)
|
||||
&& self.entity_modifiers.is_empty()
|
||||
&& self.particle_modifiers.is_empty()
|
||||
}
|
||||
|
||||
/// Whether any pack modifies vanilla or mod particles (`particle_modifiers`).
|
||||
pub(super) fn modifies_particles(&self) -> bool {
|
||||
!self.particle_modifiers.is_empty()
|
||||
}
|
||||
|
||||
/// The particle modifiers aimed at a particle type (`minecraft:flame`), in the order read;
|
||||
/// `#tags` in `targets` are particle type tags.
|
||||
fn modifiers_for(&self, id: &str) -> Arc<[usize]> {
|
||||
let mut cache = self
|
||||
.modifiers_by_type
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(found) = cache.get(id) {
|
||||
return found.clone();
|
||||
}
|
||||
let found: Arc<[usize]> = (0..self.particle_modifiers.len())
|
||||
.filter(|&i| {
|
||||
self.particle_modifiers[i]
|
||||
.0
|
||||
.targets
|
||||
.matches(id, |tag| self.tags.contains("particle_type", tag, id))
|
||||
})
|
||||
.collect();
|
||||
cache.insert(id.to_owned(), found.clone());
|
||||
found
|
||||
}
|
||||
|
||||
/// A sound id as the engine's sound queue holds it.
|
||||
@@ -277,8 +354,8 @@ impl Registry {
|
||||
}
|
||||
}
|
||||
|
||||
/// The entity modifiers aimed at an entity type (`minecraft:player`); entity tags aren't
|
||||
/// resolved here.
|
||||
/// The entity modifiers aimed at an entity type (`minecraft:player`); `#tags` in `targets`
|
||||
/// are entity type tags.
|
||||
fn entity_emitters(&self, entity: &str) -> Arc<[Arc<EntityEmitters>]> {
|
||||
let mut cache = self
|
||||
.entity_emitters
|
||||
@@ -289,7 +366,10 @@ impl Registry {
|
||||
.or_insert_with(|| {
|
||||
self.entity_modifiers
|
||||
.iter()
|
||||
.filter(|m| m.targets.matches(entity, |_| false))
|
||||
.filter(|m| {
|
||||
m.targets
|
||||
.matches(entity, |tag| self.tags.contains("entity_type", tag, entity))
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
})
|
||||
@@ -661,7 +741,7 @@ impl Host for Ctx<'_> {
|
||||
(CAMERA, "x" | "y" | "z") => num(frame.camera[axis(name)]),
|
||||
(CAMERA, "viewDistance") => num(f64::from(frame.render_distance * 16)),
|
||||
(CAMERA, _) => self.positional(ParticleLevel::block_at(frame.camera), name, args),
|
||||
(GLOBAL, "rain") => num(f64::from(frame.rain)),
|
||||
(GLOBAL, "rain") => num(frame.global_rain()),
|
||||
(GLOBAL, "time") => num(frame.game_time as f64),
|
||||
(GLOBAL, "dayTime") => num(frame.day_time as f64),
|
||||
(GLOBAL, "skyType") => num(f64::from(frame.sky_type)),
|
||||
@@ -760,7 +840,7 @@ fn state_matches(predicate: &Json, ctx: &Ctx, at: [i32; 3]) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `biomes` (an id, a list of ids, or a tag, which isn't resolved here) holds the biome
|
||||
/// Whether `biomes` (an id, a `#tag`, or a list of them) holds the biome
|
||||
/// at `at`.
|
||||
fn biome_matches(biomes: &Json, ctx: &Ctx, at: [i32; 3]) -> bool {
|
||||
let Some(biome) = ctx.biome(at) else {
|
||||
@@ -773,9 +853,14 @@ fn biome_matches(biomes: &Json, ctx: &Ctx, at: [i32; 3]) -> bool {
|
||||
format!("minecraft:{s}")
|
||||
}
|
||||
};
|
||||
// An id, or a `#tag` of biomes.
|
||||
let holds = |entry: &str| match entry.strip_prefix('#') {
|
||||
Some(tag) => ctx.registry.tags.contains("worldgen/biome", tag, &biome),
|
||||
None => id(entry) == biome,
|
||||
};
|
||||
match biomes {
|
||||
Json::String(s) => id(s) == biome,
|
||||
Json::Array(list) => list.iter().filter_map(Json::as_str).any(|s| id(s) == biome),
|
||||
Json::String(s) => holds(s),
|
||||
Json::Array(list) => list.iter().filter_map(Json::as_str).any(holds),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
@@ -1044,11 +1129,11 @@ impl super::ParticleController for Controller {
|
||||
}
|
||||
// Moves with collision when it has physics, else freely; then the friction.
|
||||
if def.has_physics {
|
||||
let before = p.position;
|
||||
p.move_by(level, p.velocity);
|
||||
let still = (0..3).all(|i| (p.position[i] - before[i]).abs() < 1.0e-7);
|
||||
// `kill_when_still`: a particle a collision stopped (one that landed), not one that
|
||||
// was never moving: in Java a resting emitter with physics lives out its life.
|
||||
if (def.kill_on_contact && (p.on_ground || p.stopped_by_collision))
|
||||
|| (def.kill_when_still && still)
|
||||
|| (def.kill_when_still && p.stopped_by_collision)
|
||||
{
|
||||
p.remove();
|
||||
}
|
||||
@@ -1105,19 +1190,41 @@ pub(super) fn make(
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// `exclusion_radius`: none spawns within that distance of a live one of its type (rain
|
||||
// splashes, one per patch of ground).
|
||||
if def.exclusion_radius > 0.0 && def.gpu.is_none() {
|
||||
let r2 = def.exclusion_radius * def.exclusion_radius;
|
||||
let crowded = engine.particles.iter().chain(&engine.pending).any(|p| {
|
||||
!p.removed
|
||||
&& p.polytone_type.as_deref() == Some(name)
|
||||
&& (0..3)
|
||||
.map(|i| (p.position[i] - position[i]).powi(2))
|
||||
.sum::<f64>()
|
||||
< r2
|
||||
});
|
||||
if crowded {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let mut random = LegacyRandom::new(seed);
|
||||
let mut particle = Particle::at(&mut random, ParticleType::Smoke, position);
|
||||
particle.velocity = velocity;
|
||||
particle.gravity = 0.0;
|
||||
particle.has_physics = def.has_physics;
|
||||
let mut frames = 0;
|
||||
// A model particle (`model`) isn't drawn yet: it still ticks and emits. A GPU particle is
|
||||
// drawn from its record instead (`polytone_gpu`).
|
||||
// A model particle draws its model's faces; one whose model is missing still ticks and
|
||||
// emits. A GPU particle is drawn from its record instead (`polytone_gpu`).
|
||||
let model = registry.models.get(name).cloned();
|
||||
let hidden = def.render_type == RenderType::Invisible
|
||||
|| def.render.contains_key("model")
|
||||
|| (def.render.contains_key("model") && model.is_none())
|
||||
|| def.gpu.is_some();
|
||||
match engine.mod_sets.get(name) {
|
||||
Some(&(set, _)) => {
|
||||
// A mod's sprite set, or for a type replacing a vanilla one, that type's.
|
||||
let set = engine.mod_sets.get(name).map(|&(set, _)| set).or_else(|| {
|
||||
let vanilla = name.strip_prefix("minecraft:")?;
|
||||
engine.sets.get(&ParticleType::from_name(vanilla)?).copied()
|
||||
});
|
||||
match set {
|
||||
Some(set) => {
|
||||
let sprites = engine.set_sprites.get(set as usize)?;
|
||||
frames = sprites.len();
|
||||
particle.set = Some(set);
|
||||
@@ -1129,11 +1236,22 @@ pub(super) fn make(
|
||||
particle.sprite = Sprite::Index(*sprites.get(pick).or(sprites.first())?);
|
||||
}
|
||||
None if hidden => {}
|
||||
None if model.is_some() => {}
|
||||
None => return None,
|
||||
}
|
||||
particle.no_render = hidden;
|
||||
if let Some(model) = model {
|
||||
// The sprite lookup that admits a particle to drawing: its first face's.
|
||||
particle.sprite = Sprite::Named(model.quads[0].sprite.clone());
|
||||
particle.model = Some(model);
|
||||
}
|
||||
particle.orientation = super::polytone_gpu::Orientation::parse(&def.rotation_mode);
|
||||
particle.additive = def.render_type == RenderType::AdditiveTranslucent;
|
||||
// `light_level`: the block light the particle gives itself (wiki "Custom Particle Types"),
|
||||
// a floor under the light where it is, as GPU particles have.
|
||||
if def.light_level > 0 {
|
||||
particle.light = super::Light::MinBlock(def.light_level);
|
||||
}
|
||||
particle.behaviour = Behaviour::Controlled {
|
||||
translucent: def.render_type != RenderType::Opaque,
|
||||
};
|
||||
@@ -1170,7 +1288,8 @@ pub(super) fn make(
|
||||
customs.extend(seed.customs.iter().map(|(k, v)| (k.clone(), *v)));
|
||||
}
|
||||
particle.random = random;
|
||||
particle.polytone_type = def.limit.map(|_| Arc::from(name));
|
||||
particle.polytone_type =
|
||||
(def.limit.is_some() || def.exclusion_radius > 0.0).then(|| Arc::from(name));
|
||||
particle.bb = super::aabb_at(position, particle.bb_width, particle.bb_height);
|
||||
particle.controller = Some(Box::new(Controller {
|
||||
def: def.clone(),
|
||||
@@ -1185,6 +1304,108 @@ pub(super) fn make(
|
||||
Some(particle)
|
||||
}
|
||||
|
||||
/// A particle modifier's `filter`: the blocks and items (an id or a list each) whose particles
|
||||
/// it changes.
|
||||
struct Filter {
|
||||
blocks: Vec<String>,
|
||||
items: Vec<String>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
fn read(json: &Json) -> Self {
|
||||
let ids = |key: &str| -> Vec<String> {
|
||||
let id = |s: &str| {
|
||||
if s.contains(':') {
|
||||
s.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{s}")
|
||||
}
|
||||
};
|
||||
match &json[key] {
|
||||
Json::String(s) => vec![id(s)],
|
||||
Json::Array(list) => list.iter().filter_map(Json::as_str).map(id).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
Self {
|
||||
blocks: ids("block"),
|
||||
items: ids("item"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a particle carrying `source` (a block's or an item's id) passes.
|
||||
fn passes(&self, source: Option<&str>) -> bool {
|
||||
source.is_some_and(|s| self.blocks.iter().chain(&self.items).any(|id| id == s))
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the packs' particle modifiers aimed at `id` on a particle that was just made: each
|
||||
/// expression once, against the particle as its constructor left it (`size` scales it, `speed`
|
||||
/// sets its power, as vanilla's `Particle.scale` and `setPower`).
|
||||
pub(super) fn modify(registry: &Registry, id: &str, source: Option<&str>, p: &mut Particle) {
|
||||
let modifiers = registry.modifiers_for(id);
|
||||
if modifiers.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(frame) = registry.frame.read() else {
|
||||
return;
|
||||
};
|
||||
let mut random = std::mem::replace(&mut p.random, LegacyRandom::new(0));
|
||||
let (mut custom, mut customs) = (0.0, BTreeMap::new());
|
||||
for &i in modifiers.iter() {
|
||||
let (modifier, tint, filter) = ®istry.particle_modifiers[i];
|
||||
if filter.as_ref().is_some_and(|f| !f.passes(source)) {
|
||||
continue;
|
||||
}
|
||||
let mut ctx = Ctx {
|
||||
subject: Subject::Particle(p),
|
||||
random: &mut random,
|
||||
custom: &mut custom,
|
||||
customs: &mut customs,
|
||||
frame: &frame,
|
||||
registry,
|
||||
level: None,
|
||||
blocks: Vec::new(),
|
||||
};
|
||||
// Every expression reads the particle before this modifier changes it.
|
||||
let value =
|
||||
|ctx: &mut Ctx, key: &str| modifier.fields.get(key).and_then(|prog| eval(prog, ctx));
|
||||
let values = MODIFIED.map(|key| value(&mut ctx, key));
|
||||
let tinted = tint.as_ref().and_then(|t| t.sample(&mut ctx));
|
||||
drop(ctx);
|
||||
let [color, red, green, blue, alpha, life, size, speed] = values;
|
||||
if let Some(rgb) = tinted {
|
||||
p.rgba[..3].copy_from_slice(&rgb);
|
||||
}
|
||||
if let Some(c) = color {
|
||||
let c = c as i64;
|
||||
for (channel, shift) in [16, 8, 0].into_iter().enumerate() {
|
||||
p.rgba[channel] = ((c >> shift) & 0xff) as f32 / 255.0;
|
||||
}
|
||||
}
|
||||
for (channel, v) in [red, green, blue, alpha].into_iter().enumerate() {
|
||||
if let Some(v) = v {
|
||||
p.rgba[channel] = v as f32;
|
||||
}
|
||||
}
|
||||
if let Some(life) = life {
|
||||
p.lifetime = life as i32;
|
||||
}
|
||||
if let Some(size) = size {
|
||||
p.scale(size as f32);
|
||||
}
|
||||
if let Some(speed) = speed {
|
||||
p.set_power(speed as f32);
|
||||
}
|
||||
}
|
||||
p.random = random;
|
||||
}
|
||||
|
||||
/// The fields a particle modifier sets, in the order `modify` applies them.
|
||||
const MODIFIED: [&str; 8] = [
|
||||
"color", "red", "green", "blue", "alpha", "life", "size", "speed",
|
||||
];
|
||||
|
||||
impl ParticleEngine {
|
||||
/// The emitters of the packs' entity modifiers aimed at `entity` (its type, as
|
||||
/// `minecraft:player`) standing at `position`, run once a client tick.
|
||||
@@ -1228,6 +1449,19 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the packs' block modifiers stop `state`'s own particles (`disable_particles`): its
|
||||
/// `Block.animateTick` doesn't run.
|
||||
pub fn polytone_disables_particles(
|
||||
&self,
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
) -> bool {
|
||||
self.polytone
|
||||
.as_ref()
|
||||
.and_then(|r| r.block_emitters.get(state.0 as usize))
|
||||
.and_then(Option::as_ref)
|
||||
.is_some_and(|modifiers| modifiers.iter().any(|m| m.disable_particles))
|
||||
}
|
||||
|
||||
/// The block emitters of the packs' block modifiers aimed at the block at `at`, run from a
|
||||
/// block animate tick (`Block.animateTick`, as torches spawn their flames).
|
||||
pub fn polytone_animate(&mut self, level: &ParticleLevel<'_>, at: [i32; 3]) {
|
||||
@@ -1393,6 +1627,22 @@ mod tests {
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disable_particles_stops_only_its_blocks_own_particles() {
|
||||
let blocks = blocks();
|
||||
let root = pack(&[(
|
||||
"assets/minecraft/polytone/block_modifiers/torch.json",
|
||||
serde_json::json!({"disable_particles": true}),
|
||||
)]);
|
||||
let engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
let state = |name| blocks.default_state(name).unwrap();
|
||||
assert!(engine.polytone_disables_particles(state("minecraft:torch")));
|
||||
assert!(!engine.polytone_disables_particles(state("minecraft:air")));
|
||||
// Without a Polytone pack nothing is stopped.
|
||||
let plain = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
assert!(!plain.polytone_disables_particles(state("minecraft:torch")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_emitter_spawns_a_custom_particle_its_values_seed() {
|
||||
let blocks = blocks();
|
||||
@@ -1420,6 +1670,39 @@ mod tests {
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// `light_level` is a floor under the block light a custom particle is drawn with (as the
|
||||
/// real mod draws a lit `light_level` 8 particle at night, measured beside it): the world's
|
||||
/// light where it is brighter.
|
||||
#[test]
|
||||
fn light_level_is_a_floor_under_the_block_light() {
|
||||
let blocks = blocks();
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/test/polytone/custom_particles/spark.json",
|
||||
serde_json::json!({"has_physics": false, "light_level": 8, "initializer": {"lifetime": "10"}}),
|
||||
),
|
||||
(
|
||||
"assets/test/polytone/block_modifiers/torch.json",
|
||||
serde_json::json!({"targets": ["minecraft:torch"], "particle_emitters": [{"particle": "test:spark", "chance": "1"}]}),
|
||||
),
|
||||
]);
|
||||
let scene = scene(blocks);
|
||||
let level = ParticleLevel {
|
||||
world: &scene.world,
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
engine.polytone_animate(&level, [1, 64, 1]);
|
||||
let p = engine.pending.pop().unwrap();
|
||||
// Dark: the floor, 8 (light coordinates count 16 a level). The sky light is the world's.
|
||||
assert_eq!(p.light_coords(0.0, 240, 0), (240, 128));
|
||||
// A brighter block light is kept.
|
||||
assert_eq!(p.light_coords(0.0, 0, 208), (0, 208));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ticker_moves_the_particle_and_removes_it() {
|
||||
let blocks = blocks();
|
||||
@@ -1497,6 +1780,199 @@ mod tests {
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_particle_spawns_within_the_exclusion_radius_of_its_type() {
|
||||
let blocks = blocks();
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/test/polytone/custom_particles/splash.json",
|
||||
serde_json::json!({"has_physics": false, "exclusion_radius": 1, "render_type": "invisible"}),
|
||||
),
|
||||
(
|
||||
"assets/test/polytone/custom_particles/drop.json",
|
||||
serde_json::json!({"has_physics": false, "render_type": "invisible"}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/player.json",
|
||||
serde_json::json!({"emitters": [
|
||||
{"particle": "test:splash", "x": "0", "chance": "1"},
|
||||
// 0.5 from the first: excluded.
|
||||
{"particle": "test:splash", "x": "0.5", "chance": "1"},
|
||||
// 3 away: spawns.
|
||||
{"particle": "test:splash", "x": "3", "chance": "1"},
|
||||
// Another type is not excluded by it.
|
||||
{"particle": "test:drop", "x": "0.5", "chance": "1"}
|
||||
]}),
|
||||
),
|
||||
]);
|
||||
let scene = scene(blocks);
|
||||
let level = ParticleLevel {
|
||||
world: &scene.world,
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
};
|
||||
let registry = Registry::read(&root, Default::default(), blocks);
|
||||
let mut engine = engine(registry);
|
||||
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
|
||||
let mut xs: Vec<f64> = engine.pending.iter().map(|p| p.position[0]).collect();
|
||||
xs.sort_by(f64::total_cmp);
|
||||
assert_eq!(xs, [1.0, 1.5, 4.0]);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// Measured in Java: a custom particle's `movement_aligned` quad has its texture's top
|
||||
/// against its motion, so the freepack's light rays, rising toward the noon sun, hang
|
||||
/// below the leaves they start at instead of over them.
|
||||
#[test]
|
||||
fn a_rising_movement_aligned_particle_hangs_its_texture_below() {
|
||||
let blocks = blocks();
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/test/polytone/custom_particles/spark.json",
|
||||
serde_json::json!({"has_physics": false, "rotation_mode": "movement_aligned",
|
||||
"initializer": {"lifetime": "100", "size": "1"}}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/player.json",
|
||||
serde_json::json!({"emitters": [{"particle": "test:spark", "chance": "1", "dy": "0.01"}]}),
|
||||
),
|
||||
]);
|
||||
let scene = scene(blocks);
|
||||
let level = ParticleLevel {
|
||||
world: &scene.world,
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
let rect = crate::hud::Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
engine.rects = vec![Some(rect); 13];
|
||||
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
|
||||
engine.tick(&level);
|
||||
let p = engine.particles.last().unwrap();
|
||||
// The camera 5 blocks south, at the particle's height.
|
||||
let camera = DVec3::new(p.position[0], p.position[1], p.position[2] + 5.0);
|
||||
let mut out = Vec::new();
|
||||
engine.render(camera, 0.0, 0.0, 0.0, &|_| 0xF0, &mut out);
|
||||
assert_eq!(out.len(), 4);
|
||||
for v in &out {
|
||||
// The texture's top row (v 0) under the centre, its bottom row over it.
|
||||
if v.uv[1] == 0.0 {
|
||||
assert!(v.pos[1] < 0.0, "top row at {:?}", v.pos);
|
||||
} else {
|
||||
assert!(v.pos[1] > 0.0, "bottom row at {:?}", v.pos);
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// An `additive_translucent` custom particle takes the fog in Java, as vanilla's particle
|
||||
/// shader gives it: its quads say so to the additive pass.
|
||||
#[test]
|
||||
fn an_additive_custom_particle_takes_the_particle_shader_overlay() {
|
||||
let blocks = blocks();
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/test/polytone/custom_particles/spark.json",
|
||||
serde_json::json!({"has_physics": false, "render_type": "additive_translucent",
|
||||
"initializer": {"lifetime": "100", "size": "1"}}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/player.json",
|
||||
serde_json::json!({"emitters": [{"particle": "test:spark", "chance": "1"}]}),
|
||||
),
|
||||
]);
|
||||
let scene = scene(blocks);
|
||||
let level = ParticleLevel {
|
||||
world: &scene.world,
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
let rect = crate::hud::Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
engine.rects = vec![Some(rect); 13];
|
||||
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
|
||||
engine.tick(&level);
|
||||
let mut out = Vec::new();
|
||||
engine.render(
|
||||
DVec3::new(1.0, 64.0, 6.0),
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
&|_| 0xF0,
|
||||
&mut out,
|
||||
);
|
||||
assert!(out.is_empty());
|
||||
let additive = engine.take_additive();
|
||||
assert_eq!(additive.len(), 4);
|
||||
assert!(
|
||||
additive
|
||||
.iter()
|
||||
.all(|v| v.layer & 0xffff == crate::gfx::ADDITIVE_PARTICLE_OVERLAY)
|
||||
);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// A particle's emitter spawns while the live particles tick: its children's
|
||||
/// `exclusion_radius` sees the ones alive, so a ray emitted every tick at one spot stays one.
|
||||
#[test]
|
||||
fn a_particles_emitter_respects_the_exclusion_radius_of_its_children() {
|
||||
let blocks = blocks();
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/test/polytone/custom_particles/holder.json",
|
||||
serde_json::json!({"has_physics": false, "render_type": "invisible",
|
||||
"initializer": {"lifetime": "100"},
|
||||
"particle_emitters": [{"particle": "test:ray", "chance": "1"}]}),
|
||||
),
|
||||
(
|
||||
"assets/test/polytone/custom_particles/ray.json",
|
||||
serde_json::json!({"has_physics": false, "render_type": "invisible",
|
||||
"exclusion_radius": 1, "initializer": {"lifetime": "100"}}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/player.json",
|
||||
serde_json::json!({"emitters": [{"particle": "test:holder", "chance": "1"}]}),
|
||||
),
|
||||
]);
|
||||
let scene = scene(blocks);
|
||||
let level = ParticleLevel {
|
||||
world: &scene.world,
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
engine.polytone_entity(&level, "minecraft:player", [1.0, 70.0, 1.0]);
|
||||
for _ in 0..10 {
|
||||
engine.tick(&level);
|
||||
}
|
||||
let rays = engine
|
||||
.particles
|
||||
.iter()
|
||||
.chain(&engine.pending)
|
||||
.filter(|p| p.polytone_type.as_deref() == Some("test:ray"))
|
||||
.count();
|
||||
assert_eq!(rays, 1);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gpu_particle_is_a_record_drawn_until_its_life_ends() {
|
||||
let blocks = blocks();
|
||||
@@ -1627,4 +2103,201 @@ mod tests {
|
||||
);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particle_modifiers_scale_power_and_recolour_their_particles_once() {
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/minecraft/polytone/particle_modifiers/flame.json",
|
||||
serde_json::json!({"size": "2", "life": "LIFETIME + 5", "red": "0.5", "speed": "2"}),
|
||||
),
|
||||
(
|
||||
"assets/pack/polytone/particle_modifiers/smoky.json",
|
||||
serde_json::json!({"targets": ["minecraft:smoke"], "color": "0x336699"}),
|
||||
),
|
||||
]);
|
||||
let registry = Registry::read(&root, Default::default(), blocks());
|
||||
let flame = || {
|
||||
let mut p = Particle::blank(ParticleType::Flame, [0.0; 3]);
|
||||
p.size = 0.1;
|
||||
p.lifetime = 10;
|
||||
p.velocity = [0.25, f64::from(0.1f32), -0.5];
|
||||
p
|
||||
};
|
||||
let mut p = flame();
|
||||
modify(®istry, "minecraft:flame", None, &mut p);
|
||||
// `size` multiplies, `speed` is vanilla's power: x and z doubled, a y of 0.1 kept.
|
||||
assert_eq!((p.size, p.lifetime, p.rgba[0]), (0.2, 15, 0.5));
|
||||
assert_eq!(p.velocity, [0.5, f64::from(0.1f32), -1.0]);
|
||||
// Another type's modifier, by its targets: the packed colour.
|
||||
let mut p = flame();
|
||||
modify(®istry, "minecraft:smoke", None, &mut p);
|
||||
assert_eq!(
|
||||
&p.rgba[..3],
|
||||
&[
|
||||
0x33 as f32 / 255.0,
|
||||
0x66 as f32 / 255.0,
|
||||
0x99 as f32 / 255.0
|
||||
]
|
||||
);
|
||||
assert_eq!((p.size, p.lifetime), (0.1, 10));
|
||||
// No modifier aimed at it: untouched.
|
||||
let mut p = flame();
|
||||
modify(®istry, "minecraft:soul_fire_flame", None, &mut p);
|
||||
assert_eq!(
|
||||
(p.size, p.lifetime, p.velocity),
|
||||
(0.1, 10, [0.25, f64::from(0.1f32), -0.5])
|
||||
);
|
||||
}
|
||||
|
||||
/// The freepack's light rays come from an emitter with physics that rests beside a leaf and
|
||||
/// emits every tick of its life: resting is not being still, only a stopped fall is.
|
||||
#[test]
|
||||
fn a_resting_particle_with_physics_lives_out_its_life() {
|
||||
let blocks = blocks();
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/test/polytone/custom_particles/rest.json",
|
||||
serde_json::json!({"has_physics": true, "render_type": "invisible",
|
||||
"initializer": {"lifetime": "20"}}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/player.json",
|
||||
serde_json::json!({"emitters": [{"particle": "test:rest", "chance": "1"}]}),
|
||||
),
|
||||
]);
|
||||
let scene = scene(blocks);
|
||||
let level = ParticleLevel {
|
||||
world: &scene.world,
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
engine.polytone_entity(&level, "minecraft:player", [1.5, 70.0, 1.5]);
|
||||
let mut p = engine.pending.pop().unwrap();
|
||||
for _ in 0..10 {
|
||||
engine.tick_particle(&mut p, &level);
|
||||
assert!(!p.removed);
|
||||
}
|
||||
// Stopped by a collision, it goes.
|
||||
p.stopped_by_collision = true;
|
||||
engine.tick_particle(&mut p, &level);
|
||||
assert!(p.removed);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_custom_particle_replaces_the_vanilla_type_of_its_id() {
|
||||
let root = pack(&[(
|
||||
"assets/minecraft/polytone/custom_particles/flame.json",
|
||||
serde_json::json!({"has_physics": false, "initializer": {"lifetime": "33", "size": "0.75"}}),
|
||||
)]);
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks()));
|
||||
// The vanilla flame's sprite set (what the pack's particles/flame.json lists).
|
||||
engine.sets.insert(ParticleType::Flame, 1);
|
||||
engine.set_sprites.push(vec![20, 21]);
|
||||
engine.create(
|
||||
&ParticleOptions::simple(ParticleType::Flame),
|
||||
[1.0, 65.0, 1.0],
|
||||
[0.0; 3],
|
||||
);
|
||||
assert_eq!(engine.pending.len(), 1);
|
||||
let p = &engine.pending[0];
|
||||
assert!(
|
||||
p.controller.is_some(),
|
||||
"the pack's particle, not vanilla's flame"
|
||||
);
|
||||
assert_eq!((p.lifetime, p.size), (33, 0.75));
|
||||
assert!(matches!(p.sprite, Sprite::Index(20)));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_and_biome_tags_pick_where_an_emitter_runs() {
|
||||
let blocks = blocks();
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/test/polytone/custom_particles/spark.json",
|
||||
serde_json::json!({"has_physics": false}),
|
||||
),
|
||||
(
|
||||
"assets/test/polytone/block_modifiers/shiny.json",
|
||||
serde_json::json!({
|
||||
"targets": ["#test:shiny"],
|
||||
"particle_emitters": [{
|
||||
"particle": "test:spark", "chance": "1", "biomes": "#test:warm",
|
||||
"spawn_location": "lower_corner", "y": "1"
|
||||
}]
|
||||
}),
|
||||
),
|
||||
(
|
||||
"data/test/tags/block/shiny.json",
|
||||
serde_json::json!({"values": ["minecraft:torch"]}),
|
||||
),
|
||||
(
|
||||
"data/test/tags/worldgen/biome/warm.json",
|
||||
serde_json::json!({"values": ["#test:hot", "minecraft:plains"]}),
|
||||
),
|
||||
(
|
||||
"data/test/tags/worldgen/biome/hot.json",
|
||||
serde_json::json!({"values": ["minecraft:desert"]}),
|
||||
),
|
||||
]);
|
||||
let scene = scene(blocks);
|
||||
let level = ParticleLevel {
|
||||
world: &scene.world,
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
// The test chunk's biome is the table's first: a cold one first, then a warm one.
|
||||
let first = |name: &str| {
|
||||
Arc::new(crate::biome::Biomes::from_jsons(&[(
|
||||
name,
|
||||
serde_json::json!({}),
|
||||
)]))
|
||||
};
|
||||
let registry = engine.polytone.clone().unwrap();
|
||||
registry.set_biomes(&first("minecraft:snowy_plains"));
|
||||
engine.polytone_animate(&level, [1, 64, 1]);
|
||||
assert!(engine.pending.is_empty(), "outside #test:warm");
|
||||
// `#test:warm` holds the desert through the nested `#test:hot`.
|
||||
registry.set_biomes(&first("minecraft:desert"));
|
||||
engine.polytone_animate(&level, [1, 64, 1]);
|
||||
assert_eq!(engine.pending.len(), 1);
|
||||
assert_eq!(engine.pending[0].position, [1.0, 65.0, 1.0]);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filtered_modifier_changes_only_the_blocks_and_items_it_names() {
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/minecraft/polytone/particle_modifiers/block.json",
|
||||
serde_json::json!({"size": "3", "filter": {"block": ["stone", "minecraft:granite"]}}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/particle_modifiers/item.json",
|
||||
serde_json::json!({"life": "50", "filter": {"item": "minecraft:apple"}}),
|
||||
),
|
||||
]);
|
||||
let registry = Registry::read(&root, Default::default(), blocks());
|
||||
let run = |id: &str, source: Option<&str>| {
|
||||
let mut p = Particle::blank(ParticleType::Block, [0.0; 3]);
|
||||
p.size = 0.1;
|
||||
p.lifetime = 10;
|
||||
modify(®istry, id, source, &mut p);
|
||||
(p.size, p.lifetime)
|
||||
};
|
||||
assert_eq!(run("minecraft:block", Some("minecraft:stone")), (0.3, 10));
|
||||
assert_eq!(run("minecraft:block", Some("minecraft:granite")), (0.3, 10));
|
||||
assert_eq!(run("minecraft:block", Some("minecraft:dirt")), (0.1, 10));
|
||||
// A particle that carries no block (the filter can't pass).
|
||||
assert_eq!(run("minecraft:block", None), (0.1, 10));
|
||||
assert_eq!(run("minecraft:item", Some("minecraft:apple")), (0.1, 50));
|
||||
assert_eq!(run("minecraft:item", Some("minecraft:bread")), (0.1, 10));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ pub(super) enum Orientation {
|
||||
Flat,
|
||||
/// `movement_aligned`: stands along its velocity, facing the camera around it.
|
||||
Movement,
|
||||
/// `none`: fixed in the world's axes (its +Z facing south).
|
||||
Fixed,
|
||||
}
|
||||
|
||||
impl Orientation {
|
||||
@@ -31,6 +33,7 @@ impl Orientation {
|
||||
"look_at_y" => Self::AroundY,
|
||||
"look_up" => Self::Flat,
|
||||
"movement_aligned" => Self::Movement,
|
||||
"none" => Self::Fixed,
|
||||
_ => Self::Camera,
|
||||
}
|
||||
}
|
||||
@@ -50,6 +53,7 @@ pub(super) fn orient(
|
||||
Orientation::Camera => rolled(camera),
|
||||
Orientation::AroundY => rolled(Quat::from_rotation_y((-center.x).atan2(-center.z))),
|
||||
Orientation::Flat => rolled(Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2)),
|
||||
Orientation::Fixed => rolled(Quat::IDENTITY),
|
||||
Orientation::Movement => {
|
||||
let up = velocity.normalize_or_zero();
|
||||
let right = up.cross((-center).normalize_or_zero()).normalize_or_zero();
|
||||
@@ -61,6 +65,24 @@ pub(super) fn orient(
|
||||
}
|
||||
}
|
||||
|
||||
/// The alpha a particle keeps at `life` (0 to 1 of its lifetime) with `fade_in` and `fade_out`
|
||||
/// fractions of it spent fading: each ramp eases in and out (smoothstep), as Polytone's are
|
||||
/// measured to (a linear ramp leaves young rain drops twice as bright).
|
||||
fn fade(life: f32, fade_in: f32, fade_out: f32) -> f32 {
|
||||
let ease = |x: f32| {
|
||||
let x = x.clamp(0.0, 1.0);
|
||||
x * x * (3.0 - 2.0 * x)
|
||||
};
|
||||
let mut alpha = 1.0;
|
||||
if fade_in > 0.0 {
|
||||
alpha *= ease(life / fade_in);
|
||||
}
|
||||
if fade_out > 0.0 {
|
||||
alpha *= ease((1.0 - life) / fade_out);
|
||||
}
|
||||
alpha
|
||||
}
|
||||
|
||||
/// One GPU particle spawn.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Record {
|
||||
@@ -239,12 +261,7 @@ impl Store {
|
||||
// Fading in and out, and the colour and size toward their ends.
|
||||
let [fade_in, fade_out] = gpu.fade.map(|f| f as f32);
|
||||
let mut alpha = r.rgba[3];
|
||||
if fade_in > 0.0 {
|
||||
alpha *= (life / fade_in).min(1.0);
|
||||
}
|
||||
if fade_out > 0.0 {
|
||||
alpha *= ((1.0 - life) / fade_out).min(1.0);
|
||||
}
|
||||
alpha *= fade(life, fade_in, fade_out);
|
||||
let mut rgb = [r.rgba[0], r.rgba[1], r.rgba[2]];
|
||||
if let Some(end) = gpu.color_end {
|
||||
let channel = |shift: u32| ((end >> shift) & 0xff) as f32 / 255.0;
|
||||
@@ -368,6 +385,20 @@ mod tests {
|
||||
assert!((v[1] + 0.1 / -f64::ln(friction)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// Measured in Java on a line of particles whose age grows with x: a quarter of the way
|
||||
/// through its fade-in a particle has about a sixth of its alpha, not a quarter; half at half.
|
||||
#[test]
|
||||
fn fades_ease_in_and_out() {
|
||||
let close = |a: f32, b: f32| (a - b).abs() < 1e-4;
|
||||
assert!(close(fade(0.0, 0.4, 0.2), 0.0));
|
||||
assert!(close(fade(0.1, 0.4, 0.2), 0.15625));
|
||||
assert!(close(fade(0.2, 0.4, 0.2), 0.5));
|
||||
assert!(close(fade(0.5, 0.4, 0.2), 1.0));
|
||||
assert!(close(fade(0.9, 0.4, 0.2), 0.5));
|
||||
assert!(close(fade(1.0, 0.4, 0.2), 0.0));
|
||||
assert!(close(fade(0.3, 0.0, 0.0), 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientations_turn_the_quad_normal() {
|
||||
let normal = |q: Quat| q * FVec3::Z;
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
//! Polytone model particles (`"model"` on a custom particle type, #11486): a particle drawn as
|
||||
//! the faces of a block model (`assets/<ns>/models/<path>.json`) instead of one sprite quad.
|
||||
//! Visual Effects+ draws its block glows this way: a glow texture on a plane in front of the
|
||||
//! block, turned to the camera and blended additively.
|
||||
//!
|
||||
//! The model is read the vanilla way (elements, faces, `#texture` references, parents, element
|
||||
//! rotations) into quads around the model's centre, in blocks; the particle's size scales them
|
||||
//! and its rotation mode turns them as it turns a sprite quad (`+Z` facing the camera for
|
||||
//! `look_at_xyz`, world axes for `none`).
|
||||
use glam::{Quat, Vec3};
|
||||
use serde_json::{Map, Value as Json};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// One face of a model particle: corners in blocks around the model's centre, in drawing order,
|
||||
/// with their texture coordinates inside the sprite (0 to 1).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct ModelQuad {
|
||||
pub(crate) corners: [Vec3; 4],
|
||||
pub(crate) uv: [[f32; 2]; 4],
|
||||
/// The sprite's atlas name (`davidparticles:block/glow_test_1`, `block/stone`).
|
||||
pub(crate) sprite: String,
|
||||
}
|
||||
|
||||
/// A model particle's faces.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct ParticleModel {
|
||||
pub(crate) quads: Vec<ModelQuad>,
|
||||
}
|
||||
|
||||
/// `ns:path` with `minecraft` when the namespace is left out.
|
||||
fn split_id(id: &str) -> (&str, &str) {
|
||||
id.split_once(':').unwrap_or(("minecraft", id))
|
||||
}
|
||||
|
||||
/// The atlas name of a texture id: `minecraft:block/stone` is `block/stone`.
|
||||
fn sprite_name(texture: &str) -> String {
|
||||
match split_id(texture) {
|
||||
("minecraft", path) => path.to_owned(),
|
||||
(namespace, path) => format!("{namespace}:{path}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A model JSON and its parents' `textures` and `elements`, the child's winning.
|
||||
fn read_model(pack_root: &Path, id: &str, depth: u32) -> Option<(Map<String, Json>, Vec<Json>)> {
|
||||
if depth > 8 {
|
||||
return None;
|
||||
}
|
||||
let (namespace, path) = split_id(id);
|
||||
let file = pack_root
|
||||
.join("assets")
|
||||
.join(namespace)
|
||||
.join("models")
|
||||
.join(format!("{path}.json"));
|
||||
let json: Json = horizon_world::json_file::read(&file).ok()?;
|
||||
let (mut textures, mut elements) = match json.get("parent").and_then(Json::as_str) {
|
||||
Some(parent) => read_model(pack_root, parent, depth + 1).unwrap_or_default(),
|
||||
None => Default::default(),
|
||||
};
|
||||
if let Some(own) = json.get("textures").and_then(Json::as_object) {
|
||||
textures.extend(own.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
}
|
||||
if let Some(own) = json.get("elements").and_then(Json::as_array) {
|
||||
elements.clone_from(own);
|
||||
}
|
||||
Some((textures, elements))
|
||||
}
|
||||
|
||||
/// A `#name` texture reference followed through the model's textures to an id.
|
||||
fn resolve_texture(textures: &Map<String, Json>, reference: &str) -> Option<String> {
|
||||
let mut name = reference;
|
||||
for _ in 0..8 {
|
||||
let Some(key) = name.strip_prefix('#') else {
|
||||
return Some(name.to_owned());
|
||||
};
|
||||
name = textures.get(key)?.as_str()?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn vec3(v: &Json) -> Option<Vec3> {
|
||||
let a = v.as_array()?;
|
||||
Some(Vec3::new(
|
||||
a.first()?.as_f64()? as f32,
|
||||
a.get(1)?.as_f64()? as f32,
|
||||
a.get(2)?.as_f64()? as f32,
|
||||
))
|
||||
}
|
||||
|
||||
/// A face's corners (in model pixels) in vanilla's order: top left, bottom left, bottom right,
|
||||
/// top right, as seen from outside the face.
|
||||
fn face_corners(face: &str, f: Vec3, t: Vec3) -> Option<[Vec3; 4]> {
|
||||
let v = Vec3::new;
|
||||
Some(match face {
|
||||
"south" => [
|
||||
v(f.x, t.y, t.z),
|
||||
v(f.x, f.y, t.z),
|
||||
v(t.x, f.y, t.z),
|
||||
v(t.x, t.y, t.z),
|
||||
],
|
||||
"north" => [
|
||||
v(t.x, t.y, f.z),
|
||||
v(t.x, f.y, f.z),
|
||||
v(f.x, f.y, f.z),
|
||||
v(f.x, t.y, f.z),
|
||||
],
|
||||
"east" => [
|
||||
v(t.x, t.y, t.z),
|
||||
v(t.x, f.y, t.z),
|
||||
v(t.x, f.y, f.z),
|
||||
v(t.x, t.y, f.z),
|
||||
],
|
||||
"west" => [
|
||||
v(f.x, t.y, f.z),
|
||||
v(f.x, f.y, f.z),
|
||||
v(f.x, f.y, t.z),
|
||||
v(f.x, t.y, t.z),
|
||||
],
|
||||
"up" => [
|
||||
v(f.x, t.y, f.z),
|
||||
v(f.x, t.y, t.z),
|
||||
v(t.x, t.y, t.z),
|
||||
v(t.x, t.y, f.z),
|
||||
],
|
||||
"down" => [
|
||||
v(f.x, f.y, t.z),
|
||||
v(f.x, f.y, f.z),
|
||||
v(t.x, f.y, f.z),
|
||||
v(t.x, f.y, t.z),
|
||||
],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The `uv` a face has when its JSON gives none: the element's extent on that face.
|
||||
fn default_uv(face: &str, f: Vec3, t: Vec3) -> [f32; 4] {
|
||||
match face {
|
||||
"north" => [16.0 - t.x, 16.0 - t.y, 16.0 - f.x, 16.0 - f.y],
|
||||
"south" => [f.x, 16.0 - t.y, t.x, 16.0 - f.y],
|
||||
"east" => [16.0 - t.z, 16.0 - t.y, 16.0 - f.z, 16.0 - f.y],
|
||||
"west" => [f.z, 16.0 - t.y, t.z, 16.0 - f.y],
|
||||
"up" => [f.x, f.z, t.x, t.z],
|
||||
_ => [f.x, 16.0 - t.z, t.x, 16.0 - f.z],
|
||||
}
|
||||
}
|
||||
|
||||
/// An element's `rotation` (`angle` degrees around `axis` through `origin`, in pixels).
|
||||
fn element_rotation(element: &Json) -> Option<(Quat, Vec3)> {
|
||||
let r = element.get("rotation")?;
|
||||
let angle = (r.get("angle")?.as_f64()? as f32).to_radians();
|
||||
if angle == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let axis = match r.get("axis")?.as_str()? {
|
||||
"x" => Vec3::X,
|
||||
"y" => Vec3::Y,
|
||||
_ => Vec3::Z,
|
||||
};
|
||||
let origin = r.get("origin").and_then(vec3).unwrap_or(Vec3::splat(8.0));
|
||||
Some((Quat::from_axis_angle(axis, angle), origin))
|
||||
}
|
||||
|
||||
impl ParticleModel {
|
||||
/// The model `id` (`davidparticles:block/fullblock_glow`) under a pack overlay root; `None`
|
||||
/// when it's missing or has no face to draw.
|
||||
pub(crate) fn read(pack_root: &Path, id: &str) -> Option<Self> {
|
||||
let (textures, elements) = read_model(pack_root, id, 0)?;
|
||||
let mut quads = Vec::new();
|
||||
for element in &elements {
|
||||
let (Some(f), Some(t)) = (
|
||||
element.get("from").and_then(vec3),
|
||||
element.get("to").and_then(vec3),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let rotation = element_rotation(element);
|
||||
let faces = element.get("faces").and_then(Json::as_object);
|
||||
for (face, spec) in faces.into_iter().flatten() {
|
||||
let Some(corners) = face_corners(face, f, t) else {
|
||||
continue;
|
||||
};
|
||||
// A face with no area (a plane's edges) draws nothing.
|
||||
let span = (corners[0] - corners[2]).abs();
|
||||
if [span.x, span.y, span.z]
|
||||
.iter()
|
||||
.filter(|&&s| s > 1.0e-4)
|
||||
.count()
|
||||
< 2
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(sprite) = spec
|
||||
.get("texture")
|
||||
.and_then(Json::as_str)
|
||||
.and_then(|r| resolve_texture(&textures, r))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let uv = match spec.get("uv").and_then(Json::as_array) {
|
||||
Some(a) if a.len() == 4 => {
|
||||
let n = |i: usize| a[i].as_f64().unwrap_or(0.0) as f32;
|
||||
[n(0), n(1), n(2), n(3)]
|
||||
}
|
||||
_ => default_uv(face, f, t),
|
||||
};
|
||||
if uv[0] == uv[2] || uv[1] == uv[3] {
|
||||
continue;
|
||||
}
|
||||
let corners = corners.map(|c| {
|
||||
let c = match rotation {
|
||||
Some((q, origin)) => q * (c - origin) + origin,
|
||||
None => c,
|
||||
};
|
||||
c / 16.0 - Vec3::splat(0.5)
|
||||
});
|
||||
let [u0, v0, u1, v1] = uv.map(|x| x / 16.0);
|
||||
quads.push(ModelQuad {
|
||||
corners,
|
||||
uv: [[u0, v0], [u0, v1], [u1, v1], [u1, v0]],
|
||||
sprite: sprite_name(&sprite),
|
||||
});
|
||||
}
|
||||
}
|
||||
(!quads.is_empty()).then_some(Self { quads })
|
||||
}
|
||||
}
|
||||
|
||||
/// The packs' model particles: each custom particle type with a `model`, its faces.
|
||||
pub(crate) fn read_models<'a>(
|
||||
pack_root: &Path,
|
||||
types: impl Iterator<Item = (&'a String, &'a Json)>,
|
||||
) -> HashMap<String, std::sync::Arc<ParticleModel>> {
|
||||
types
|
||||
.filter_map(|(id, model)| {
|
||||
let model_id = model.as_str()?;
|
||||
match ParticleModel::read(pack_root, model_id) {
|
||||
Some(m) => Some((id.clone(), std::sync::Arc::new(m))),
|
||||
None => {
|
||||
log::warn!("custom particle {id}: model {model_id} not found or empty");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(root: &Path, path: &str, json: serde_json::Value) {
|
||||
let file = root.join(path);
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
std::fs::write(file, json.to_string()).unwrap();
|
||||
}
|
||||
|
||||
fn root() -> std::path::PathBuf {
|
||||
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!("horizon-particle-model-{}-{n}", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glow_plane_keeps_its_one_face_in_front_of_the_centre() {
|
||||
let root = root();
|
||||
// Visual Effects+'s glow, as Blockbench writes it: a plane at z 18.25 whose edges have
|
||||
// no area, and faces without a texture.
|
||||
write(
|
||||
&root,
|
||||
"assets/fx/models/block/glow.json",
|
||||
serde_json::json!({
|
||||
"textures": {"2": "fx:block/glow", "particle": "fx:block/glow"},
|
||||
"elements": [{
|
||||
"from": [1.25, 1.25, 18.25], "to": [14.75, 14.75, 18.25],
|
||||
"faces": {
|
||||
"north": {"uv": [0, 0, 0, 0], "texture": "#2"},
|
||||
"east": {"uv": [0, 0, 0, 4], "texture": "#missing"},
|
||||
"south": {"uv": [0, 0, 16, 16], "texture": "#2"}
|
||||
}
|
||||
}]
|
||||
}),
|
||||
);
|
||||
let model = ParticleModel::read(&root, "fx:block/glow").unwrap();
|
||||
assert_eq!(model.quads.len(), 1);
|
||||
let quad = &model.quads[0];
|
||||
assert_eq!(quad.sprite, "fx:block/glow");
|
||||
// 18.25 px is 0.640625 blocks in front of the centre; the plane spans ±0.421875.
|
||||
let z = 18.25 / 16.0 - 0.5;
|
||||
let e = 6.75 / 16.0;
|
||||
assert_eq!(
|
||||
quad.corners,
|
||||
[
|
||||
Vec3::new(-e, e, z),
|
||||
Vec3::new(-e, -e, z),
|
||||
Vec3::new(e, -e, z),
|
||||
Vec3::new(e, e, z)
|
||||
]
|
||||
);
|
||||
assert_eq!(quad.uv, [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parents_textures_and_default_uvs_resolve_as_vanilla() {
|
||||
let root = root();
|
||||
write(
|
||||
&root,
|
||||
"assets/minecraft/models/block/base.json",
|
||||
serde_json::json!({
|
||||
"textures": {"all": "block/stone"},
|
||||
"elements": [{"from": [0, 0, 0], "to": [16, 8, 16], "faces": {"up": {"texture": "#all"}}}]
|
||||
}),
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
"assets/fx/models/slab.json",
|
||||
serde_json::json!({
|
||||
"parent": "block/base", "textures": {"all": "fx:block/sand"}
|
||||
}),
|
||||
);
|
||||
let model = ParticleModel::read(&root, "fx:slab").unwrap();
|
||||
assert_eq!(model.quads.len(), 1);
|
||||
// The child's texture wins; `up` at y 8 px, seen from above with north on top.
|
||||
assert_eq!(model.quads[0].sprite, "fx:block/sand");
|
||||
assert_eq!(model.quads[0].corners[0], Vec3::new(-0.5, 0.0, -0.5));
|
||||
assert_eq!(model.quads[0].corners[2], Vec3::new(0.5, 0.0, 0.5));
|
||||
assert_eq!(model.quads[0].uv[2], [1.0, 1.0]);
|
||||
assert!(ParticleModel::read(&root, "fx:missing").is_none());
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! The particle stress scene's load on `ParticleEngine`, outside the game: `cargo test --profile fast
|
||||
//! -p horizon-client-render --lib particle_tick_cost -- --ignored --nocapture` (prepared resources;
|
||||
//! `PARTICLE_BENCH_SECS` sets the run, default 5). It spawns what tools/stress_scenes.py's `particles`
|
||||
//! scene makes each tick (flames, cosy smoke, end rods, TNT debris) over its stone floor and times the
|
||||
//! spawns and the engine tick apart, once the engine holds as many particles as it will.
|
||||
use super::*;
|
||||
use crate::{blocks::BlockFacts, chunk::Chunk, collisions, world::World};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const FLOOR_Y: i32 = 289;
|
||||
|
||||
/// A cheap, repeatable stream of numbers in [0, 1).
|
||||
struct Lcg(u64);
|
||||
impl Lcg {
|
||||
fn next(&mut self) -> f64 {
|
||||
self.0 = self
|
||||
.0
|
||||
.wrapping_mul(6_364_136_223_846_793_005)
|
||||
.wrapping_add(1_442_695_040_888_963_407);
|
||||
((self.0 >> 11) as f64) / ((1u64 << 53) as f64)
|
||||
}
|
||||
fn signed(&mut self) -> f64 {
|
||||
self.next() * 2.0 - 1.0
|
||||
}
|
||||
}
|
||||
|
||||
type Emitter = (ParticleType, usize, [f64; 3], [f64; 3], f64);
|
||||
|
||||
fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], rockets: usize) {
|
||||
for _ in 0..rockets {
|
||||
// `summon firework_rocket` with a large ball of three colours and a trail, bursting 18 ticks up.
|
||||
let explosion = FireworkExplosion {
|
||||
shape: FireworkShape::LargeBall,
|
||||
colors: vec![0xFF0000, 0x00FF00, 0x0000FF],
|
||||
fade: Vec::new(),
|
||||
trail: true,
|
||||
twinkle: false,
|
||||
};
|
||||
let at = [-5.5 + rng.next() * 12.0, f64::from(FLOOR_Y + 20), 20.5];
|
||||
engine.fireworks(at, [0.0, 0.1, 0.0], vec![explosion], engine.camera);
|
||||
}
|
||||
for &(ty, count, center, delta, speed) in types {
|
||||
let options = ParticleOptions::simple(ty);
|
||||
for _ in 0..count {
|
||||
let p = [
|
||||
center[0] + rng.signed() * delta[0],
|
||||
center[1] + rng.signed() * delta[1],
|
||||
center[2] + rng.signed() * delta[2],
|
||||
];
|
||||
let v = [
|
||||
rng.signed() * speed,
|
||||
rng.signed() * speed,
|
||||
rng.signed() * speed,
|
||||
];
|
||||
engine.add(&options, p, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "a measurement, not a check"]
|
||||
fn particle_tick_cost() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("cached-collision.nbt").exists() {
|
||||
eprintln!("no prepared resources: skipped");
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let collision = collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = World::new(facts.air, 0);
|
||||
let floor = assets
|
||||
.registry
|
||||
.default_state("minecraft:smooth_stone")
|
||||
.unwrap();
|
||||
for cz in -1..=3 {
|
||||
for cx in -2..=2 {
|
||||
world
|
||||
.chunks
|
||||
.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
|
||||
}
|
||||
}
|
||||
for z in 2..=40 {
|
||||
for x in -24..=24 {
|
||||
world.set(x, FLOOR_Y, z, floor).unwrap();
|
||||
}
|
||||
}
|
||||
let level = ParticleLevel {
|
||||
world: &world,
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
};
|
||||
let ty = |name: &str| ParticleType::from_name(name).unwrap();
|
||||
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
|
||||
let tnt = [0.5, f64::from(FLOOR_Y + 24), 26.5];
|
||||
// The scene's emitters, per tick, and TNT's debris (poof and smoke).
|
||||
let flame: Emitter = (ty("flame"), 320, [fx, fy, fz], [8.0, 4.0, 8.0], 0.01);
|
||||
let cosy: Emitter = (
|
||||
ty("campfire_cosy_smoke"),
|
||||
8,
|
||||
[fx, fy, fz],
|
||||
[8.0, 4.0, 8.0],
|
||||
0.0,
|
||||
);
|
||||
let rod: Emitter = (ty("end_rod"), 80, [fx, fy, fz], [8.0, 4.0, 8.0], 0.05);
|
||||
let poof: Emitter = (ty("poof"), 258, tnt, [3.0, 3.0, 3.0], 0.4);
|
||||
let smoke: Emitter = (ty("smoke"), 238, tnt, [3.0, 3.0, 3.0], 0.4);
|
||||
let cases: [(&str, Vec<Emitter>, usize); 7] = [
|
||||
("flame 320/tick", vec![flame], 0),
|
||||
("cosy smoke 8/tick", vec![cosy], 0),
|
||||
("end rod 80/tick", vec![rod], 0),
|
||||
("poof 258/tick", vec![poof], 0),
|
||||
("smoke 238/tick", vec![smoke], 0),
|
||||
("3 rockets/tick", vec![], 3),
|
||||
("the whole scene", vec![flame, cosy, rod, poof, smoke], 3),
|
||||
];
|
||||
let secs: f64 = std::env::var("PARTICLE_BENCH_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(2.0);
|
||||
for (name, types, rockets) in cases {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.camera = [0.5, 298.0, 0.5];
|
||||
let mut rng = Lcg(1234);
|
||||
for _ in 0..600 {
|
||||
spawn_tick(&mut engine, &mut rng, &types, rockets);
|
||||
engine.tick(&level);
|
||||
}
|
||||
let (mut spawn, mut tick, mut ticks, mut alive) =
|
||||
(Duration::ZERO, Duration::ZERO, 0u64, 0usize);
|
||||
let end = Instant::now() + Duration::from_secs_f64(secs);
|
||||
while Instant::now() < end {
|
||||
let t = Instant::now();
|
||||
spawn_tick(&mut engine, &mut rng, &types, rockets);
|
||||
spawn += t.elapsed();
|
||||
let t = Instant::now();
|
||||
engine.tick(&level);
|
||||
tick += t.elapsed();
|
||||
alive += engine.len();
|
||||
ticks += 1;
|
||||
}
|
||||
let per = |d: Duration| d.as_secs_f64() * 1000.0 / ticks as f64;
|
||||
let alive = alive / ticks as usize;
|
||||
eprintln!(
|
||||
"{name:20} alive {alive:6} | per tick: spawn {:6.3} ms, engine tick {:6.3} ms | {:5.0} ns per particle",
|
||||
per(spawn),
|
||||
per(tick),
|
||||
per(tick) * 1.0e6 / alive.max(1) as f64
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Resource packs' Polytone post chains (#11486, wiki "Shaders"): vanilla `post_effect` chains a
|
||||
//! pack runs over the world while their `activation_condition` holds, with uniform blocks from
|
||||
//! expressions (`expression_uniforms`), the `PolyGlobals` block, and targets of their own
|
||||
//! (`post_targets`) kept between frames. `post_chain` runs them; this reads what they add.
|
||||
use crate::environment::Frame;
|
||||
use crate::post_chain::{Extras, InternalTarget};
|
||||
use horizon_polytone::expr::Program;
|
||||
use serde_json::Value as Json;
|
||||
use std::path::Path;
|
||||
|
||||
/// A `polytone/post_chains/<path>.json`.
|
||||
struct Chain {
|
||||
/// The `post_effect` chain it runs.
|
||||
effect: String,
|
||||
activation: Option<Program>,
|
||||
/// Each expression uniform: its block's name and expression.
|
||||
uniforms: Vec<(String, Program)>,
|
||||
}
|
||||
|
||||
/// The packs' post chains and targets.
|
||||
pub struct PolytonePost {
|
||||
chains: Vec<Chain>,
|
||||
targets: std::collections::BTreeMap<String, InternalTarget>,
|
||||
configs: horizon_polytone::pack::Configs,
|
||||
}
|
||||
|
||||
/// What `PolyGlobals` holds (wiki "Extra Shader Uniforms").
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct PolyGlobals {
|
||||
/// The world's projection (reverse-Z, as 26.3's and Horizon's are), and its model-view (the
|
||||
/// camera's turn).
|
||||
pub projection: glam::Mat4,
|
||||
pub model_view: glam::Mat4,
|
||||
/// The sun's angle in radians, 0 at the horizon.
|
||||
pub sun_angle: f32,
|
||||
/// The time of day in ticks (0 to 24000), and the game ticks this frame advanced.
|
||||
pub day_time: f32,
|
||||
pub delta_time: f32,
|
||||
/// The player's feet: `PolyPlayerBlockPos`, and `PolyPlayerOffset` as vanilla's
|
||||
/// `CameraOffset` is (the block position less the position, so `polyPlayerPos()` is the
|
||||
/// block position less the offset).
|
||||
pub player: [f64; 3],
|
||||
}
|
||||
|
||||
impl PolyGlobals {
|
||||
/// The std140 block: `mat4, mat4, float, float, float, ivec3, vec3`.
|
||||
pub fn bytes(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(176);
|
||||
for m in [self.projection, self.model_view] {
|
||||
out.extend(m.to_cols_array().iter().flat_map(|v| v.to_le_bytes()));
|
||||
}
|
||||
for v in [self.sun_angle, self.day_time, self.delta_time, 0.0] {
|
||||
out.extend(v.to_le_bytes());
|
||||
}
|
||||
let block = self.player.map(f64::floor);
|
||||
for v in block {
|
||||
out.extend((v as i32).to_le_bytes());
|
||||
}
|
||||
out.extend(0i32.to_le_bytes());
|
||||
for i in 0..3 {
|
||||
out.extend(((block[i] - self.player[i]) as f32).to_le_bytes());
|
||||
}
|
||||
out.extend(0f32.to_le_bytes());
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `.json` under `assets/<namespace>/polytone/<kind>`, by its implicit id.
|
||||
fn files(root: &Path, kind: &str) -> Vec<(String, Json)> {
|
||||
let mut out = Vec::new();
|
||||
let namespaces = std::fs::read_dir(root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten();
|
||||
for namespace in namespaces {
|
||||
let mut stack = vec![namespace.path().join("polytone").join(kind)];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let Some(id) = horizon_polytone::implicit_id(&relative, kind) else {
|
||||
continue;
|
||||
};
|
||||
match horizon_world::json_file::read::<Json>(&path) {
|
||||
Ok(json) => out.push((id, json)),
|
||||
Err(e) => log::warn!("{relative}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn compile(where_: &str, src: &Json) -> Option<Program> {
|
||||
let text = match src {
|
||||
Json::String(s) => s.clone(),
|
||||
Json::Number(n) => n.to_string(),
|
||||
Json::Bool(b) => b.to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
match Program::compile(&text) {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
log::warn!("{where_}: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PolytonePost {
|
||||
/// The post chains and targets of the packs merged at `root`; `None` without chains.
|
||||
pub fn read(root: &Path, configs: horizon_polytone::pack::Configs) -> Option<Self> {
|
||||
let chains: Vec<Chain> = files(root, "post_chains")
|
||||
.into_iter()
|
||||
.filter_map(|(id, json)| {
|
||||
let effect = json["post_chain"].as_str()?;
|
||||
let effect = if effect.contains(':') {
|
||||
effect.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{effect}")
|
||||
};
|
||||
let activation = json
|
||||
.get("activation_condition")
|
||||
.and_then(|c| compile(&id, c));
|
||||
let uniforms = json["expression_uniforms"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|(name, src)| Some((name.clone(), compile(&id, src)?)))
|
||||
.collect();
|
||||
Some(Chain {
|
||||
effect,
|
||||
activation,
|
||||
uniforms,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if chains.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let targets = files(root, "post_targets")
|
||||
.into_iter()
|
||||
.map(|(id, json)| {
|
||||
let size = |key: &str| json[key].as_u64().filter(|n| *n > 0).map(|n| n as u32);
|
||||
let target = InternalTarget {
|
||||
width: size("width"),
|
||||
height: size("height"),
|
||||
persistent: true,
|
||||
clear: [0.0; 4],
|
||||
};
|
||||
(id, target)
|
||||
})
|
||||
.collect();
|
||||
Some(Self {
|
||||
chains,
|
||||
targets,
|
||||
configs,
|
||||
})
|
||||
}
|
||||
|
||||
/// The chains to run this frame (those whose condition holds), and what they add.
|
||||
pub fn frame(&self, frame: &Frame, globals: &PolyGlobals) -> (Vec<String>, Extras) {
|
||||
let mut effects = Vec::new();
|
||||
let mut extras = Extras {
|
||||
targets: self.targets.clone(),
|
||||
blocks: Default::default(),
|
||||
};
|
||||
extras
|
||||
.blocks
|
||||
.insert("PolyGlobals".to_owned(), globals.bytes());
|
||||
for chain in &self.chains {
|
||||
let on = chain.activation.as_ref().is_none_or(|c| {
|
||||
crate::environment::eval_frame(c, frame, &self.configs).is_some_and(|v| v.truthy())
|
||||
});
|
||||
if !on {
|
||||
continue;
|
||||
}
|
||||
for (name, program) in &chain.uniforms {
|
||||
let value = crate::environment::eval_frame(program, frame, &self.configs)
|
||||
.map_or(0.0, |v| v.num()) as f32;
|
||||
let mut block = value.to_le_bytes().to_vec();
|
||||
block.resize(16, 0);
|
||||
extras.blocks.insert(name.clone(), block);
|
||||
}
|
||||
effects.push(chain.effect.clone());
|
||||
}
|
||||
(effects, extras)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn poly_globals_follow_std140() {
|
||||
let g = PolyGlobals {
|
||||
projection: glam::Mat4::from_diagonal(glam::Vec4::splat(2.0)),
|
||||
sun_angle: 1.5,
|
||||
day_time: 6000.0,
|
||||
delta_time: 0.5,
|
||||
player: [10.25, -3.5, 7.0],
|
||||
..Default::default()
|
||||
};
|
||||
let b = g.bytes();
|
||||
let f = |at: usize| f32::from_le_bytes(b[at..at + 4].try_into().unwrap());
|
||||
let i = |at: usize| i32::from_le_bytes(b[at..at + 4].try_into().unwrap());
|
||||
assert_eq!(b.len(), 176);
|
||||
assert_eq!(f(0), 2.0);
|
||||
assert_eq!((f(128), f(132), f(136)), (1.5, 6000.0, 0.5));
|
||||
// ivec3 at 144, vec3 at 160 (std140 aligns both to 16). The offset is the block position
|
||||
// less the position, as vanilla's CameraOffset: the block position less it is the position.
|
||||
assert_eq!((i(144), i(148), i(152)), (10, -4, 7));
|
||||
assert_eq!((f(160), f(164), f(168)), (-0.25, -0.5, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chains_run_while_their_condition_holds_with_their_uniforms() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-polytone-post-{}", std::process::id()));
|
||||
let write = |path: &str, text: &str| {
|
||||
let path = root.join(path);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, text).unwrap();
|
||||
};
|
||||
write(
|
||||
"assets/p/polytone/post_chains/glare.json",
|
||||
r#"{"activation_condition": "g.rain() < 0.5", "post_chain": "p:glare",
|
||||
"expression_uniforms": {"Weather": "1 - g.rain()"}}"#,
|
||||
);
|
||||
write(
|
||||
"assets/p/polytone/post_targets/history.json",
|
||||
r#"{"width": 1, "height": 1}"#,
|
||||
);
|
||||
let post = PolytonePost::read(&root, Default::default()).unwrap();
|
||||
let mut frame = Frame {
|
||||
rain: 0.25,
|
||||
..Frame::default()
|
||||
};
|
||||
let (effects, extras) = post.frame(&frame, &PolyGlobals::default());
|
||||
assert_eq!(effects, ["p:glare"]);
|
||||
// `g.rain` is the mean of rain (0.25) and thunder (0): 0.125.
|
||||
assert_eq!(
|
||||
f32::from_le_bytes(extras.blocks["Weather"][..4].try_into().unwrap()),
|
||||
0.875
|
||||
);
|
||||
assert!(extras.targets["p:history"].persistent);
|
||||
frame.rain = 1.0;
|
||||
assert!(post.frame(&frame, &PolyGlobals::default()).0.is_empty());
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -385,6 +385,8 @@ enum Block {
|
||||
Globals,
|
||||
/// One of the pass's `uniforms` groups.
|
||||
Custom(wgpu::Buffer),
|
||||
/// A block the request sets every frame (`Extras::blocks`), by name.
|
||||
Dynamic(String, wgpu::Buffer),
|
||||
}
|
||||
|
||||
/// A pass running the resources' own GLSL, translated by `post_glsl`.
|
||||
@@ -474,6 +476,17 @@ pub struct Request<'a> {
|
||||
pub menu_blur_radius: f32,
|
||||
/// What the `Globals` block holds besides the screen size and the blur radius.
|
||||
pub globals: Globals,
|
||||
/// What resource packs' Polytone post chains add, when a pack has some.
|
||||
pub extras: Option<&'a Extras>,
|
||||
}
|
||||
|
||||
/// What resource packs' Polytone post chains add to every chain (#11486, wiki "Shaders"): their
|
||||
/// declared targets (`post_targets`), kept between frames, and uniform blocks set every frame.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Extras {
|
||||
pub targets: BTreeMap<String, InternalTarget>,
|
||||
/// Each block's std140 bytes, by its name (`PolyGlobals`, an expression uniform's).
|
||||
pub blocks: BTreeMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
/// `GlobalSettingsUniform.update`'s other values.
|
||||
@@ -516,6 +529,7 @@ impl<'a> Request<'a> {
|
||||
root: Path::new(""),
|
||||
menu_blur_radius: 0.0,
|
||||
globals: Globals::default(),
|
||||
extras: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -544,8 +558,31 @@ pub struct PostEffects {
|
||||
/// root they load from; `pending` until they arrive.
|
||||
preloading: Option<(PathBuf, mpsc::Receiver<Preloaded>)>,
|
||||
pending: HashSet<String>,
|
||||
/// This frame's `Extras::blocks`.
|
||||
extra_blocks: BTreeMap<String, Vec<u8>>,
|
||||
/// The frame's depth with GL's rows, for the passes that read `minecraft:main`'s: the
|
||||
/// pipeline, and the texture at the frame's size.
|
||||
gl_depth: Option<(wgpu::BindGroupLayout, wgpu::RenderPipeline)>,
|
||||
gl_depth_target: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
}
|
||||
|
||||
/// The frame's depth with its rows flipped as the chains' colour is (row 0 at the bottom). Its
|
||||
/// values stay: 26.3 keeps reverse-Z depth as Horizon does (the sky is 0), and packs' post
|
||||
/// shaders read it so.
|
||||
const GL_DEPTH_WGSL: &str = r#"
|
||||
@group(0) @binding(0) var depth: texture_depth_2d;
|
||||
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
@fragment fn fs(@builtin(position) at: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let size = textureDimensions(depth);
|
||||
let x = min(u32(at.x), size.x - 1u);
|
||||
let y = size.y - 1u - min(u32(at.y), size.y - 1u);
|
||||
return vec4<f32>(textureLoad(depth, vec2<u32>(x, y), 0), 0.0, 0.0, 1.0);
|
||||
}
|
||||
"#;
|
||||
|
||||
/// A chain [`PostEffects::preload`] loaded, by id.
|
||||
type Preloaded = (String, Result<Option<Chain>, String>);
|
||||
|
||||
@@ -628,6 +665,9 @@ impl PostEffects {
|
||||
failed: HashSet::new(),
|
||||
preloading: None,
|
||||
pending: HashSet::new(),
|
||||
extra_blocks: BTreeMap::new(),
|
||||
gl_depth: None,
|
||||
gl_depth_target: None,
|
||||
root: PathBuf::new(),
|
||||
work: None,
|
||||
staging: None,
|
||||
@@ -685,7 +725,7 @@ impl PostEffects {
|
||||
let ids = ids.clone();
|
||||
move || {
|
||||
for id in ids {
|
||||
let mut chain = Self::load(&root, &device, &queue, &id);
|
||||
let mut chain = Self::load(&root, &device, &queue, &id, &Extras::default());
|
||||
if let Ok(Some(chain)) = &mut chain {
|
||||
for pass in &mut chain.passes {
|
||||
if let Some(translated) = &mut pass.translated {
|
||||
@@ -784,11 +824,18 @@ impl PostEffects {
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
id: &str,
|
||||
extras: &Extras,
|
||||
) -> Result<Option<Chain>, String> {
|
||||
let Ok(text) = std::fs::read_to_string(resource_path(root, id)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let config = Config::parse(&text)?;
|
||||
let mut config = Config::parse(&text)?;
|
||||
for (target, t) in &extras.targets {
|
||||
config
|
||||
.targets
|
||||
.entry(target.clone())
|
||||
.or_insert_with(|| t.clone());
|
||||
}
|
||||
// isPostEffectValid / LevelTargetBundle.MAIN_TARGETS: the frame is the one
|
||||
// external target Horizon has.
|
||||
if let Some(bad) = config.external_targets().into_iter().find(|t| *t != MAIN) {
|
||||
@@ -812,18 +859,26 @@ impl PostEffects {
|
||||
let mut passes = Vec::new();
|
||||
for (n, p) in config.passes.iter().enumerate() {
|
||||
let output = index(&p.output).ok_or("a target is missing")?;
|
||||
let pass =
|
||||
match Self::translated_pass(root, device, queue, n, p, output, &index, &mut images)
|
||||
{
|
||||
Ok(pass) => pass,
|
||||
Err(e) => match Self::standin_pass(device, queue, n, p, output, &index) {
|
||||
Ok(pass) => {
|
||||
log::debug!("post effect {id} pass {n} uses the built-in shaders: {e}");
|
||||
pass
|
||||
}
|
||||
Err(_) => return Err(e),
|
||||
},
|
||||
};
|
||||
let pass = match Self::translated_pass(
|
||||
root,
|
||||
device,
|
||||
queue,
|
||||
n,
|
||||
p,
|
||||
output,
|
||||
&index,
|
||||
&mut images,
|
||||
extras,
|
||||
) {
|
||||
Ok(pass) => pass,
|
||||
Err(e) => match Self::standin_pass(device, queue, n, p, output, &index) {
|
||||
Ok(pass) => {
|
||||
log::debug!("post effect {id} pass {n} uses the built-in shaders: {e}");
|
||||
pass
|
||||
}
|
||||
Err(_) => return Err(e),
|
||||
},
|
||||
};
|
||||
passes.push(pass);
|
||||
}
|
||||
Ok(Some(Chain {
|
||||
@@ -847,6 +902,7 @@ impl PostEffects {
|
||||
output: usize,
|
||||
index: &dyn Fn(&str) -> Option<usize>,
|
||||
images: &mut Vec<(wgpu::Texture, wgpu::TextureView, [u32; 2])>,
|
||||
extras: &Extras,
|
||||
) -> Result<Pass, String> {
|
||||
let program = crate::post_glsl::translate(root, &p.vertex, &p.fragment)?;
|
||||
let mut refs = Vec::new();
|
||||
@@ -879,6 +935,15 @@ impl PostEffects {
|
||||
let block = match name.as_str() {
|
||||
"SamplerInfo" => Block::Info,
|
||||
"Globals" => Block::Globals,
|
||||
other if !p.uniforms.contains_key(other) && extras.blocks.contains_key(other) => {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Post effect pack uniforms"),
|
||||
size: UNIFORM_BYTES,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
Block::Dynamic(other.to_owned(), buffer)
|
||||
}
|
||||
other => {
|
||||
let values = p
|
||||
.uniforms
|
||||
@@ -1130,6 +1195,7 @@ impl PostEffects {
|
||||
) -> bool {
|
||||
self.use_root(req.root);
|
||||
self.collect_preloaded();
|
||||
self.extra_blocks = req.extras.map(|e| e.blocks.clone()).unwrap_or_default();
|
||||
let mut applied = Vec::new();
|
||||
for effect in req.effects {
|
||||
let id = normalise(effect);
|
||||
@@ -1138,7 +1204,9 @@ impl PostEffects {
|
||||
continue;
|
||||
}
|
||||
if !self.chains.contains_key(&id) {
|
||||
let loaded = match Self::load(&self.root, device, queue, &id) {
|
||||
let empty = Extras::default();
|
||||
let extras = req.extras.unwrap_or(&empty);
|
||||
let loaded = match Self::load(&self.root, device, queue, &id, extras) {
|
||||
Ok(chain) => chain,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to load post effect {id}: {e}");
|
||||
@@ -1207,6 +1275,11 @@ impl PostEffects {
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
self.staging = Some(staging);
|
||||
if let Some(view) = depth {
|
||||
self.gl_depth(device, encoder, view, size);
|
||||
}
|
||||
let gl_depth = self.gl_depth_target.take();
|
||||
let depth = depth.and(gl_depth.as_ref().map(|(_, v)| v));
|
||||
for id in &applied {
|
||||
let mut chain = self.chains.remove(id).flatten().expect("loaded above");
|
||||
self.run_chain(
|
||||
@@ -1237,9 +1310,117 @@ impl PostEffects {
|
||||
);
|
||||
self.blit = Some(blit);
|
||||
self.work = Some(work);
|
||||
self.gl_depth_target = gl_depth;
|
||||
true
|
||||
}
|
||||
|
||||
/// Copies `depth` (the frame's) into `gl_depth_target` with GL's rows, at `size`.
|
||||
fn gl_depth(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
depth: &wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
) {
|
||||
let (layout, pipeline) = self.gl_depth.get_or_insert_with(|| {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Post effect GL depth"),
|
||||
source: wgpu::ShaderSource::Wgsl(GL_DEPTH_WGSL.into()),
|
||||
});
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Post effect GL depth"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Post effect GL depth"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Post effect GL depth"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::TextureFormat::R32Float.into())],
|
||||
}),
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
(layout, pipeline)
|
||||
});
|
||||
if self
|
||||
.gl_depth_target
|
||||
.as_ref()
|
||||
.is_none_or(|(t, _)| t.size().width != size[0] || t.size().height != size[1])
|
||||
{
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Post effect GL depth"),
|
||||
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: wgpu::TextureFormat::R32Float,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
self.gl_depth_target = Some((texture, view));
|
||||
}
|
||||
let (_, target) = self.gl_depth_target.as_ref().expect("made above");
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Post effect GL depth"),
|
||||
layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(depth),
|
||||
}],
|
||||
});
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Post effect GL depth"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_chain(
|
||||
&mut self,
|
||||
@@ -1438,6 +1619,13 @@ impl PostEffects {
|
||||
Block::Info => &pass.info,
|
||||
Block::Globals => &self.globals,
|
||||
Block::Custom(buffer) => buffer,
|
||||
Block::Dynamic(name, buffer) => {
|
||||
if let Some(bytes) = self.extra_blocks.get(name) {
|
||||
let n = bytes.len().min(UNIFORM_BYTES as usize) & !3;
|
||||
queue.write_buffer(buffer, 0, &bytes[..n]);
|
||||
}
|
||||
buffer
|
||||
}
|
||||
};
|
||||
entries.push(wgpu::BindGroupEntry {
|
||||
binding: *binding,
|
||||
@@ -1831,6 +2019,7 @@ mod tests {
|
||||
root,
|
||||
menu_blur_radius: 5.0,
|
||||
globals: Globals::default(),
|
||||
extras: None,
|
||||
},
|
||||
None,
|
||||
);
|
||||
@@ -1962,6 +2151,7 @@ mod tests {
|
||||
root,
|
||||
menu_blur_radius: 0.0,
|
||||
globals: Globals::default(),
|
||||
extras: None,
|
||||
},
|
||||
None,
|
||||
));
|
||||
@@ -2050,6 +2240,99 @@ mod tests {
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// The frame's depth reaches a pass as 26.3 keeps it, reverse-Z (the sky 0, a near pixel
|
||||
/// close to 1), not as GL's window depth.
|
||||
#[test]
|
||||
fn a_pass_reads_the_frame_depth_as_it_is() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let root = scratch(
|
||||
"depth",
|
||||
r#"{"passes":[{"vertex_shader":"minecraft:core/quad","fragment_shader":"minecraft:post/depth",
|
||||
"inputs":[{"sampler_name":"Depth","target":"minecraft:main","use_depth_buffer":true}],
|
||||
"output":"minecraft:main"}]}"#,
|
||||
);
|
||||
own_glsl(&root);
|
||||
std::fs::write(
|
||||
root.join("assets/minecraft/shaders/post/depth.fsh"),
|
||||
"#version 330\nuniform sampler2D DepthSampler;\nlayout(location = 0) in vec2 texCoord;\n\
|
||||
layout(location = 0) out vec4 fragColor;\n\
|
||||
void main() { fragColor = vec4(texture(DepthSampler, texCoord).r, 0.0, 0.0, 1.0); }\n",
|
||||
)
|
||||
.unwrap();
|
||||
let size = wgpu::Extent3d {
|
||||
width: 8,
|
||||
height: 4,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let texture = |format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let frame = texture(
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let depth = texture(
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
);
|
||||
let (view, depth_view) = (
|
||||
frame.create_view(&Default::default()),
|
||||
depth.create_view(&Default::default()),
|
||||
);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &depth_view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.25),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
let mut effects = PostEffects::new(&device);
|
||||
let ran = effects.apply(
|
||||
&device,
|
||||
&queue,
|
||||
&mut encoder,
|
||||
&frame,
|
||||
&view,
|
||||
[8, 4],
|
||||
Some(&depth_view),
|
||||
&Request {
|
||||
effects: &["minecraft:depth".to_owned()],
|
||||
root: &root,
|
||||
menu_blur_radius: 0.0,
|
||||
globals: Globals::default(),
|
||||
extras: None,
|
||||
},
|
||||
None,
|
||||
);
|
||||
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &frame, [8, 4]);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let red = buffer.get_mapped_range(..).unwrap()[0];
|
||||
assert!(ran);
|
||||
// 0.25 of 255, not 0.75.
|
||||
assert!(red.abs_diff(64) <= 1, "{red}");
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// `core/quad.vsh`, `post/mine.fsh` and the `tint` image of `a_chain_runs_its_own_glsl`.
|
||||
fn own_glsl(root: &Path) {
|
||||
let shaders = root.join("assets/minecraft/shaders");
|
||||
@@ -2189,13 +2472,22 @@ mod tests {
|
||||
for name in ["blur", "creeper", "invert", "spider"] {
|
||||
let id = format!("minecraft:{name}");
|
||||
assert!(
|
||||
PostEffects::load(&root, &device, &queue, &id)
|
||||
PostEffects::load(&root, &device, &queue, &id, &Extras::default())
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"{id}"
|
||||
);
|
||||
}
|
||||
// entity_outline needs targets and shaders the world pass has of its own.
|
||||
assert!(PostEffects::load(&root, &device, &queue, "minecraft:entity_outline").is_err());
|
||||
assert!(
|
||||
PostEffects::load(
|
||||
&root,
|
||||
&device,
|
||||
&queue,
|
||||
"minecraft:entity_outline",
|
||||
&Extras::default()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,7 @@ pub struct Context {
|
||||
pub spectator: bool,
|
||||
/// `isSteppingCarefully` (sneaking).
|
||||
pub stepping_carefully: bool,
|
||||
/// `dampensVibrations` (a warden).
|
||||
/// `dampensVibrations` (a warden or a wool item).
|
||||
pub dampens: bool,
|
||||
/// The source is a living entity wardens may target.
|
||||
pub targetable: bool,
|
||||
@@ -187,11 +187,10 @@ pub fn valid_vibration(listens: bool, event: &str, ctx: &Context) -> bool {
|
||||
if ctx.stepping_carefully && ignored_when_sneaking(event) {
|
||||
return false;
|
||||
}
|
||||
if ctx.dampens {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
!ctx.dampening_state
|
||||
// Nonliving sources have no Who handle, but ItemEntity still carries its
|
||||
// stack's dampensVibrations flag (including after Entity.kill removes it).
|
||||
!ctx.dampens && !ctx.dampening_state
|
||||
}
|
||||
|
||||
impl Selector {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -19,8 +19,11 @@ pub struct ShaderOptions {
|
||||
pub chosen: BTreeMap<String, String>,
|
||||
/// The screens opened from the main one, innermost last.
|
||||
pub path: Vec<String>,
|
||||
/// Something changed since the page opened.
|
||||
/// The values differ from those the pack's file holds (`saved`): saving them loads the pack
|
||||
/// again.
|
||||
pub changed: bool,
|
||||
/// The values the pack's file holds, as read or as last saved.
|
||||
saved: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl ShaderOptions {
|
||||
@@ -39,14 +42,28 @@ impl ShaderOptions {
|
||||
chosen: BTreeMap::new(),
|
||||
path: Vec::new(),
|
||||
changed: false,
|
||||
saved: BTreeMap::new(),
|
||||
};
|
||||
for (name, value) in chosen {
|
||||
out.set(&name, value);
|
||||
}
|
||||
out.saved = out.chosen.clone();
|
||||
out.changed = false;
|
||||
out
|
||||
}
|
||||
|
||||
/// The values back to `chosen` (Undo): changed while they differ from the file's.
|
||||
pub fn restore(&mut self, chosen: BTreeMap<String, String>) {
|
||||
self.chosen = chosen;
|
||||
self.changed = self.chosen != self.saved;
|
||||
}
|
||||
|
||||
/// The pack's file holds `saved` now, saved by another way than this page's.
|
||||
pub fn saved_as(&mut self, saved: BTreeMap<String, String>) {
|
||||
self.saved = saved;
|
||||
self.changed = self.chosen != self.saved;
|
||||
}
|
||||
|
||||
/// The screen shown: the innermost opened, else the main one.
|
||||
pub fn screen(&self) -> &str {
|
||||
self.path.last().map_or("", String::as_str)
|
||||
@@ -74,7 +91,7 @@ impl ShaderOptions {
|
||||
} else {
|
||||
self.chosen.insert(name.to_owned(), value);
|
||||
}
|
||||
self.changed = true;
|
||||
self.changed = self.chosen != self.saved;
|
||||
}
|
||||
|
||||
/// The option's next value (the previous one with `back`), wrapping around.
|
||||
@@ -123,9 +140,8 @@ impl ShaderOptions {
|
||||
|
||||
/// Option `name` back at the pack's default.
|
||||
pub fn reset_option(&mut self, name: &str) {
|
||||
if self.chosen.remove(name).is_some() {
|
||||
self.changed = true;
|
||||
}
|
||||
self.chosen.remove(name);
|
||||
self.changed = self.chosen != self.saved;
|
||||
}
|
||||
|
||||
/// The profile after the current one (the first when none matches) with its values.
|
||||
@@ -150,10 +166,8 @@ impl ShaderOptions {
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
if !self.chosen.is_empty() {
|
||||
self.chosen.clear();
|
||||
self.changed = true;
|
||||
}
|
||||
self.chosen.clear();
|
||||
self.changed = self.chosen != self.saved;
|
||||
}
|
||||
|
||||
fn named(&self, key: &str, or: &str) -> Text {
|
||||
|
||||
@@ -73,29 +73,6 @@ impl Entry {
|
||||
changed
|
||||
}
|
||||
|
||||
/// Option `i`'s slider one step on (`back`: one before), stopping at its ends. Whether its
|
||||
/// value changed.
|
||||
pub fn nudge_option(&mut self, i: usize, back: bool) -> bool {
|
||||
let (Some(o), Some(v)) = (self.options.get(i), self.values.get_mut(i)) else {
|
||||
return false;
|
||||
};
|
||||
let dir = if back { -1.0 } else { 1.0 };
|
||||
let new = match &o.kind {
|
||||
OptionKind::Float { min, max, step, .. } => {
|
||||
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
|
||||
let n = ((*v - min) / step).round() + dir;
|
||||
on_f32((min + n * step).clamp(min, max))
|
||||
}
|
||||
OptionKind::Int { min, max, .. } => {
|
||||
(v.round() + dir).clamp(f64::from(*min), f64::from(*max))
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
let changed = new != *v;
|
||||
*v = new;
|
||||
changed
|
||||
}
|
||||
|
||||
/// Every option back to its default.
|
||||
pub fn reset_options(&mut self) {
|
||||
let defaults = self.options.iter().map(|o| o.kind.default_constant());
|
||||
@@ -353,15 +330,36 @@ fn step_value(kind: &OptionKind, v: f64, back: bool) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `v` sits between the option's minimum and maximum, 0 to 1; `None` for the options
|
||||
/// that are buttons.
|
||||
/// A float option's stops past its minimum: its steps, the last one cut short at the maximum
|
||||
/// when the range isn't a whole number of them.
|
||||
pub fn float_steps(min: f32, max: f32, step: f32) -> f64 {
|
||||
let (min, max, step) = (f64::from(min), f64::from(max), f64::from(step));
|
||||
((max - min) / step - STEP_SLACK).ceil()
|
||||
}
|
||||
/// What a range a hair over a whole number of steps loses to rounding, short of another stop.
|
||||
const STEP_SLACK: f64 = 1e-6;
|
||||
|
||||
/// Where `v` sits between the option's minimum and maximum, 0 to 1, as `slider_value` reads
|
||||
/// it back: a float at its stop (the maximum the last one), an int along its range. `None` for
|
||||
/// the options that are buttons.
|
||||
fn slider_position(kind: &OptionKind, v: f64) -> Option<f32> {
|
||||
let (min, max) = match kind {
|
||||
OptionKind::Float { min, max, .. } => (f64::from(*min), f64::from(*max)),
|
||||
OptionKind::Int { min, max, .. } => (f64::from(*min), f64::from(*max)),
|
||||
let position = match kind {
|
||||
OptionKind::Float { min, max, step, .. } => {
|
||||
let steps = float_steps(*min, *max, *step);
|
||||
let stop = ((v - f64::from(*min)) / f64::from(*step)).round();
|
||||
if v >= f64::from(*max) || steps <= 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
stop / steps
|
||||
}
|
||||
}
|
||||
OptionKind::Int { min, max, .. } => {
|
||||
let (min, max) = (f64::from(*min), f64::from(*max));
|
||||
(v - min) / (max - min)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(((v - min) / (max - min)).clamp(0.0, 1.0) as f32)
|
||||
Some(position.clamp(0.0, 1.0) as f32)
|
||||
}
|
||||
|
||||
/// The option's value nearest the slider position `at`: a float on its steps from the
|
||||
@@ -371,8 +369,8 @@ fn slider_value(kind: &OptionKind, at: f32) -> Option<f64> {
|
||||
let at = f64::from(at.clamp(0.0, 1.0));
|
||||
match kind {
|
||||
OptionKind::Float { min, max, step, .. } => {
|
||||
let steps = float_steps(*min, *max, *step);
|
||||
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
|
||||
let steps = ((max - min) / step - 1e-6).ceil();
|
||||
Some(on_f32((min + (at * steps).round() * step).min(max)))
|
||||
}
|
||||
OptionKind::Int { min, max, .. } => {
|
||||
@@ -649,17 +647,47 @@ mod tests {
|
||||
assert_eq!(entry.values[3], 1.0);
|
||||
entry.set_slider_position(3, 0.6);
|
||||
assert_eq!(entry.values[3], f64::from(0.6_f32));
|
||||
}
|
||||
|
||||
// The arrow keys: a step at a time, not round past the ends.
|
||||
entry.values[0] = 2.0;
|
||||
assert!(!entry.nudge_option(0, false));
|
||||
assert_eq!(entry.values[0], 2.0);
|
||||
assert!(entry.nudge_option(0, true));
|
||||
assert_eq!(entry.values[0], f64::from(1.95_f32));
|
||||
entry.values[1] = 0.0;
|
||||
assert!(!entry.nudge_option(1, true));
|
||||
assert!(entry.nudge_option(1, false));
|
||||
assert_eq!(entry.values[1], 1.0);
|
||||
assert!(!entry.nudge_option(2, false));
|
||||
/// The arrow keys step a slider through its stops (the shell's `slider_step`, by the stops
|
||||
/// `float_steps` counts): a float whose range isn't a whole number of steps walks every one
|
||||
/// both ways, its maximum included, and stops at the ends.
|
||||
#[test]
|
||||
fn the_arrow_keys_walk_a_floats_stops_both_ways() {
|
||||
let kind = OptionKind::Float {
|
||||
default: 0.0,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
step: 0.3,
|
||||
};
|
||||
let mut entry = Entry {
|
||||
file: "p".into(),
|
||||
key: "horizon:p".into(),
|
||||
title: "P".into(),
|
||||
kind: Kind::Horizon,
|
||||
options: vec![horizon_shader_api::PackOption {
|
||||
id: "d".into(),
|
||||
name: "D".into(),
|
||||
description: String::new(),
|
||||
kind,
|
||||
}],
|
||||
values: vec![0.0],
|
||||
pages: Vec::new(),
|
||||
};
|
||||
let steps = float_steps(0.0, 1.0, 0.3) as u32;
|
||||
let mut walk = |forward: bool| {
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..steps + 2 {
|
||||
let at = entry.slider_position(0).unwrap();
|
||||
let next = crate::settings_shell::slider_step(at, Some(steps), 100.0, forward);
|
||||
entry.set_slider_position(0, next);
|
||||
seen.push(entry.values[0]);
|
||||
}
|
||||
seen
|
||||
};
|
||||
// The `k`th stop, as the pack's f32 `override` gets it.
|
||||
let stop = |k: u8| f64::from((f64::from(k) * f64::from(0.3_f32)) as f32);
|
||||
assert_eq!(walk(true), [stop(1), stop(2), stop(3), 1.0, 1.0, 1.0]);
|
||||
assert_eq!(walk(false), [stop(3), stop(2), stop(1), 0.0, 0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,13 @@ pub use crate::chunk::block_entity::{SavedLine, SignData, SignText};
|
||||
use crate::{scoreboard, text::Text};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `getBooleanOr(key, false)` and `Codec.BOOL`: any numeric tag's nonzero byte.
|
||||
fn nbt_flag(tag: &Tag, key: &str) -> bool {
|
||||
tag.get(key)
|
||||
.and_then(horizon_client_core::nbt_path::int_value)
|
||||
.is_some_and(|value| value as i8 != 0)
|
||||
}
|
||||
|
||||
/// `DyeColor` names and `getTextColor` values by id.
|
||||
pub const DYE_TEXT: [(&str, u32); 16] = [
|
||||
("white", 0xFFFFFF),
|
||||
@@ -201,12 +208,12 @@ impl SignData {
|
||||
out.color = i as u8;
|
||||
}
|
||||
}
|
||||
out.glowing = matches!(t.get("has_glowing_text"), Some(Tag::Byte(b)) if *b != 0);
|
||||
out.glowing = nbt_flag(t, "has_glowing_text");
|
||||
out
|
||||
};
|
||||
self.front = side(tag.get("front_text"));
|
||||
self.back = side(tag.get("back_text"));
|
||||
self.waxed = matches!(tag.get("is_waxed"), Some(Tag::Byte(b)) if *b != 0);
|
||||
self.waxed = nbt_flag(tag, "is_waxed");
|
||||
self.allow_op_features =
|
||||
matches!(tag.get("allow_op_features"), Some(Tag::Byte(b)) if *b != 0);
|
||||
}
|
||||
@@ -301,6 +308,19 @@ pub fn editor_sprite(folder: &str, wood: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn waxed_and_glowing_text_take_any_number() {
|
||||
let tag = Tag::compound([
|
||||
("is_waxed", Tag::Int(1)),
|
||||
(
|
||||
"front_text",
|
||||
Tag::compound([("has_glowing_text", Tag::Short(1))]),
|
||||
),
|
||||
]);
|
||||
let mut decoded = SignData::default();
|
||||
decoded.decode(&tag);
|
||||
assert!(decoded.waxed && decoded.front.glowing);
|
||||
}
|
||||
#[test]
|
||||
fn round_trips_nbt() {
|
||||
let mut data = SignData {
|
||||
hanging: false,
|
||||
|
||||
@@ -978,6 +978,10 @@ pub struct SoundEngine {
|
||||
instance_by_source: Vec<(usize, InstanceId)>,
|
||||
gain_by_source: [f32; 11],
|
||||
ticking: Vec<InstanceId>,
|
||||
live_scratch: Vec<(u64, ChannelId)>,
|
||||
mapped_scratch: Vec<u64>,
|
||||
removed_scratch: Vec<InstanceId>,
|
||||
ticking_scratch: Vec<InstanceId>,
|
||||
/// `queuedSounds` (insertion order).
|
||||
queued: Vec<(InstanceId, i32)>,
|
||||
delete_time: HashMap<InstanceId, i32>,
|
||||
@@ -1015,6 +1019,10 @@ impl SoundEngine {
|
||||
instance_by_source: Vec::new(),
|
||||
gain_by_source: [1.0; 11],
|
||||
ticking: Vec::new(),
|
||||
live_scratch: Vec::new(),
|
||||
mapped_scratch: Vec::new(),
|
||||
removed_scratch: Vec::new(),
|
||||
ticking_scratch: Vec::new(),
|
||||
queued: Vec::new(),
|
||||
delete_time: HashMap::new(),
|
||||
queued_tickable: Vec::new(),
|
||||
@@ -1479,20 +1487,26 @@ impl SoundEngine {
|
||||
self.schedule_tick(backend);
|
||||
}
|
||||
fn schedule_tick(&mut self, backend: &mut impl Backend) {
|
||||
let live: Vec<(u64, ChannelId)> = self
|
||||
.handles
|
||||
.iter()
|
||||
.filter_map(|(h, handle)| handle.channel.map(|c| (*h, c)))
|
||||
.collect();
|
||||
for (h, c) in live {
|
||||
let mut live = std::mem::take(&mut self.live_scratch);
|
||||
live.clear();
|
||||
live.extend(
|
||||
self.handles
|
||||
.iter()
|
||||
.filter_map(|(h, handle)| handle.channel.map(|c| (*h, c))),
|
||||
);
|
||||
for (h, c) in live.drain(..) {
|
||||
if backend.stopped(c) {
|
||||
self.release_handle(h, backend);
|
||||
}
|
||||
}
|
||||
// Released handles nobody maps any more.
|
||||
let mapped: Vec<u64> = self.instance_to_channel.iter().map(|(_, h)| *h).collect();
|
||||
let mut mapped = std::mem::take(&mut self.mapped_scratch);
|
||||
mapped.clear();
|
||||
mapped.extend(self.instance_to_channel.iter().map(|(_, h)| *h));
|
||||
self.handles
|
||||
.retain(|h, handle| handle.channel.is_some() || mapped.contains(h));
|
||||
self.live_scratch = live;
|
||||
self.mapped_scratch = mapped;
|
||||
}
|
||||
fn handle_stopped(&self, handle: u64) -> bool {
|
||||
self.handles.get(&handle).is_none_or(|h| h.stopped)
|
||||
@@ -1502,7 +1516,10 @@ impl SoundEngine {
|
||||
for id in std::mem::take(&mut self.queued_tickable) {
|
||||
self.play(id, backend);
|
||||
}
|
||||
for id in self.ticking.clone() {
|
||||
let mut ticking = std::mem::take(&mut self.ticking_scratch);
|
||||
ticking.clear();
|
||||
ticking.extend(self.ticking.iter().copied());
|
||||
for id in ticking.drain(..) {
|
||||
let Some(instance) = self.instances.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
@@ -1549,7 +1566,8 @@ impl SoundEngine {
|
||||
self.notify(id, subtitle, distance);
|
||||
}
|
||||
}
|
||||
let mut removed = Vec::new();
|
||||
let mut removed = std::mem::take(&mut self.removed_scratch);
|
||||
removed.clear();
|
||||
let mut index = 0;
|
||||
while index < self.instance_to_channel.len() {
|
||||
let (id, handle) = self.instance_to_channel[index];
|
||||
@@ -1596,9 +1614,11 @@ impl SoundEngine {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
for id in removed {
|
||||
for id in removed.drain(..) {
|
||||
self.collect(id);
|
||||
}
|
||||
self.ticking_scratch = ticking;
|
||||
self.removed_scratch = removed;
|
||||
}
|
||||
fn tick_music_when_paused(&mut self) {
|
||||
let mut index = 0;
|
||||
|
||||
@@ -1654,11 +1654,9 @@ mod tests {
|
||||
"L'écran partagé est désactivé."
|
||||
);
|
||||
assert!(labels(&ui).contains(&"Écran partagé : Désactivé".to_owned()));
|
||||
// German translates it now; a key a language lacks still falls back to the bundled
|
||||
// English (lang's `layers_selected_language_over_english`).
|
||||
lang::set(lang::Language::load(&[], "de_de"));
|
||||
assert_eq!(
|
||||
ui.status_lines().unwrap()[0],
|
||||
"Splitscreen is off.",
|
||||
"untranslated new keys use bundled English"
|
||||
);
|
||||
assert_eq!(ui.status_lines().unwrap()[0], "Splitscreen ist aus.");
|
||||
}
|
||||
}
|
||||
@@ -530,6 +530,12 @@ pub struct Streaming {
|
||||
saves: Arc<dyn Saves>,
|
||||
/// Results received while `save` waited, for the next `update`.
|
||||
deferred: Vec<Done>,
|
||||
/// Per-update collection scratch, retained to avoid allocator churn while
|
||||
/// the view is moving or many columns are dirty.
|
||||
light_scratch: Vec<(i64, i32, i32)>,
|
||||
gone_scratch: Vec<(i32, i32)>,
|
||||
mesh_candidates_scratch: Vec<(i64, i32, i32, SectionMask)>,
|
||||
mesh_columns_scratch: Vec<((i32, i32), SectionMask)>,
|
||||
sequence: u64,
|
||||
pub center: (i32, i32),
|
||||
/// The view distance around `center`; below 0, no area there (a dedicated
|
||||
@@ -1130,6 +1136,10 @@ impl Streaming {
|
||||
written: Default::default(),
|
||||
saves,
|
||||
deferred: Vec::new(),
|
||||
light_scratch: Vec::new(),
|
||||
gone_scratch: Vec::new(),
|
||||
mesh_candidates_scratch: Vec::new(),
|
||||
mesh_columns_scratch: Vec::new(),
|
||||
sequence: 0,
|
||||
center: (0, 0),
|
||||
radius: 8,
|
||||
@@ -1664,6 +1674,15 @@ impl Streaming {
|
||||
if self.paused {
|
||||
return (uploads, Vec::new());
|
||||
}
|
||||
let mut stale = Vec::new();
|
||||
let mut light = std::mem::take(&mut self.light_scratch);
|
||||
light.clear();
|
||||
let mut gone = std::mem::take(&mut self.gone_scratch);
|
||||
gone.clear();
|
||||
let mut candidates = std::mem::take(&mut self.mesh_candidates_scratch);
|
||||
candidates.clear();
|
||||
let mut columns = std::mem::take(&mut self.mesh_columns_scratch);
|
||||
columns.clear();
|
||||
ExtraCover::refresh(&mut self.cover, &self.extra);
|
||||
self.cover_current = true;
|
||||
// The sweeps below walk every loaded column and proto chunk. Run them
|
||||
@@ -1678,7 +1697,6 @@ impl Streaming {
|
||||
let deferred = std::mem::take(&mut self.deferred);
|
||||
let received = self.shared.done.take();
|
||||
// Meshes made stale meanwhile, freed with the uploaded ones (`free`).
|
||||
let mut stale = Vec::new();
|
||||
for done in deferred.into_iter().chain(received) {
|
||||
match done {
|
||||
Done::Proto(x, z, proto, decorated) => {
|
||||
@@ -1831,8 +1849,8 @@ impl Streaming {
|
||||
protos.contains_key(k) || finishing.contains(k) || world.chunks.contains_key(k)
|
||||
});
|
||||
// Finalize chunks whose neighbourhood is decorated.
|
||||
let candidates: Vec<(i32, i32)> = self.protos.keys().copied().collect();
|
||||
for (x, z) in candidates {
|
||||
let finalize_candidates: Vec<(i32, i32)> = self.protos.keys().copied().collect();
|
||||
for (x, z) in finalize_candidates {
|
||||
if world.chunks.contains_key(&(x, z))
|
||||
|| self.finishing.contains(&(x, z))
|
||||
|| !self.in_range(x, z, 1)
|
||||
@@ -1990,8 +2008,6 @@ impl Streaming {
|
||||
// grew with the render distance and outlived the columns they
|
||||
// were queued for (#3629). What stays in `dirty_light` is asked
|
||||
// for again, from the current centre, as capacity frees.
|
||||
let mut light: Vec<(i64, i32, i32)> = Vec::new();
|
||||
let mut gone: Vec<(i32, i32)> = Vec::new();
|
||||
for &(x, z) in world.dirty_light.iter() {
|
||||
if !world.chunks.contains_key(&(x, z)) {
|
||||
gone.push((x, z));
|
||||
@@ -1999,12 +2015,12 @@ impl Streaming {
|
||||
light.push((self.distance(x, z), x, z));
|
||||
}
|
||||
}
|
||||
for key in gone {
|
||||
for key in gone.drain(..) {
|
||||
world.dirty_light.remove(&key);
|
||||
}
|
||||
let room = (self.workers * 4).saturating_sub(self.lighting.len());
|
||||
nearest_first(&mut light, room);
|
||||
for (_, x, z) in light {
|
||||
for (_, x, z) in light.drain(..) {
|
||||
let Some(region) = self.light_region(world, x, z) else {
|
||||
continue;
|
||||
};
|
||||
@@ -2023,6 +2039,10 @@ impl Streaming {
|
||||
if !self.mesh {
|
||||
world.dirty_sections.clear();
|
||||
self.cover_current = false;
|
||||
self.light_scratch = light;
|
||||
self.gone_scratch = gone;
|
||||
self.mesh_candidates_scratch = candidates;
|
||||
self.mesh_columns_scratch = columns;
|
||||
return (uploads, removed);
|
||||
}
|
||||
// Meshing once the column and its neighbours are lit, and the column
|
||||
@@ -2056,9 +2076,8 @@ impl Streaming {
|
||||
}
|
||||
// Column by column: the checks below hold for all of a column's
|
||||
// sections, and columns waiting for light cost one look per frame.
|
||||
let mut candidates: Vec<(i64, i32, i32, SectionMask)> = Vec::new();
|
||||
let columns: Vec<_> = world.dirty_sections.columns().collect();
|
||||
for ((x, z), sections) in columns {
|
||||
columns.extend(world.dirty_sections.columns());
|
||||
for ((x, z), sections) in columns.iter().copied() {
|
||||
if !world.chunks.contains_key(&(x, z)) {
|
||||
world.dirty_sections.take_column(x, z);
|
||||
continue;
|
||||
@@ -2096,7 +2115,7 @@ impl Streaming {
|
||||
// capacity frees; `requeue` then has a small queue to keep close.
|
||||
let room = (self.workers * 32).saturating_sub(self.meshing.len());
|
||||
nearest_first(&mut candidates, room);
|
||||
for (_, x, z, sections) in candidates {
|
||||
for (_, x, z, sections) in candidates.drain(..) {
|
||||
let Some(region) = Self::region(world, x, z) else {
|
||||
continue;
|
||||
};
|
||||
@@ -2132,6 +2151,10 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
self.cover_current = false;
|
||||
self.light_scratch = light;
|
||||
self.gone_scratch = gone;
|
||||
self.mesh_candidates_scratch = candidates;
|
||||
self.mesh_columns_scratch = columns;
|
||||
(uploads, removed)
|
||||
}
|
||||
fn schedule_generation(&mut self, world: &World, workers: usize) {
|
||||
|
||||
@@ -344,10 +344,16 @@ pub struct TabInputs<'a> {
|
||||
/// The player is in the level with an upside-down name
|
||||
/// (`AvatarRenderer.isPlayerUpsideDown`).
|
||||
pub upside_down: &'a dyn Fn(u128) -> bool,
|
||||
/// The player plays Horizon: Horizon's mark after their head (`horizon_cosmetics::mark`).
|
||||
pub horizon: &'a dyn Fn(u128) -> bool,
|
||||
/// A mark after the player's head, or none: an extension point for what lives above the
|
||||
/// engine (Horizon's mark, `horizon_cosmetics::mark`). Vanilla draws none.
|
||||
pub mark: &'a dyn Fn(u128) -> Option<NameMark>,
|
||||
}
|
||||
|
||||
/// A mark drawn after a player's head in the tab list: filled rectangles `(x, y, width, height,
|
||||
/// ARGB)` in an 8x8 box. Once any listed player has one, every row gets the column, so the names
|
||||
/// stay aligned.
|
||||
pub type NameMark = &'static [(u8, u8, u8, u8, u32)];
|
||||
|
||||
/// `PlayerTabOverlay`: the header, footer, visibility and heart states.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TabList {
|
||||
@@ -367,7 +373,7 @@ struct LayoutCache {
|
||||
footer: Option<Text>,
|
||||
language: Option<std::sync::Arc<crate::lang::Language>>,
|
||||
dimensions: (i32, bool, u32, u64),
|
||||
flags: Vec<(bool, bool)>,
|
||||
flags: Vec<(bool, Option<NameMark>)>,
|
||||
draws: Vec<GuiDraw>,
|
||||
hearts: Vec<(usize, i32, i32, i32, u128, i32)>,
|
||||
}
|
||||
@@ -426,7 +432,7 @@ impl TabList {
|
||||
let flags: Vec<_> = i
|
||||
.players
|
||||
.iter()
|
||||
.map(|p| ((i.upside_down)(p.uuid), (i.horizon)(p.uuid)))
|
||||
.map(|p| ((i.upside_down)(p.uuid), (i.mark)(p.uuid)))
|
||||
.collect();
|
||||
let valid = self.cache.as_ref().is_some_and(|c| {
|
||||
c.players == i.players
|
||||
@@ -540,9 +546,13 @@ impl TabList {
|
||||
};
|
||||
let sw = i.screen_width;
|
||||
let head = if show_head { 9 } else { 0 };
|
||||
// Horizon: a column for the mark once anyone listed has it, so the names stay aligned.
|
||||
let marked: Vec<bool> = players.iter().map(|p| (i.horizon)(p.uuid)).collect();
|
||||
let mark = if marked.contains(&true) { 9 } else { 0 };
|
||||
// A column for the marks once anyone listed has one, so the names stay aligned.
|
||||
let marked: Vec<Option<NameMark>> = players.iter().map(|p| (i.mark)(p.uuid)).collect();
|
||||
let mark = if marked.iter().any(Option::is_some) {
|
||||
9
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let head = head + mark;
|
||||
let slot_width = (cols * (head + max_name + width_for_score + 13)).min(sw - 50) / cols;
|
||||
let xxo = sw / 2 - (slot_width * cols + (cols - 1) * 5) / 2;
|
||||
@@ -610,8 +620,8 @@ impl TabList {
|
||||
);
|
||||
xo += 9;
|
||||
}
|
||||
if marked[n as usize] {
|
||||
for (mx, my, w, h, color) in horizon_cosmetics::mark::MARK {
|
||||
if let Some(rects) = marked[n as usize] {
|
||||
for &(mx, my, w, h, color) in rects {
|
||||
let (x, y) = (xo + i32::from(mx), yo + i32::from(my));
|
||||
let (x1, y1) = (x + i32::from(w), y + i32::from(h));
|
||||
out.push(GuiDraw::Fill {
|
||||
@@ -830,7 +840,7 @@ mod tests {
|
||||
gui_ticks: frame,
|
||||
background: 0x20ffffff,
|
||||
upside_down: &|_| false,
|
||||
horizon: &|_| false,
|
||||
mark: &|_| None,
|
||||
};
|
||||
fresh.cache = None;
|
||||
assert_eq!(
|
||||
|
||||
@@ -151,31 +151,23 @@ fn rotated_grid(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>,
|
||||
let dv_len = length(dv);
|
||||
let effective = sqrt(min(du_len, dv_len) * max(du_len, dv_len));
|
||||
let exact = max(0.0, log2(effective / min_pixel));
|
||||
let low = floor(exact);
|
||||
let mip_blend = fract(exact);
|
||||
// From this level on the sprite is a single texel (0: the views stop before one is), and
|
||||
// the sampler clamps any level past it to it: there every tap reads the same value, so
|
||||
// one tap gives the four's mean. Distant tiles sit there.
|
||||
let single = globals.filtering.z;
|
||||
if single > 0.0 && low >= single {
|
||||
return sprite_level(uv, layer, low);
|
||||
if single > 0.0 && floor(exact) >= single {
|
||||
return sprite_level(uv, layer, floor(exact));
|
||||
}
|
||||
let high_single = single > 0.0 && low + 1.0 >= single;
|
||||
// sampleRGSS takes each of the four taps at the two mip levels around `exact` and mixes them
|
||||
// by fract(exact). The sprite sampler's mip filter is linear, so a tap at the fractional
|
||||
// level is that mix: four texture instructions where the shader mix took eight.
|
||||
var offsets = array<vec2<f32>, 4>(
|
||||
vec2(0.125, 0.375), vec2(-0.125, -0.375), vec2(0.375, -0.125), vec2(-0.375, 0.125));
|
||||
var sum_low = vec4(0.0);
|
||||
var sum_high = vec4(0.0);
|
||||
var sum = vec4(0.0);
|
||||
for (var i = 0; i < 4; i++) {
|
||||
let p = uv + offsets[i] * pixel;
|
||||
sum_low += sprite_level(p, layer, low);
|
||||
if !high_single {
|
||||
sum_high += sprite_level(p, layer, low + 1.0);
|
||||
}
|
||||
sum += sprite_level(uv + offsets[i] * pixel, layer, exact);
|
||||
}
|
||||
if high_single {
|
||||
sum_high = 4.0 * sprite_level(uv, layer, low + 1.0);
|
||||
}
|
||||
return mix(sum_low * 0.25, sum_high * 0.25, mip_blend);
|
||||
return sum * 0.25;
|
||||
}
|
||||
// Distant terrain tiles (lod/mesh.rs) count their UVs in blocks past 1: each block takes the
|
||||
// sprite again, as separate faces would under the clamped sampler. Sprite UVs in [0, 1] stay.
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
//! On-screen touch controls for touch-screen devices (Android first). Nothing
|
||||
//! here runs unless a touch surface is in use.
|
||||
pub mod aim;
|
||||
pub mod layout;
|
||||
pub use aim::{
|
||||
attack_indicator, camera_delta, hotbar_slot, item_places_entity_on_tap, item_uses_on_tap,
|
||||
projectile_crosshair, screen_direction,
|
||||
};
|
||||
pub use layout::{Control, Mode, Placement, Settings};
|
||||
@@ -0,0 +1,255 @@
|
||||
// Adapted from Starling (https://github.com/starling-client/starling, `android` branch at
|
||||
// 150a414b09a1, `crates/horizon-client-render/src/touch.rs`), written by Vasysik and the
|
||||
// Starling contributors. Copyright (c) 2026 Ludovic Marie and the Horizon contributors, under the
|
||||
// same MIT license as Horizon (LICENSE at the repository root).
|
||||
|
||||
//! What a touch aims at and what it does: camera turns from a drag, the ray
|
||||
//! through a tapped point, the hotbar slot under a finger, and which held
|
||||
//! items act on a short tap. Pure functions; the touch controls call them.
|
||||
use super::layout::Settings;
|
||||
|
||||
/// Items that place an entity where the player taps, like a block: a short tap
|
||||
/// places them instead of waiting for a long press.
|
||||
pub fn item_places_entity_on_tap(item: &str) -> bool {
|
||||
let Some(id) = item.strip_prefix("minecraft:") else {
|
||||
return false;
|
||||
};
|
||||
id.ends_with("_boat")
|
||||
|| id.ends_with("_raft")
|
||||
|| id.ends_with("_spawn_egg")
|
||||
|| id == "minecart"
|
||||
|| id.ends_with("_minecart")
|
||||
|| matches!(
|
||||
id,
|
||||
"armor_stand" | "item_frame" | "glow_item_frame" | "painting" | "end_crystal"
|
||||
)
|
||||
}
|
||||
|
||||
/// Items used on the tapped surface (buckets, bone meal, flint and steel...):
|
||||
/// a short tap uses them instead of waiting for a long press.
|
||||
pub fn item_uses_on_tap(item: &str) -> bool {
|
||||
item_places_entity_on_tap(item)
|
||||
|| item
|
||||
.strip_prefix("minecraft:")
|
||||
.is_some_and(|id| id.ends_with("_bucket") && id != "milk_bucket")
|
||||
|| matches!(
|
||||
item,
|
||||
"minecraft:bone_meal"
|
||||
| "minecraft:flint_and_steel"
|
||||
| "minecraft:fire_charge"
|
||||
| "minecraft:honeycomb"
|
||||
| "minecraft:glass_bottle"
|
||||
| "minecraft:bucket"
|
||||
)
|
||||
}
|
||||
|
||||
/// Items that are aimed rather than pointed at a block: while one is held, the
|
||||
/// tap schemes show the crosshair and shoot along it.
|
||||
pub fn projectile_crosshair(item: &str) -> bool {
|
||||
matches!(
|
||||
item,
|
||||
"minecraft:bow"
|
||||
| "minecraft:crossbow"
|
||||
| "minecraft:trident"
|
||||
| "minecraft:snowball"
|
||||
| "minecraft:egg"
|
||||
| "minecraft:ender_pearl"
|
||||
| "minecraft:experience_bottle"
|
||||
| "minecraft:splash_potion"
|
||||
| "minecraft:lingering_potion"
|
||||
| "minecraft:wind_charge"
|
||||
| "minecraft:fishing_rod"
|
||||
)
|
||||
}
|
||||
|
||||
/// The camera turn in degrees (yaw, pitch) for a drag of `delta`, normalized to
|
||||
/// a `width` x `height` surface. Equal physical drags turn equally on either
|
||||
/// axis, in landscape and in portrait; a drag across the long side is 180°.
|
||||
pub fn camera_delta(delta: [f32; 2], width: u32, height: u32, s: &Settings) -> [f32; 2] {
|
||||
let scale = width.max(height).max(1) as f32;
|
||||
[
|
||||
delta[0] * width as f32 / scale * 180.0 * s.sensitivity * s.sensitivity_horizontal,
|
||||
delta[1] * height as f32 / scale * 180.0 * s.sensitivity * s.sensitivity_vertical,
|
||||
]
|
||||
}
|
||||
|
||||
/// The attack indicator to draw (the `attackIndicator` option: 0 off,
|
||||
/// 1 crosshair, 2 hotbar). Without a crosshair, the crosshair gauge moves to
|
||||
/// the hotbar, as in vanilla's hotbar setting.
|
||||
pub fn attack_indicator(setting: u8, crosshair: bool) -> u8 {
|
||||
if setting == 1 && !crosshair {
|
||||
2
|
||||
} else {
|
||||
setting
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit ray from the eye through `point` (normalized surface coordinates,
|
||||
/// y down) for a perspective view with vertical `fov` in degrees. The view
|
||||
/// itself does not turn: a tap acts where it lands.
|
||||
pub fn screen_direction(
|
||||
forward: [f64; 3],
|
||||
up: [f64; 3],
|
||||
point: [f32; 2],
|
||||
aspect: f64,
|
||||
fov: f64,
|
||||
) -> [f64; 3] {
|
||||
// forward x up: screen right in the game's coordinates.
|
||||
let right = [
|
||||
forward[1] * up[2] - forward[2] * up[1],
|
||||
forward[2] * up[0] - forward[0] * up[2],
|
||||
forward[0] * up[1] - forward[1] * up[0],
|
||||
];
|
||||
let t = (fov.to_radians() / 2.0).tan();
|
||||
let x = (f64::from(point[0]) * 2.0 - 1.0) * aspect * t;
|
||||
let y = (1.0 - f64::from(point[1]) * 2.0) * t;
|
||||
let ray = std::array::from_fn::<_, 3, _>(|i| forward[i] + right[i] * x + up[i] * y);
|
||||
let length = ray.iter().map(|v| v * v).sum::<f64>().sqrt();
|
||||
ray.map(|v| v / length)
|
||||
}
|
||||
|
||||
/// The hotbar slot under `point`, in GUI pixels on a `width` x `height` GUI:
|
||||
/// the nine 20-pixel slots of the 22-pixel bar centred at the bottom.
|
||||
pub fn hotbar_slot(width: f32, height: f32, point: [f32; 2]) -> Option<usize> {
|
||||
let x = point[0] - (width / 2.0 - 90.0);
|
||||
(point[1] >= height - 22.0 && point[1] < height && (0.0..180.0).contains(&x))
|
||||
.then(|| (x / 20.0) as usize)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn camera_turns_by_physical_distance_on_both_axes() {
|
||||
let mut settings = Settings::default();
|
||||
for (w, h) in [(2400, 1080), (1080, 2400)] {
|
||||
// 100 pixels right and 100 pixels down.
|
||||
let drag = [100.0 / w as f32, 100.0 / h as f32];
|
||||
let turn = camera_delta(drag, w, h, &settings);
|
||||
assert!((turn[0] - turn[1]).abs() < 0.0001);
|
||||
settings.sensitivity_vertical = 0.5;
|
||||
let tuned = camera_delta(drag, w, h, &settings);
|
||||
assert!((tuned[0] - tuned[1] * 2.0).abs() < 0.0001);
|
||||
settings.sensitivity_vertical = 1.0;
|
||||
}
|
||||
let full = camera_delta([1.0, 0.0], 2400, 1080, &settings);
|
||||
assert!((full[0] - 180.0).abs() < 0.0001);
|
||||
settings.sensitivity = 2.0;
|
||||
let fast = camera_delta([0.5, 0.0], 2400, 1080, &settings);
|
||||
assert!((fast[0] - 180.0).abs() < 0.0001);
|
||||
// A zero-sized surface turns nothing instead of dividing by zero.
|
||||
assert_eq!(camera_delta([0.5, 0.5], 0, 0, &settings), [0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_tools_act_on_tap_but_projectiles_aim() {
|
||||
for id in [
|
||||
"bone_meal",
|
||||
"flint_and_steel",
|
||||
"fire_charge",
|
||||
"water_bucket",
|
||||
"lava_bucket",
|
||||
"powder_snow_bucket",
|
||||
"bucket",
|
||||
"honeycomb",
|
||||
"glass_bottle",
|
||||
] {
|
||||
assert!(item_uses_on_tap(&format!("minecraft:{id}")), "{id}");
|
||||
}
|
||||
assert!(!item_uses_on_tap("minecraft:milk_bucket"));
|
||||
assert!(!item_uses_on_tap("minecraft:apple"));
|
||||
for id in [
|
||||
"bow",
|
||||
"crossbow",
|
||||
"snowball",
|
||||
"egg",
|
||||
"wind_charge",
|
||||
"trident",
|
||||
] {
|
||||
let id = format!("minecraft:{id}");
|
||||
assert!(!item_uses_on_tap(&id), "{id}");
|
||||
assert!(projectile_crosshair(&id), "{id}");
|
||||
}
|
||||
assert!(!projectile_crosshair("minecraft:firework_rocket"));
|
||||
assert!(!projectile_crosshair("minecraft:stone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_placers_act_on_tap() {
|
||||
for id in [
|
||||
"oak_boat",
|
||||
"bamboo_chest_raft",
|
||||
"villager_spawn_egg",
|
||||
"minecart",
|
||||
"chest_minecart",
|
||||
"armor_stand",
|
||||
"item_frame",
|
||||
"glow_item_frame",
|
||||
"painting",
|
||||
"end_crystal",
|
||||
] {
|
||||
let id = format!("minecraft:{id}");
|
||||
assert!(item_places_entity_on_tap(&id), "{id}");
|
||||
assert!(item_uses_on_tap(&id), "{id}");
|
||||
}
|
||||
for id in [
|
||||
"egg",
|
||||
"snowball",
|
||||
"wind_charge",
|
||||
"firework_rocket",
|
||||
"fishing_rod",
|
||||
"apple",
|
||||
"wheat_seeds",
|
||||
"diamond_helmet",
|
||||
"bow",
|
||||
] {
|
||||
assert!(
|
||||
!item_places_entity_on_tap(&format!("minecraft:{id}")),
|
||||
"{id}"
|
||||
);
|
||||
}
|
||||
// Only vanilla ids: a modded item that happens to end in `_boat` is left alone.
|
||||
assert!(!item_places_entity_on_tap("example:spell_boat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attack_gauge_moves_to_the_hotbar_without_a_crosshair() {
|
||||
assert_eq!(attack_indicator(0, false), 0);
|
||||
assert_eq!(attack_indicator(1, false), 2);
|
||||
assert_eq!(attack_indicator(1, true), 1);
|
||||
assert_eq!(attack_indicator(2, false), 2);
|
||||
assert_eq!(attack_indicator(2, true), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tap_ray_goes_through_the_point_without_turning_the_view() {
|
||||
let forward = [0.0, 0.0, 1.0];
|
||||
let up = [0.0, 1.0, 0.0];
|
||||
assert_eq!(
|
||||
screen_direction(forward, up, [0.5, 0.5], 2.0, 90.0),
|
||||
forward
|
||||
);
|
||||
let ray = screen_direction(forward, up, [0.75, 0.25], 2.0, 90.0);
|
||||
// Right of centre is -x for this view; above centre is +y.
|
||||
assert!(ray[0] < 0.0 && ray[1] > 0.0 && ray[2] > 0.0);
|
||||
assert!((ray.iter().map(|x| x * x).sum::<f64>() - 1.0).abs() < 1e-10);
|
||||
// The top edge at a 90° vertical fov is 45° up.
|
||||
let top = screen_direction(forward, up, [0.5, 0.0], 2.0, 90.0);
|
||||
assert!((top[1] - top[2]).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotbar_has_nine_slots_and_nothing_beside_it() {
|
||||
for i in 0..9 {
|
||||
assert_eq!(
|
||||
hotbar_slot(427.0, 240.0, [133.5 + i as f32 * 20.0, 230.0]),
|
||||
Some(i)
|
||||
);
|
||||
}
|
||||
assert_eq!(hotbar_slot(427.0, 240.0, [320.0, 230.0]), None);
|
||||
assert_eq!(hotbar_slot(427.0, 240.0, [100.0, 230.0]), None);
|
||||
// Above the bar is the world, not a slot.
|
||||
assert_eq!(hotbar_slot(427.0, 240.0, [213.5, 210.0]), None);
|
||||
}
|
||||
}
|
||||
@@ -168,6 +168,12 @@ pub struct Settings {
|
||||
pub button_visibility: [u8; CONTROLS],
|
||||
/// One row of placements per [`Mode`], indexed by [`Control`].
|
||||
pub layouts: [[Placement; CONTROLS]; 3],
|
||||
/// Camera turn speed for look drags; scales both axes.
|
||||
pub sensitivity: f32,
|
||||
/// Extra scale on the yaw (left and right) turn.
|
||||
pub sensitivity_horizontal: f32,
|
||||
/// Extra scale on the pitch (up and down) turn.
|
||||
pub sensitivity_vertical: f32,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -203,6 +209,9 @@ impl Default for Settings {
|
||||
shield_button: true,
|
||||
button_visibility: [FOLLOW_SCHEME; CONTROLS],
|
||||
layouts,
|
||||
sensitivity: 1.0,
|
||||
sensitivity_horizontal: 1.0,
|
||||
sensitivity_vertical: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,6 +247,13 @@ impl Settings {
|
||||
for p in self.layouts.iter_mut().flatten() {
|
||||
p.sanitize();
|
||||
}
|
||||
for v in [
|
||||
&mut self.sensitivity,
|
||||
&mut self.sensitivity_horizontal,
|
||||
&mut self.sensitivity_vertical,
|
||||
] {
|
||||
*v = finite_or(*v, 1.0).clamp(0.1, 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether attack and use act on the crosshair rather than on a tap.
|
||||
@@ -385,6 +401,28 @@ mod tests {
|
||||
assert_eq!(p.opacity, 0.65);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitivity_stays_finite_and_in_range() {
|
||||
let mut s = Settings {
|
||||
sensitivity: f32::INFINITY,
|
||||
sensitivity_horizontal: 9.0,
|
||||
sensitivity_vertical: 0.0,
|
||||
..Settings::default()
|
||||
};
|
||||
s.sanitize();
|
||||
assert_eq!(
|
||||
[
|
||||
s.sensitivity,
|
||||
s.sensitivity_horizontal,
|
||||
s.sensitivity_vertical
|
||||
],
|
||||
[1.0, 3.0, 0.1]
|
||||
);
|
||||
// Files saved before these options existed keep the defaults.
|
||||
let read: Settings = serde_json::from_str(r#"{"mode":"tap"}"#).unwrap();
|
||||
assert_eq!(read.sensitivity, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_hands_mirror_and_dpad_can_swap_jump_and_sneak() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -108,6 +108,9 @@ pub struct World {
|
||||
pub inhabited: FxHashMap<(i32, i32), i64>,
|
||||
pub game_time: i64,
|
||||
pub day_time: i64,
|
||||
/// The End's world clock (`WorldClocks.THE_END`, the End sky flash's), saved apart from
|
||||
/// the game time and, like every clock, still while `advance_time` is off.
|
||||
pub end_clock: i64,
|
||||
pub weather: Weather,
|
||||
/// Every block position written since the log was last drained, kept
|
||||
/// only while the world is open to the network (see `lan`).
|
||||
@@ -238,6 +241,7 @@ impl World {
|
||||
inhabited: FxHashMap::default(),
|
||||
game_time: 0,
|
||||
day_time: 1000,
|
||||
end_clock: 0,
|
||||
weather: Weather::default(),
|
||||
block_log: None,
|
||||
block_log_held: Vec::new(),
|
||||
|
||||
@@ -1,29 +1,137 @@
|
||||
{
|
||||
"horizon.extras.header.world": "Wêreld",
|
||||
"horizon.extras.pregen": "Chunk voorafgenerering",
|
||||
"horizon.extras.pregen.tooltip": "Aktiveer /pregen in jou enkelspeler- en LAN-wêrelde. Met cheat aktief, gebruik /pregen start om 'n area te genereer voordat jy dit verken. Aktiveer nie die opdrag op bedieners waarby jy aansluit nie.",
|
||||
"horizon.options.controller": "Beheerder",
|
||||
"horizon.options.three_d_skin_layers": "3D-velae",
|
||||
"horizon.options.vibration": "Vibrasie",
|
||||
"horizon.options.play_in_background": "Speel in agtergrond",
|
||||
"horizon.options.adaptive_triggers": "Aanpasbare snellers",
|
||||
"horizon.options.gameplay_guide": "Spelgids",
|
||||
"horizon.options.gameplay_guide.basic": "Basies",
|
||||
"horizon.options.gameplay_guide.full": "Vol",
|
||||
"horizon.options.gameplay_guide.off": "Af",
|
||||
"horizon.controller.guide.use": "Gebruik",
|
||||
"horizon.controller.guide.open": "Maak oop",
|
||||
"horizon.controller.guide.ride": "Ry",
|
||||
"horizon.controller.guide.trade": "Handel",
|
||||
"horizon.controller.guide.feed": "Voer",
|
||||
"horizon.controller.guide.place": "Plaas",
|
||||
"horizon.controller.guide.eat": "Eet",
|
||||
"horizon.controller.guide.drink": "Drink",
|
||||
"horizon.controller.guide.shoot": "Skiet",
|
||||
"horizon.controller.guide.load": "Laai",
|
||||
"horizon.controller.guide.throw": "Gooi",
|
||||
"horizon.controller.guide.raise": "Lig",
|
||||
"horizon.controller.guide.equip": "Toerus",
|
||||
"horizon.controller.guide.fish": "Visvang",
|
||||
"horizon.controller.guide.zoom": "Zoem",
|
||||
"horizon.controller.guide.boost": "Versnel",
|
||||
"horizon.controller.guide.break": "Breek",
|
||||
"horizon.controller.guide.attack": "Aanval",
|
||||
"horizon.controller.guide.spectate": "Kyk",
|
||||
"horizon.controller.guide.spectator_menu": "Toeskouermenu",
|
||||
"horizon.controller.guide.release_shoot": "Los om te skiet",
|
||||
"horizon.controller.guide.release_throw": "Los om te gooi",
|
||||
"horizon.controller.guide.release_stop": "Los om te stop",
|
||||
"horizon.controller.guide.fly": "Vlieg",
|
||||
"horizon.controller.guide.fly_up": "Vlieg op",
|
||||
"horizon.controller.guide.fly_down": "Vlieg af",
|
||||
"horizon.controller.guide.swim_up": "Swem op",
|
||||
"horizon.controller.guide.swim_down": "Swem af",
|
||||
"horizon.controller.guide.glide": "Sweef",
|
||||
"horizon.controller.guide.dismount": "Klim af",
|
||||
"horizon.controller.guide.stand": "Staan op",
|
||||
"horizon.controller.guide.drop": "Laat item val",
|
||||
"horizon.controller.guide.swap": "Ruil hande",
|
||||
"horizon.controller.guide.take": "Neem",
|
||||
"horizon.controller.guide.take_half": "Neem helfte",
|
||||
"horizon.controller.guide.put": "Sit neer",
|
||||
"horizon.controller.guide.put_one": "Sit een",
|
||||
"horizon.controller.guide.swap_stack": "Ruil",
|
||||
"horizon.controller.guide.quick_move": "Vinnige skuif",
|
||||
"horizon.controller.guide.drop_stack": "Laat stapel val",
|
||||
"horizon.controller.guide.place_recipe": "Plaas resep",
|
||||
"horizon.controller.guide.place_recipe_all": "Plaas alles",
|
||||
"horizon.controller.guide.alternatives": "Alternatiewe",
|
||||
"horizon.controller.guide.select": "Kies",
|
||||
"horizon.controller.guide.close": "Maak toe",
|
||||
"horizon.controller.guide.pages": "Bladsye",
|
||||
"horizon.controller.guide.categories": "Kategorieë",
|
||||
"horizon.controller.guide.tabs": "Oortjies",
|
||||
"horizon.options.lightbar": "Ligbalk",
|
||||
"horizon.options.look_sensitivity": "Kyksensitiwiteit",
|
||||
"horizon.options.invert_x": "Omgekeerde kyk X",
|
||||
"horizon.options.invert_y": "Omgekeerde kyk Y",
|
||||
"horizon.options.craft_on_servers": "Vinnige vervaardiging op bedieners",
|
||||
"horizon.controller.direct_craft": "Vinnige vervaardiging",
|
||||
"horizon.options.sneak_button": "Sluipknoppie",
|
||||
"horizon.options.move_deadzone": "Bewegingsdooiesone",
|
||||
"horizon.options.look_deadzone": "Kykdooiesone",
|
||||
"horizon.options.trigger_threshold": "Snellerdrempel",
|
||||
"horizon.options.button_glyphs": "Knoppie Glyphs",
|
||||
"horizon.options.on_screen_keyboard": "Sleutelbord op die skerm",
|
||||
"horizon.options.chat_delay": "Chat Vertraging",
|
||||
"horizon.options.lightbar.health": "Gesondheid",
|
||||
"horizon.options.lightbar.dimension": "Dimensie",
|
||||
"horizon.options.glyphs.auto": "Outo",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.nintendo_buttons": "Nintendo-knoppies",
|
||||
"horizon.options.nintendo_buttons.position": "Volgens posisie",
|
||||
"horizon.options.nintendo_buttons.letter": "Volgens letter",
|
||||
"horizon.options.keyboard.auto": "Outo",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Hierdie kenmerk is nog nie in Horizon beskikbaar nie.",
|
||||
"horizon.options.search": "Soek instellings...",
|
||||
"horizon.studio.record.usage": "Gebruik: /studio record <start [name]|stop|status>",
|
||||
"horizon.studio.record.started": "Studio-opname begin: %s",
|
||||
"horizon.studio.record.saved": "Studio-opname gestoor: %s",
|
||||
"horizon.studio.record.error": "Studio-opnamefout: %s",
|
||||
"horizon.studio.record.status.active": "Studio-opname aktief — %s s, %s pakkette, %s KB (%s)",
|
||||
"horizon.studio.record.status.inactive": "Studio-opname onaktief",
|
||||
"horizon.studio.replay.usage": "Gebruik: /studio replay <open PATH|pause|resume|speed MULTIPLIER|seek MS|status|close>",
|
||||
"horizon.studio.replay.open.usage": "Gebruik: /studio replay open <PATH>",
|
||||
"horizon.studio.replay.opened": "Studio-herhaling oopgemaak: %s (%s s, %s pakkette)",
|
||||
"horizon.studio.replay.open_error": "Kon nie herhaling oopmaak nie: %s",
|
||||
"horizon.studio.replay.paused": "Studio-herhaling gepouseer",
|
||||
"horizon.studio.replay.resumed": "Studio-herhaling hervat",
|
||||
"horizon.studio.replay.inactive": "Geen aktiewe Studio-herhaling nie",
|
||||
"horizon.studio.replay.speed.usage": "Gebruik: /studio replay speed <MULTIPLIER>",
|
||||
"horizon.studio.replay.speed.set": "Studio-herhalingspoed op %s× gestel",
|
||||
"horizon.studio.replay.speed.invalid": "Ongeldige herhalingspoed",
|
||||
"horizon.studio.replay.seek.usage": "Gebruik: /studio replay seek <MILLISECONDS>",
|
||||
"horizon.studio.replay.seeked": "Studio-herhaling verskuif na %s ms (%s s)",
|
||||
"horizon.studio.replay.seek.error": "Kon nie in die herhaling spring nie: %s",
|
||||
"horizon.studio.replay.seek.backward_requires_standalone": "Terugspoel is slegs vir 'n selfstandige herhaling beskikbaar.",
|
||||
"horizon.studio.replay.seek.invalid": "Ongeldige herhalingsposisie",
|
||||
"horizon.studio.replay.status": "Studio-herhaling: %s / %s ms (%s%%), spoed %s×, gepouseer %s, klaar %s",
|
||||
"horizon.studio.replay.closed": "Studio-herhaling toegemaak",
|
||||
"horizon.studio.freecam.enabled": "Studio-vrye kamera aktief. Gebruik WASD om te beweeg, Space en Shift om op of af te gaan, en Control om vinniger te beweeg.",
|
||||
"horizon.studio.freecam.disabled": "Studio-vrye kamera afgeskakel",
|
||||
"horizon.studio.freecam.unavailable": "Studio-vrye kamera kon nie 'n spelerkamera vind nie",
|
||||
"horizon.studio.freecam.replay_required": "Studio-vrye kamera is beskikbaar tydens herhalingsweergawe",
|
||||
"horizon.studio.freecam.usage": "Gebruik: /studio freecam <on|off>",
|
||||
"horizon.studio.keyframe.added": "Kamera-sleutelraam by %s ms bygevoeg. Die pad word gestoor wanneer jy die herhaling toemaak.",
|
||||
"horizon.studio.keyframe.cleared": "Kamerapad skoongemaak. Die verandering word gestoor wanneer jy die herhaling toemaak.",
|
||||
"horizon.studio.keyframe.removed": "Kamera-sleutelraam %s verwyder. Die verandering word gestoor wanneer jy die herhaling toemaak.",
|
||||
"horizon.studio.keyframe.save_error": "Kon nie die kamerapad stoor nie: %s",
|
||||
"horizon.studio.keyframe.remove_usage": "Gebruik: /studio keyframe remove <INDEX>",
|
||||
"horizon.studio.keyframe.index_invalid": "Kamera-sleutelraamindeks is buite bereik",
|
||||
"horizon.studio.keyframe.usage": "Gebruik: /studio keyframe <add|remove INDEX|clear>",
|
||||
"horizon.studio.path.locked": "Studio-kamerapad-weergawe aktief",
|
||||
"horizon.studio.path.unlocked": "Studio-kamerapad-weergawe gestop",
|
||||
"horizon.studio.path.usage": "Gebruik: /studio path <lock|unlock> (voeg eers ten minste een sleutelraam by)",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Shader Pakke",
|
||||
"horizon.pages.shader_options": "Shader Opsies",
|
||||
"horizon.pages.controller": "Beheerderinstellings",
|
||||
"horizon.pages.controller_binds": "Kontroleerder bind",
|
||||
"horizon.pages.extras": "Horizon-ekstras",
|
||||
"horizon.pages.settings": "Horizon-instellings",
|
||||
"horizon.pages.studio": "Herhalingstudio",
|
||||
"horizon.pages.agent": "KI Agent",
|
||||
"horizon.split.leave": "Los",
|
||||
"horizon.pages.split_settings": "Verdeelskerm-instellings",
|
||||
@@ -44,6 +152,7 @@
|
||||
"horizon.pages.music.empty": "Geen musieksnitte nie: die speletjie se klanke is nie gelaai nie.",
|
||||
"horizon.pages.music.tooltip": "Die ingeboude snitte, volgens situasie: speel nou een, of skakel enige af sodat die speletjie ophou om dit te kies.",
|
||||
"horizon.pages.settings.tooltip": "Horizon se eie instellings: grafika, beheerders, ekstras en mods.",
|
||||
"horizon.pages.studio.tooltip": "Blaai deur gestoorde opnames en hul metadata.",
|
||||
"horizon.pages.graphics.tooltip": "Grafika, eenvoudig: elke instelling sê wat dit kos.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Elke grafiese instelling, Horizon s'n ingesluit, met wat elkeen kos.",
|
||||
"horizon.pages.controller.tooltip": "Beheerders: hul knoppies, stokke, vibrasie en spyskaartnavigasie.",
|
||||
@@ -64,6 +173,14 @@
|
||||
"horizon.pages.open.horizon_graphics": "Horizon-grafika...",
|
||||
"horizon.pages.open.controller_binds": "Beheerder bind...",
|
||||
"horizon.pages.open.horizon_settings": "Horizon-instellings...",
|
||||
"horizon.pages.open.studio": "Herhalingstudio...",
|
||||
"horizon.studio.saved_recordings": "Gestoorde opnames",
|
||||
"horizon.studio.empty": "Geen opnames in die opnames-lêergids gevind nie.",
|
||||
"horizon.studio.empty_hint": "Gebruik /studio record start <name> in 'n wêreld, of sleep .mcpr-lêers daar in.",
|
||||
"horizon.studio.open_folder": "Maak opnames oop",
|
||||
"horizon.studio.open_replay": "Speel herhaling",
|
||||
"horizon.studio.refresh": "Verfris",
|
||||
"horizon.studio.unknown_server": "Onbekend",
|
||||
"horizon.pages.open.music": "Musiek...",
|
||||
"horizon.mods.empty": "Geen mods: plaas .hmod-lêers in die horizon-mods-lêergids",
|
||||
"horizon.mods.stopped": " (Gestop)",
|
||||
@@ -72,6 +189,28 @@
|
||||
"horizon.mods.settings": "Instellings",
|
||||
"horizon.mods.in_world": " (los die wêreld om te verander)",
|
||||
"horizon.mods.open_folder": "Maak Mod-lêergids oop",
|
||||
"horizon.mods.browse": "Blaai deur mods...",
|
||||
"horizon.mods.browse.title": "Blaai deur mods",
|
||||
"horizon.mods.browse.search": "Soek mods...",
|
||||
"horizon.mods.browse.project": "Mod",
|
||||
"horizon.mods.browse.loading": "Lees Horizon se modregister...",
|
||||
"horizon.mods.browse.failed": "Kon nie die modregister lees nie: %s",
|
||||
"horizon.mods.browse.none": "Geen mod gevind nie",
|
||||
"horizon.mods.browse.has_update": " (opdatering)",
|
||||
"horizon.mods.browse.is_installed": " (geïnstalleer)",
|
||||
"horizon.mods.browse.by": "deur %s",
|
||||
"horizon.mods.browse.installing": "Installeer tans...",
|
||||
"horizon.mods.browse.install": "Installeer",
|
||||
"horizon.mods.browse.update": "Dateer op",
|
||||
"horizon.mods.browse.installed": "Geïnstalleer",
|
||||
"horizon.mods.browse.open_page": "Maak bladsy oop",
|
||||
"horizon.mods.browse.enabled": "Mod",
|
||||
"horizon.mods.browse.changelog": "Veranderingslys",
|
||||
"horizon.mods.browse.done": "%s %s is geïnstalleer en aan.",
|
||||
"horizon.mods.browse.install_failed": "Kon dit nie installeer nie: %s",
|
||||
"horizon.mods.switch.worldgen": "verander wêreldgenerering",
|
||||
"horizon.mods.switch.registries": "voeg blokke, items of ander registerinskrywings by",
|
||||
"horizon.mods.switch.restart": "Herbegin die speletjie om %s toe te pas: hulle voeg blokke, items of wêreldgenerering by.",
|
||||
"horizon.shader_options.profile": "Profiel",
|
||||
"horizon.agent.copy_details": "Kopieer verbindingbesonderhede",
|
||||
"horizon.multiplayer.join": "Sluit aan",
|
||||
@@ -83,7 +222,9 @@
|
||||
"LOD Distance": "LOD Afstand",
|
||||
"Far Detail": "Verre Detail",
|
||||
"Cave Culling": "Grotuitdunning",
|
||||
"GPU Culling": "GPU-uitdunning",
|
||||
"Entity Culling": "Entiteit Uitdunning",
|
||||
"Chunk Updates": "Chunk-opdaterings",
|
||||
"Upload Budget": "Laai begroting op",
|
||||
"Sky": "Hemel",
|
||||
"Fog": "Mis",
|
||||
@@ -93,11 +234,17 @@
|
||||
"Frame Pacing": "Raamtempo",
|
||||
"Worker Threads": "Werkerdrade",
|
||||
"Resolution Scale": "Resolusieskaal",
|
||||
"Anti-aliasing": "Teen-aliassing",
|
||||
"Upscaling": "Opskaling",
|
||||
"Upscaling Sharpness": "Opskalingskerpte",
|
||||
"Paper White": "Papier Wit",
|
||||
"Peak Brightness": "Piek helderheid",
|
||||
"Black Level": "Swart vlak",
|
||||
"UI Brightness": "UI Helderheid",
|
||||
"HDR Lighting": "HDR-beligting",
|
||||
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Ligbronne, die son en sonlig skyn verby wit. Af: net soos SDR.",
|
||||
"Smooth Gradients": "Gladde gradiënte",
|
||||
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "16-bis raam: donker lug en skaduwees band nie. 'n Bietjie meer VRAM. Volgende begin.",
|
||||
"Battery Mode": "Batterymodus",
|
||||
"Graphics API": "Grafiese API",
|
||||
"Translucent Batching": "Deurskynende Batching",
|
||||
@@ -111,6 +258,7 @@
|
||||
"Performance": "Prestasie",
|
||||
"Balanced": "Gebalanseerd",
|
||||
"Quality": "Kwaliteit",
|
||||
"Ultra": "Ultra",
|
||||
"Ultra Performance": "Ultra prestasie",
|
||||
"Native": "Inheems",
|
||||
"Full": "Vol",
|
||||
@@ -121,18 +269,29 @@
|
||||
"FIFO": "EIEU",
|
||||
"Mailbox": "Posbus",
|
||||
"Immediate": "Onmiddellik",
|
||||
"Vulkan": "Vulkan",
|
||||
"DirectX 12": "DirectX 12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS en grafiek",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "Geen koste nie",
|
||||
"Low cost": "Lae koste",
|
||||
"Medium cost": "Medium koste",
|
||||
"High cost": "Hoë koste",
|
||||
"Saves time": "Spaar tyd",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "Grafika",
|
||||
"Done": "Klaar",
|
||||
"This feature is not yet available in Horizon.": "Hierdie kenmerk is nog nie in Horizon beskikbaar nie.",
|
||||
"Your display or system doesn't offer HDR output.": "Jou skerm of stelsel bied nie HDR-uitvoer nie.",
|
||||
"Turn HDR on to use it.": "Skakel HDR aan om dit te gebruik.",
|
||||
"Your GPU or graphics API doesn't support it.": "Jou GPU of grafiese API ondersteun dit nie.",
|
||||
"Not with software rendering, which runs it on the CPU.": "Nie met sagteware-weergawe nie, wat dit op die CPU laat loop.",
|
||||
"Out of video memory: off until the next start.": "Nie genoeg videogeheue nie: af tot die volgende begin.",
|
||||
"The graphics device was lost: off until the next start.": "Die grafiese toestel is verloor: af tot die volgende begin.",
|
||||
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Nog nie op DirectX 12 nie: probeer die Vulkan-grafiese API.",
|
||||
"%s chunks": "%s stukke",
|
||||
"%s per frame": "%s per raam",
|
||||
"%s MiB/frame": "%s MiB/raam",
|
||||
@@ -148,13 +307,15 @@
|
||||
"Size of menus and the HUD.": "Grootte van spyskaarte en die HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Stel die opsies hieronder saam. Om een te verander, maak dit Pasgemaak.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, vuur en sonlig skyn helderder as wit. Benodig 'n HDR-skerm.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Jou skerm is die donkerste swart. 0 vir OLED; LCD's gloei 'n bietjie.",
|
||||
"Lifts shadows onto your display's darkest black. 0: true black, no lift.": "Lig skaduwees na jou skerm se donkerste swart. 0: suiwer swart, geen ligting.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Hoe helder hoogtepunte word. Outo: die hoogtepunt wat jou stelsel rapporteer.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Hoe helder wit papier en spyskaarte lyk. Outo: jou lessenaar is wit.",
|
||||
"How bright white looks in the world. Auto: your desktop's white.": "Hoe helder wit in die wêreld lyk. Outo: jou lessenaar se wit.",
|
||||
"How bright the HUD and menus are, against paper white.": "Hoe helder die HUD en spyskaarte is, teenoor papierwit.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Hoe ver skare, gewasse en masjiene aanhou werk. SVE tyd.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Hoe ver weg skares en items getrek word, as 'n deel van normaal.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Slaan terrein oor wat agter geslote gedeeltes versteek is. Af kos baie.",
|
||||
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Dun nabygeleë terrein op die GPU uit: minder CPU by hoë tekenafstand, meer GPU.",
|
||||
"Auto: tries it where your CPU limits the frame, keeps it if frames get faster.": "Outo: probeer dit waar jou CPU die raam beperk, hou dit as rame vinniger word.",
|
||||
"Hides entities behind opaque blocks.": "Versteek entiteite agter ondeursigtige blokke.",
|
||||
"No entity occlusion culling.": "Geen entiteit-okklusion uitskot nie.",
|
||||
"CPU occlusion test runs this frame.": "SVE-okklusietoets loop hierdie raam.",
|
||||
@@ -178,6 +339,7 @@
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Wêreldpixels per skermpiksel. Onder 100% is vinniger op die GPU.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Maak gekartelde rande glad. FXAA: sagter. TAA: gladste, bewegende dinge kan loop.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Maak die wêreld kleiner en herbou dit met TAA. Vinniger, sagter.",
|
||||
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Verskerp die opgeskaalde beeld. Hoër: skerper rande, meer flikkering.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-uitset op 'n HDR-skerm: lawa, vuur en sonlig skyn helderder.",
|
||||
"How frames reach the screen while VSync is off.": "Hoe rame die skerm bereik terwyl VSync af is.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Doeltreffende slaap tot die volgende raam; Presiese draaie, soos vanielje.",
|
||||
@@ -197,6 +359,7 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "Raamtempo en raamtye oor die spel, met 'n grafiek.",
|
||||
"horizon.multiplayer.finding_friend": "Soek jou vriend se wêreld...",
|
||||
"horizon.multiplayer.not_signed_in": "Hierdie bediener is in aanlynmodus. Meld aan met jou Microsoft-rekening in die Horizon-lanseerder om daarby aan te sluit.",
|
||||
"horizon.realms.not_signed_in": "Meld aan met jou Microsoft-rekening in die Horizon-lanseerder om op Realms te speel.",
|
||||
"horizon.multiplayer.invalid_address": "Ongeldige bedieneradres: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Reguit gekoppel aan jou vriend...",
|
||||
"horizon.multiplayer.connected_friend_relay": "Gekoppel aan jou vriend deur die aflos...",
|
||||
@@ -226,6 +389,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "Helderder-as-wit uitset op 'n HDR-skerm.",
|
||||
"Results for \"%s\"": "Resultate vir \"%s\"",
|
||||
"No settings match.": "Geen instellings pas nie.",
|
||||
"horizon.plain.official": "Amptelike Horizon met lanseerder, mods en opdaterings: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Nie geaffilieer met Mojang of Microsoft nie",
|
||||
"horizon.extras.header.lighting": "Beligting",
|
||||
"horizon.extras.header.third_person": "Derde persoon",
|
||||
@@ -249,9 +413,12 @@
|
||||
"horizon.extras.no_ai": "Geen KI",
|
||||
"horizon.extras.agent_control": "Agent beheer",
|
||||
"horizon.extras.discord_presence": "Ryk teenwoordigheid op Discord",
|
||||
"horizon.extras.zoom": "Zoem",
|
||||
"horizon.extras.zoom_mod_install": "Zoem: installeer uit Mods",
|
||||
"horizon.extras.splitscreen": "Verdeelskerm",
|
||||
"horizon.extras.chat_animation": "Glad gesels",
|
||||
"horizon.extras.chat_indicator": "Boodskap aanwyser",
|
||||
"horizon.extras.wheel_cycles_list_settings.tooltip": "Die rolwiel oor 'n knoppie in 'n instellingslys verander sy waarde. Af, soos in vanielje, rol die wiel deur die lys.",
|
||||
"horizon.extras.wheel_cycles_settings": "Rolwiel verander instellings",
|
||||
"horizon.extras.pack_browser": "Hulpbronpakketblaaier",
|
||||
"horizon.extras.fullbright.tooltip": "Wys blokke en entiteite in volle lig, selfs in donker areas.",
|
||||
@@ -312,6 +479,7 @@
|
||||
"horizon.friends.stopped": "Het opgehou om die uitnodigingkode te deel.",
|
||||
"horizon.friends.short_code_changed": "Iemand het aanhou om 'n verkeerde kort kode te probeer, so dit het verander.",
|
||||
"horizon.friends.wrong_code": "Iemand het 'n verkeerde kode probeer.",
|
||||
"horizon.friends.relay_stopped": "Te veel verkeerde kort kodes, so die aflos is af totdat jy weer deel. Vriende kan steeds met die volle kode aansluit.",
|
||||
"horizon.friends.code_ready": "Jou uitnodigingskode is gereed: Speletjie-kieslys, Speel met vriende. Kopieer dit en stuur dit aan jou vriende.",
|
||||
"horizon.friends.share_failed": "Kon nie die wêreld deel nie: %s",
|
||||
"horizon.friends.finding_route": "Vind tans hoe vriende jou kan bereik …",
|
||||
@@ -325,11 +493,13 @@
|
||||
"horizon.extras.chat_indicator.tooltip": "Die gekleurde balk aan die linkerkant van 'n kletslyn.",
|
||||
"horizon.extras.pack_browser.tooltip": "Select Resource Packs kry 'n Voeg by-knoppie wat Modrinth deursoek en 'n pakket in jou pakke-lêergids installeer. Af, die speletjie kontak nooit Modrinth nie.",
|
||||
"horizon.extras.splitscreen.tooltip": "In 'n wêreld kan 'n ander beheerder as 'n nuwe speler aansluit: druk A wanneer dit vra, kies dan 'n profiel en waar om te speel.",
|
||||
"horizon.splitscreen.setup.monitor": "Monitor %s",
|
||||
"horizon.splitscreen.setup.monitor_unknown": "'n monitor",
|
||||
"horizon.splitscreen.setup.window": "Venster %s",
|
||||
"horizon.splitscreen.setup.new_window": "Nuwe venster",
|
||||
"horizon.splitscreen.setup.fill_monitor": "Vul %s",
|
||||
"horizon.splitscreen.setup.done": "Klaar",
|
||||
"horizon.splitscreen.setup.switch": "%s: %s",
|
||||
"horizon.splitscreen.setup.on": "Aan",
|
||||
"horizon.splitscreen.setup.off": "Af",
|
||||
"horizon.splitscreen.setup.player": "Speler %s",
|
||||
@@ -387,8 +557,12 @@
|
||||
"horizon.splitscreen.setup.help.give_up_pad": "Jou beheerder hou op om jou te bestuur en word aangebied om aan te sluit (A).",
|
||||
"horizon.splitscreen.setup.note.held_at": "%s tot %s: A laat val dit hier, B sit dit terug.",
|
||||
"horizon.splitscreen.setup.note.held": "%s opgetel: links/regs kies waar, A laat val, B sit dit terug.",
|
||||
"horizon.splitscreen.online_only": "Hierdie bediener is in aanlynmodus: vanlyn profiele kan nie daarby aansluit nie; elke speler het sy eie Minecraft-rekening nodig.",
|
||||
"horizon.splitscreen.own_account": "Hierdie bediener vereis elke speler se eie Minecraft-rekening",
|
||||
"horizon.splitscreen.local_only": "Ander spelers kan slegs met hul eie Minecraft-rekening by hierdie bediener aansluit: vanlyn profiele speel in jou wêreld, op LAN of op 'n bediener op jou plaaslike netwerk.",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Versteek modelle agter ondeursigtige blokke. Async kontroleer af-draad; entiteite merk.",
|
||||
"Block Entities": "Blok Entiteite",
|
||||
"Shulker Boxes": "Shulkerbokse",
|
||||
"Reuses settled boxes; lid movement stays animated.": "Hergebruik afgewikkelde bokse; dekselbeweging bly geanimeerd.",
|
||||
"Signs": "Tekens",
|
||||
"Reuses sign text until text, light, or font changes.": "Hergebruik tekenteks totdat teks, lig of lettertipe verander.",
|
||||
@@ -404,6 +578,8 @@
|
||||
"Reuses statue geometry until its block state changes.": "Hergebruik standbeeldgeometrie totdat die bloktoestand daarvan verander.",
|
||||
"Leaf Culling": "Blaaruitsny",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Elke blaar gesig. Vanielje se spoggerige blare.",
|
||||
"Opaque Ice": "Ondeursigtige ys",
|
||||
"Draws ice in the solid pass, without its see-through texture.": "Teken ys in die soliede fase, sonder sy deurskynende tekstuur.",
|
||||
"Distant Plant Dither": "Verre Plant Dither",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Hou fyn plante in hoë-resolusie-pakke om ver weg te vervaag.",
|
||||
"Screenshot Quality": "Skermkiekie Kwaliteit",
|
||||
@@ -411,7 +587,18 @@
|
||||
"Frame Generation": "Raam Generasie",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Toon 'n opgemaakte raam tussen elke twee regte: gladder, 'n bietjie meer vertraging.",
|
||||
"Shadow Cache": "Skadu-kas",
|
||||
"Shaders": "Skadus",
|
||||
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Skadupakke, Horizon s'n en dié in die shaders/-lêergids, en hul opsies.",
|
||||
"Shader packs": "Skadupakke",
|
||||
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "Vanielje se eie voorafinstelling: stel net vanielje se opsies, soos Minecraft dit doen.",
|
||||
"Fullscreen takes the display over, at the mode below.": "Volskerm neem die skerm oor, in die modus hieronder.",
|
||||
"The display mode exclusive fullscreen uses. Applies when you leave.": "Die skermmodus wat eksklusiewe volskerm gebruik. Word toegepas wanneer jy verlaat.",
|
||||
"Blurs the world behind menus.": "Vervaag die wêreld agter spyskaarte.",
|
||||
"How textures are sampled: sharper at a slant with Anisotropic.": "Hoe teksture gesampled word: skerper skuins met Anisotropies.",
|
||||
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "Hoe ver Anisotropiese filtering gaan. Hoër is skerper skuins.",
|
||||
"Only with Anisotropic texture filtering.": "Slegs met Anisotropiese tekstuurfiltrering.",
|
||||
"Shader Packs...": "Shader-pakke...",
|
||||
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "Kies 'n skadupak, Horizon s'n of een in die shaders/-lêergids, en stel sy opsies.",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Hergebruik die pakskadukaart wanneer son, uitsig en wêreld ooreenstem. Af by verstek.",
|
||||
"Find BlueMap": "Vind BlueMap",
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.": "Op bedieners, vind hul BlueMap-webkaart vir ver terrein. Sluit volgende aan.",
|
||||
@@ -419,6 +606,8 @@
|
||||
"How far around you BlueMap tiles are downloaded. Next join.": "Hoe ver om jou is BlueMap-teëls afgelaai. Sluit volgende aan.",
|
||||
"Synchronous": "Sinchronies",
|
||||
"Asynchronous": "Asinchronies",
|
||||
"Hidden Faces": "Versteekte vlakke",
|
||||
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Laat blaaroppervlakke agter twee blaarlae val: vinniger, dieselfde kroon.",
|
||||
"Solid": "Solied",
|
||||
"Solid Aggressive": "Stewige aggressiewe",
|
||||
"Hollow": "Hol",
|
||||
@@ -440,6 +629,10 @@
|
||||
"Auto (%s nits)": "Outo (%s nete)",
|
||||
"%.2f nits": "%.2f nete",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Versteek modelle agter ondeursigtige blokke. Entiteite-opdatering. Af met pasgemaakte pakke.",
|
||||
"Entity Culling Precheck": "Entiteit-uitdunning-voorafkontrole",
|
||||
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Voer die skare-AABB-voorafkontrole uit ná hierdie aantal gelaaide skare. Nul skakel dit af.",
|
||||
"%s mobs": "%s skares",
|
||||
"Only applies to asynchronous entity culling.": "Geld slegs vir asinchroniese entiteit-uitdunning.",
|
||||
"horizon.extras.repro_keys": "Opname kortpaaie",
|
||||
"horizon.extras.repro_keys.tooltip": "F3+R begin of stop 'n repro-opname en F3+E skryf sy bundel. Af by verstek: 'n opname kopieer die wêreld.",
|
||||
"horizon.agent.record_session": "Begin opnamesessie",
|
||||
@@ -448,8 +641,20 @@
|
||||
"horizon.repro.started": "Opname het begin; F3+R stop dit en skryf die bundel.",
|
||||
"horizon.repro.stopped": "Opname gestop: %s",
|
||||
"horizon.repro.bundle": "Repro-bundel: %s",
|
||||
"horizon.repro.error": "Repro: %s",
|
||||
"horizon.repro.no_recording": "Daar is nog geen opname om uit te voer nie.",
|
||||
"horizon.repro.replay_started": "Herhaling het begin: %s",
|
||||
"horizon.splitscreen.profiles.offline": " (Vanlyn)",
|
||||
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
|
||||
"horizon.splitscreen.profiles.sign_in": "Meld weer aan in die lanseerder",
|
||||
"horizon.splitscreen.profiles.online": "Microsoft aanlyn",
|
||||
"horizon.splitscreen.account_unavailable": "%s is vanlyn. Meld weer aan in die lanseerder en herbegin dan die speletjie.",
|
||||
"horizon.splitscreen.account_expired": "%s se Microsoft-aanmelding het verval. Meld weer aan in die lanseerder en herbegin dan die speletjie.",
|
||||
"Particle Culling": "Deeltjie-uitdunning",
|
||||
"Draws every particle.": "Teken elke deeltjie.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "Slaan van-skerm, subpiksel- en terreinversteekte deeltjies oor.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Slaan van-skerm, subpiksel- en terreinversteekte deeltjies oor met minder werk op die tekenraad.",
|
||||
"horizon.pack.add": "+",
|
||||
"horizon.pack.addInfo": "Voeg 'n hulpbronpakket van Modrinth by",
|
||||
"horizon.pack.addWorking": "Vra vir Modrinth...",
|
||||
"horizon.pack.addNone": "Geen hulpbronpakke gevind nie",
|
||||
@@ -459,13 +664,32 @@
|
||||
"horizon.pack.installed": "Geïnstalleer",
|
||||
"horizon.pack.search": "Soek",
|
||||
"horizon.pack.searchHint": "Soek Modrinth...",
|
||||
"horizon.pack.filters": "Filters",
|
||||
"horizon.pack.filter.realistic": "Realisties",
|
||||
"horizon.pack.filter.simplistic": "Simplistiese",
|
||||
"horizon.pack.filter.vanillaLike": "Vanielje-agtig",
|
||||
"horizon.pack.filter.x16": "16x",
|
||||
"horizon.pack.filter.x32": "32x",
|
||||
"horizon.pack.filter.x64": "64x",
|
||||
"horizon.pack.addFailed": "Die pakkie kon nie bygevoeg word nie: %s",
|
||||
"horizon.pack.options": "Pakketopsies",
|
||||
"horizon.pack.optionsInfo": "Opsies van hierdie pakket",
|
||||
"horizon.pack.options.title": "Pakketopsies",
|
||||
"horizon.pack.options.general": "Algemeen",
|
||||
"horizon.pack.options.presets": "Voorafinstellings",
|
||||
"horizon.pack.options.presets.custom": "Pasgemaak",
|
||||
"horizon.pack.options.impact": "Prestasie-impak: %s",
|
||||
"horizon.pack.options.impact.low": "laag",
|
||||
"horizon.pack.options.impact.medium": "medium",
|
||||
"horizon.pack.options.impact.high": "hoog",
|
||||
"horizon.pack.options.impact.varies": "wissel",
|
||||
"horizon.extras.header.cosmetics": "Skoonheidsmiddels",
|
||||
"horizon.extras.cosmetics_mine": "My skoonheidsmiddels",
|
||||
"horizon.extras.cosmetics_mine.tooltip": "Versteek: elke Horizon-speler sien jou sonder jou skoonheidsmiddels, jy ingesluit. Dieselfde skakelaar as die lanseerder se klerekas.",
|
||||
"key.emote": "Emotewiel",
|
||||
"horizon.emote_wheel.title": "Emotes",
|
||||
"horizon.emote_wheel.empty": "Nog geen emotes nie",
|
||||
"horizon.emote_wheel.help": "Los of klik om te speel · 1–8 · Esc maak toe",
|
||||
"horizon.extras.cosmetics_others": "Ander spelers se skoonheidsmiddels",
|
||||
"horizon.extras.cosmetics_others.tooltip": "Wat ander Horizon-spelers dra: alles, net hul naamplaatjiestyle, of niks. Versteek, die speletjie vra nooit wat hulle dra nie.",
|
||||
"horizon.extras.cosmetics_hide_fx": "Versteek ander spelers se spore en auras",
|
||||
@@ -489,5 +713,349 @@
|
||||
"Pack settings": "Pakketopsies",
|
||||
"Built-in settings": "Ingeboude opsies",
|
||||
"%s settings": "%s-opsies",
|
||||
"Settings made by the pack's author: they change only this pack.": "Opsies deur die pakket se skepper: hulle verander net hierdie pakket."
|
||||
"Settings made by the pack's author: they change only this pack.": "Opsies deur die pakket se skepper: hulle verander net hierdie pakket.",
|
||||
"horizon.moderation.duration.minute": "%s minuut",
|
||||
"horizon.moderation.duration.minutes": "%s minute",
|
||||
"horizon.moderation.duration.hour": "%s uur",
|
||||
"horizon.moderation.duration.hours": "%s uur",
|
||||
"horizon.moderation.duration.day": "%s dag",
|
||||
"horizon.moderation.duration.days": "%s dae",
|
||||
"horizon.moderation.reason": "%1$s: %2$s",
|
||||
"horizon.moderation.mutelist.empty": "Daar is geen dempings nie",
|
||||
"horizon.moderation.mutelist.count": "Daar is %s demping(s):",
|
||||
"horizon.moderation.chat_filter": "die geselsfilter",
|
||||
"horizon.moderation.until_unmuted": "tot ontdemp",
|
||||
"horizon.moderation.remaining": "vir nog %s",
|
||||
"horizon.moderation.mutelist.entry": "%1$s is deur %2$s gedemp %3$s",
|
||||
"horizon.moderation.invalid_duration": "%s is nie 'n duur nie: 10m, 2h, 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <player> [duration] [reason]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <player> <duration> [reason]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <player>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
"horizon.moderation.for_duration": "vir %s",
|
||||
"horizon.moderation.muted": "%1$s gedemp %2$s",
|
||||
"horizon.moderation.unmuted": "%s ontdemp",
|
||||
"horizon.moderation.not_muted": "%s is nie gedemp nie",
|
||||
"horizon.tempban.usage": "/tempban <player> <duration> [reason]",
|
||||
"horizon.tempban.invalid_duration": "%s is nie 'n duur nie: 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Tydelike verbannings vereis 'n toegewyde bediener",
|
||||
"horizon.tempban.success": "%1$s vir %2$s, tot %3$s",
|
||||
"horizon.tempban.list.empty": "Daar is geen tydelike verbannings nie",
|
||||
"horizon.tempban.list.count": "Daar is %s tydelike banning(s):",
|
||||
"horizon.tempban.list.entry": "%1$s is deur %2$s verban %3$s (tot %4$s)",
|
||||
"horizon.options.pointer_speed": "Spyskaartwyser-spoed",
|
||||
"horizon.options.scroll_speed": "Spyskaartrolspoed",
|
||||
"horizon.options.repeat_delay": "Navigasie-herhalingsvertraging",
|
||||
"horizon.options.repeat_interval": "Navigasie-herhalingsinterval",
|
||||
"horizon.options.milliseconds": "%s ms",
|
||||
"horizon.extras.cosmetics_solo": "In enkelspeler",
|
||||
"horizon.extras.cosmetics_solo.tooltip": "Wys jou skoonheidsmiddels en jou naamplaatjie se kenteken ook in jou enkelspeler-wêrelde (jou naamplaatjie met Eie naamplaatjie). Af: net op bedieners.",
|
||||
"horizon.extras.horizon_marks": "Horizon-logo's",
|
||||
"horizon.extras.horizon_marks.tooltip": "Wys die Horizon-logo langs Horizon-spelers: 'n klein logo voor die name van ander spelers wat Horizon gebruik, bo hul koppe en in die spelerlys. Net Horizon-spelers sien dit: bedieners ontvang niks.",
|
||||
"horizon.extras.horizon_me": "Wys my as Horizon",
|
||||
"horizon.extras.horizon_me.tooltip": "Wys my as 'n Horizon-speler: ander Horizon-spelers sien die logo by jou naam. Aan, antwoord die diens enigeen wat oor jou vra dat jy die afgelope 3 uur Horizon gespeel het; af, rapporteer dit jou nooit nie.",
|
||||
"horizon.pages.pvp": "PvP & HUD",
|
||||
"horizon.pages.pvp.tooltip": "Visier, HUD en PvP-instellings, alles by verstek af. Niks hier wys wat vanielje nie wys nie.",
|
||||
"horizon.pages.open.pvp": "PvP & HUD...",
|
||||
"horizon.pvp.header.crosshair": "Visier",
|
||||
"horizon.pvp.crosshair": "Pasgemaakte visier",
|
||||
"horizon.pvp.crosshair_shape": "Vorm",
|
||||
"horizon.pvp.crosshair_size": "Grootte",
|
||||
"horizon.pvp.crosshair_gap": "Gaping",
|
||||
"horizon.pvp.crosshair_thickness": "Dikte",
|
||||
"horizon.pvp.crosshair_colour": "Kleur",
|
||||
"horizon.pvp.crosshair_outline": "Omlyn",
|
||||
"horizon.pvp.crosshair_dot": "Middelpunt",
|
||||
"horizon.pvp.crosshair_hover": "Kleur op entiteite",
|
||||
"horizon.pvp.crosshair.tooltip": "Teken jou eie visier in plaas van vanielje s'n. Die eerste instellings daarvan lyk soos vanielje s'n.",
|
||||
"horizon.pvp.crosshair_shape.tooltip": "'n Kruis, 'n T, 'n X, 'n sirkel, 'n vierkant of net 'n punt.",
|
||||
"horizon.pvp.crosshair_size.tooltip": "Die lengte van elke arm, in GUI-piksels.",
|
||||
"horizon.pvp.crosshair_gap.tooltip": "Die leë ruimte in die middel.",
|
||||
"horizon.pvp.crosshair_thickness.tooltip": "Hoe wyd elke lyn is.",
|
||||
"horizon.pvp.crosshair_colour.tooltip": "Omgekeerd wys die kleur daaronder omgekeerd, soos vanielje se visier doen.",
|
||||
"horizon.pvp.crosshair_outline.tooltip": "'n Dun swart rand, sodat dit op enige agtergrond wys.",
|
||||
"horizon.pvp.crosshair_dot.tooltip": "'n Punt in die middel van die visier.",
|
||||
"horizon.pvp.crosshair_hover.tooltip": "'n Ander kleur terwyl die visier oor 'n skare of 'n speler is wat jy kan slaan, soos vanielje se aanvalaanwyser dit weet.",
|
||||
"horizon.pvp.colour.inverted": "Omgekeerd",
|
||||
"horizon.pvp.colour.white": "Wit",
|
||||
"horizon.pvp.colour.red": "Rooi",
|
||||
"horizon.pvp.colour.orange": "Oranje",
|
||||
"horizon.pvp.colour.yellow": "Geel",
|
||||
"horizon.pvp.colour.green": "Groen",
|
||||
"horizon.pvp.colour.cyan": "Siaan",
|
||||
"horizon.pvp.colour.blue": "Blou",
|
||||
"horizon.pvp.colour.magenta": "Magenta",
|
||||
"horizon.pvp.colour.black": "Swart",
|
||||
"horizon.pvp.shape.cross": "Kruis",
|
||||
"horizon.pvp.shape.t": "T",
|
||||
"horizon.pvp.shape.x": "X",
|
||||
"horizon.pvp.shape.circle": "Sirkel",
|
||||
"horizon.pvp.shape.square": "Vierkant",
|
||||
"horizon.pvp.shape.dot": "Punt",
|
||||
"horizon.pvp.outline": "Pasgemaakte blokomlyn",
|
||||
"horizon.pvp.outline.tooltip": "Verander die omlyn rondom die blok waarna jy kyk.",
|
||||
"horizon.pvp.outline_colour": "Kleur",
|
||||
"horizon.pvp.outline_colour.tooltip": "Die omlyn se kleur. Swart teen 40% is vanielje s'n.",
|
||||
"horizon.pvp.outline_opacity": "Ondeursigtigheid",
|
||||
"horizon.pvp.outline_opacity.tooltip": "Hoe solied die omlyn is.",
|
||||
"horizon.pvp.outline_thickness": "Dikte",
|
||||
"horizon.pvp.outline_thickness.tooltip": "Die omlyn se wydte, in keer van vanielje s'n.",
|
||||
"horizon.pvp.outline_fill": "Vulling",
|
||||
"horizon.pvp.outline_fill.tooltip": "Kleur die vlakke van die blok waarna jy kyk, liggies.",
|
||||
"horizon.pvp.hit": "Pasgemaakte trefferkleur",
|
||||
"horizon.pvp.hit.tooltip": "Verander die kleur waarin skares en spelers flits wanneer hulle seerkry. Rooi teen 30% is vanielje s'n.",
|
||||
"horizon.pvp.hit_colour": "Kleur",
|
||||
"horizon.pvp.hit_colour.tooltip": "Die kleur waarin gewonde skares en spelers flits.",
|
||||
"horizon.pvp.hit_opacity": "Ondeursigtigheid",
|
||||
"horizon.pvp.hit_opacity.tooltip": "Hoeveel van die kleur hulle bedek.",
|
||||
"horizon.pvp.header.outline": "Blokomlyn",
|
||||
"horizon.pvp.header.hit": "Trefferkleur",
|
||||
"horizon.pvp.hud_armour": "Wapenrusting",
|
||||
"horizon.pvp.hud_armour.tooltip": "Die wapenrusting wat jy dra, kop eerste, met hoeveel gebruike elke stuk oor het.",
|
||||
"horizon.pvp.hud_armour_corner": "Wapenrustinghoek",
|
||||
"horizon.pvp.hud_armour_corner.tooltip": "Waar die wapenrusting wys.",
|
||||
"horizon.pvp.hud_armour_percent": "Duursaamheid in %",
|
||||
"horizon.pvp.hud_armour_percent.tooltip": "Gebruike oor as 'n persentasie van die stuk se maksimum in plaas van 'n getal.",
|
||||
"horizon.pvp.hud_effects": "Effekte",
|
||||
"horizon.pvp.hud_effects.tooltip": "Jou effekte met hul name, vlakke en tyd oor, in die plek van die ikone regs bo.",
|
||||
"horizon.pvp.hud_effects_corner": "Effektehoek",
|
||||
"horizon.pvp.hud_effects_corner.tooltip": "Waar die effekte wys.",
|
||||
"horizon.pvp.hud_held": "Getal van item in hand",
|
||||
"horizon.pvp.hud_held.tooltip": "Hoeveel van die item in jou hand jou voorraad het; pyle met 'n boog of kruisboog.",
|
||||
"horizon.pvp.hud_held_corner": "Hoek vir getal in hand",
|
||||
"horizon.pvp.hud_held_corner.tooltip": "Waar die getal wys.",
|
||||
"horizon.pvp.hud_background": "Widget-agtergronde",
|
||||
"horizon.pvp.hud_background.tooltip": "'n Donker blokkie agter elke widget.",
|
||||
"horizon.pvp.hud_opacity": "Agtergrondondeursigtigheid",
|
||||
"horizon.pvp.hud_opacity.tooltip": "Hoe donker die blokkies agter die widgets is.",
|
||||
"horizon.pvp.hud_text_colour": "Tekskleur",
|
||||
"horizon.pvp.hud_text_colour.tooltip": "Die widgets se tekskleur. Duursaamheid behou sy groen tot rooi.",
|
||||
"horizon.pvp.hud_shadow": "Teksskaduwee",
|
||||
"horizon.pvp.hud_shadow.tooltip": "'n Skaduwee onder die widgets se teks, soos vanielje se HUD-teks het.",
|
||||
"horizon.pvp.header.hud": "HUD-widgets",
|
||||
"horizon.pvp.corner.top_left": "Links bo",
|
||||
"horizon.pvp.corner.top_right": "Regs bo",
|
||||
"horizon.pvp.corner.bottom_left": "Links onder",
|
||||
"horizon.pvp.corner.bottom_right": "Regs onder",
|
||||
"horizon.pvp.hud_ping": "Ping",
|
||||
"horizon.pvp.hud_ping.tooltip": "Die latensie wat die bediener vir jou rapporteer, in millisekondes. Slegs multispeler.",
|
||||
"horizon.pvp.hud_ping_corner": "Ping-hoek",
|
||||
"horizon.pvp.hud_ping_corner.tooltip": "Waar die ping wys.",
|
||||
"horizon.pvp.hud_coords": "Koördinate",
|
||||
"horizon.pvp.hud_coords.tooltip": "Jou blokposisie en die rigting waarin jy kyk, soos F3 dit wys. Versteek wanneer die wêreld of bediener F3 verminder.",
|
||||
"horizon.pvp.hud_coords_corner": "Koördinatehoek",
|
||||
"horizon.pvp.hud_coords_corner.tooltip": "Waar die koördinate wys.",
|
||||
"horizon.pvp.hud_server": "Bedieneradres",
|
||||
"horizon.pvp.hud_server.tooltip": "Die adres van die bediener waarop jy speel. Slegs multispeler.",
|
||||
"horizon.pvp.hud_server_corner": "Bedienerhoek",
|
||||
"horizon.pvp.hud_server_corner.tooltip": "Waar die adres wys.",
|
||||
"horizon.pvp.hud_combo": "Komboteller",
|
||||
"horizon.pvp.hud_combo.tooltip": "Treffers in 'n ry wat die bediener bevestig het, totdat jy seerkry of 3 sekondes sonder een verbygaan.",
|
||||
"horizon.pvp.hud_combo_corner": "Kombohoek",
|
||||
"horizon.pvp.hud_combo_corner.tooltip": "Waar die combo wys.",
|
||||
"horizon.pvp.hud.ping": "Ping: %s ms",
|
||||
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
|
||||
"horizon.pvp.hud.facing": "Rigting: %s",
|
||||
"horizon.pvp.hud.combo": "Combo: %s",
|
||||
"horizon.pvp.hud.direction.north": "Noord",
|
||||
"horizon.pvp.hud.direction.south": "Suid",
|
||||
"horizon.pvp.hud.direction.east": "Oos",
|
||||
"horizon.pvp.hud.direction.west": "Wes",
|
||||
"horizon.pvp.hud_fps": "FPS",
|
||||
"horizon.pvp.hud_fps.tooltip": "Rame wat in die laaste sekonde geteken is.",
|
||||
"horizon.pvp.hud_fps_corner": "FPS-hoek",
|
||||
"horizon.pvp.hud_fps_corner.tooltip": "Waar die FPS wys.",
|
||||
"horizon.pvp.hud_cps": "Klikke per sekonde",
|
||||
"horizon.pvp.hud_cps.tooltip": "Jou linker- en regterklikke in die laaste sekonde.",
|
||||
"horizon.pvp.hud_cps_corner": "CPS-hoek",
|
||||
"horizon.pvp.hud_cps_corner.tooltip": "Waar die klikke per sekonde wys.",
|
||||
"horizon.pvp.hud_keys": "Toetsaanslae",
|
||||
"horizon.pvp.hud_keys.tooltip": "Jou bewegingsleutels, muisknoppies en spring, verlig terwyl jy hulle hou, met klikke per sekonde.",
|
||||
"horizon.pvp.hud_keys_corner": "Toetsaanslaghoek",
|
||||
"horizon.pvp.hud_keys_corner.tooltip": "Waar die toetsaanslae wys.",
|
||||
"horizon.pvp.hud_clock": "Horlosie",
|
||||
"horizon.pvp.hud_clock.tooltip": "Jou rekenaar se tyd.",
|
||||
"horizon.pvp.hud_clock_corner": "Horlosiehoek",
|
||||
"horizon.pvp.hud_clock_corner.tooltip": "Waar die horlosie wys.",
|
||||
"horizon.pvp.hud_reach": "Bereik",
|
||||
"horizon.pvp.hud_reach.tooltip": "Hoe ver van jou oë jou laaste treffer geland het, vir 3 sekondes. Dit meet net: jou bereik bly vanielje s'n.",
|
||||
"horizon.pvp.hud_reach_corner": "Bereikhoek",
|
||||
"horizon.pvp.hud_reach_corner.tooltip": "Waar die bereik wys.",
|
||||
"horizon.pvp.hud_speed": "Spoed",
|
||||
"horizon.pvp.hud_speed.tooltip": "Hoe vinnig jy oor die grond beweeg, in blokke per sekonde.",
|
||||
"horizon.pvp.hud_speed_corner": "Spoedhoek",
|
||||
"horizon.pvp.hud_speed_corner.tooltip": "Waar die spoed wys.",
|
||||
"horizon.pvp.hud.fps": "%s FPS",
|
||||
"horizon.pvp.hud.cps": "CPS: %s | %s",
|
||||
"horizon.pvp.hud.reach": "Bereik: %s",
|
||||
"horizon.pvp.hud.speed": "Spoed: %s b/s",
|
||||
"horizon.pvp.hud.keys.attack": "LMB",
|
||||
"horizon.pvp.hud.keys.use": "RMB",
|
||||
"horizon.pvp.hud.keys.cps": "%s CPS",
|
||||
"horizon.pvp.numeric_enchants": "Betoweringsvlakke as getalle",
|
||||
"horizon.pvp.numeric_enchants.tooltip": "Nutswenke en die betoweringstafel wys Sharpness 5 in plaas van Sharpness V.",
|
||||
"horizon.pvp.header.items": "Items",
|
||||
"horizon.pvp.fire_lower": "Laer vuur",
|
||||
"horizon.pvp.fire_lower.tooltip": "Skuif die vlamme wat jy sien terwyl jy brand, af sodat hulle minder van die skerm bedek.",
|
||||
"horizon.pvp.shield_size": "Skildgrootte",
|
||||
"horizon.pvp.shield_size.tooltip": "Die grootte van die skild in jou hand, in eerstepersoonsaansig.",
|
||||
"horizon.pvp.totem_size": "Totem-animasiegrootte",
|
||||
"horizon.pvp.totem_size.tooltip": "Die grootte van die totem wat die skerm vul wanneer dit jou red.",
|
||||
"horizon.pvp.glint_colour": "Betoweringsglans-kleur",
|
||||
"horizon.pvp.glint_colour.tooltip": "Die kleur van die glans op betowerde items en wapenrusting. Af is vanielje se pers.",
|
||||
"horizon.pvp.clear_water": "Heldere water",
|
||||
"horizon.pvp.clear_water.tooltip": "Onder water, sien so ver soos vanielje jou toelaat ná 30 sekondes daar, van die begin af.",
|
||||
"horizon.pvp.clear_lava": "Heldere lawa",
|
||||
"horizon.pvp.clear_lava.tooltip": "In lawa, sien so ver soos vanielje se Fire Resistance jou toelaat, sonder die effek.",
|
||||
"horizon.pvp.header.visual": "Visuele aanpassings",
|
||||
"horizon.pvp.view_time": "Tyd van die dag",
|
||||
"horizon.pvp.view_time.tooltip": "Die tyd van die dag wat jy sien: net die lug en sy lig. Die wêreld se tyd gaan aan en niks word na die bediener gestuur nie.",
|
||||
"horizon.pvp.view_weather": "Weer",
|
||||
"horizon.pvp.view_weather.tooltip": "Die weer wat jy sien: net reën, sy lig en mis. Die wêreld se weer gaan aan en niks word na die bediener gestuur nie.",
|
||||
"horizon.pvp.header.view": "Jou uitsig",
|
||||
"horizon.pvp.view.world": "Wêreld s'n",
|
||||
"horizon.pvp.time.morning": "Oggend",
|
||||
"horizon.pvp.time.noon": "Middag",
|
||||
"horizon.pvp.time.sunset": "Sonsondergang",
|
||||
"horizon.pvp.time.midnight": "Middernag",
|
||||
"horizon.pvp.weather.clear": "Helder",
|
||||
"horizon.pvp.weather.rain": "Reën",
|
||||
"horizon.pvp.weather.thunder": "Donderweer",
|
||||
"horizon.chat.timestamp": "[%s] ",
|
||||
"horizon.chat.repeats": " (x%s)",
|
||||
"horizon.pvp.header.chat": "Gesels",
|
||||
"horizon.chat.copied": "Boodskap gekopieer",
|
||||
"horizon.chat.search": "Soek in gesels:",
|
||||
"horizon.pvp.chat_timestamps": "Tydstempels",
|
||||
"horizon.pvp.chat_timestamps.tooltip": "Die tyd toe elke boodskap ingekom het, voor dit.",
|
||||
"horizon.pvp.chat_compact": "Kompakte gesels",
|
||||
"horizon.pvp.chat_compact.tooltip": "'n Boodskap wat in 'n ry herhaal word, wys een keer, met hoe gereeld dit gekom het: (x3).",
|
||||
"horizon.pvp.chat_copy": "Kopieer op klik",
|
||||
"horizon.pvp.chat_copy.tooltip": "Om op 'n boodskap in die oop gesels te klik, kopieer sy teks, tensy die klik iets anders doen.",
|
||||
"horizon.pvp.chat_search": "Soek in gesels",
|
||||
"horizon.pvp.chat_search.tooltip": "Ctrl+F in die oop gesels: wat jy tik wys net die boodskappe wat dit bevat. Enter of Ctrl+F weer stop.",
|
||||
"horizon.pvp.preset": "PvP-voorafinstelling",
|
||||
"horizon.pvp.preset.tooltip": "Skakel die pasgemaakte visier, die wapenrusting- en effekte-widgets, wissel-hardloop en -sluip, laer vuur, en die FPS-teller en toetsaanslae-mods aan as dit geïnstalleer is. Jou instellings vir elkeen bly.",
|
||||
"horizon.pvp.hud_editor_key": "HUD-redigeerder-sleutel",
|
||||
"horizon.pvp.hud_editor_key.tooltip": "Maak die HUD-redigeerder vanuit die speletjie oop. Klik, druk dan 'n sleutel; Escape ontbind dit.",
|
||||
"horizon.pvp.editor": "Redigeer HUD",
|
||||
"horizon.pvp.editor.tooltip": "Skuif die widgets op die speletjie: sleep hulle enige plek, verander hul grootte met die wiel, regs-klik een om dit terug in sy hoek te sit.",
|
||||
"horizon.pvp.editor.hint": "Sleep widgets · Wiel: grootte · Regs-klik: terug na sy hoek",
|
||||
"horizon.pvp.combat_feel": "Strydgevoel",
|
||||
"horizon.pvp.combat_feel.tooltip": "Legacy teken gevegte soos 1.8.9 dit gedoen het: die item in jou hand, sy swaai, eet, die boog en 'n swaard wat opgehou word terwyl jy gebruik hou, sonder die dip ná elke treffer of die aanvalaanwyser. Net die voorkoms: wat jy na 'n bediener stuur bly dieselfde.",
|
||||
"horizon.pvp.combat_indicator": "Aanvalaanwyser in Legacy",
|
||||
"horizon.pvp.combat_indicator.tooltip": "Hou die aanvalaanwyser onder Legacy, wat 1.8.9 nie gehad het nie.",
|
||||
"horizon.pvp.combat_feel.modern": "Modern",
|
||||
"horizon.pvp.combat_feel.legacy": "Legacy (1.8.9)",
|
||||
"horizon.pvp.header.combat": "Strydgevoel",
|
||||
"horizon.pvp.combat_sounds": "1.8-trefferklanke",
|
||||
"horizon.pvp.combat_sounds.tooltip": "Onder Legacy, laat die aanvalklanke uit wat 1.9 bygevoeg het (sterk, swak, sweep, krities...). Seerklanke bly.",
|
||||
"horizon.pvp.combat_rules": "1.8-stryd in wêrelde wat jy aanbied",
|
||||
"horizon.pvp.combat_rules.tooltip": "Enkelspeler en die wêrelde wat jy op LAN oopmaak, veg volgens 1.8.9 se reëls: geen aanvalafkoeling of sweep nie, 1.8-wapenskade, Strength, Sharpness en kritiese treffers. Bedieners waarby jy aansluit, hou hul eie reëls.",
|
||||
"Bindings": "Bindings",
|
||||
"Play": "Speel",
|
||||
"Device": "Toestel",
|
||||
"Look": "Kyk",
|
||||
"Movement": "Beweging",
|
||||
"Menus": "Spyskaarte",
|
||||
"Actions": "Aksies",
|
||||
"Reset": "Stel terug",
|
||||
"Detected": "Bespeur",
|
||||
"Glyphs": "Glyphs",
|
||||
"Feedback": "Terugvoer",
|
||||
"Controller": "Beheerder",
|
||||
"How the sticks turn and move you, and how menus repeat and scroll.": "Hoe die stokke jou draai en beweeg, en hoe spyskaarte herhaal en rol.",
|
||||
"Which button does what. Each player has their own.": "Watter knoppie wat doen. Elke speler het sy eie.",
|
||||
"The controller the game sees, its button glyphs and its feedback.": "Die beheerder wat die speletjie sien, sy knoppie-glyphs en sy terugvoer.",
|
||||
"Press this row, then the button that should do it.": "Druk hierdie ry, dan die knoppie wat dit moet doen.",
|
||||
"Off, the game ignores every controller for this player.": "Af, die speletjie ignoreer elke beheerder vir hierdie speler.",
|
||||
"How fast the right stick turns the view.": "Hoe vinnig die regterstok die uitsig draai.",
|
||||
"Turn the view the other way, left and right.": "Draai die uitsig die ander kant toe, links en regs.",
|
||||
"Turn the view the other way, up and down.": "Draai die uitsig die ander kant toe, op en af.",
|
||||
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Hoe ver die regterstok beweeg voordat die uitsig draai. Verhoog dit as die uitsig dryf.",
|
||||
"Hold the button to sneak, or press it once to start and again to stop.": "Hou die knoppie om te sluip, of druk dit een keer om te begin en weer om te stop.",
|
||||
"How far the left stick moves before you walk. Raise it if you drift.": "Hoe ver die linkerstok beweeg voordat jy stap. Verhoog dit as jy dryf.",
|
||||
"How far a trigger is pulled before it counts as pressed.": "Hoe ver 'n sneller getrek word voordat dit as ingedruk tel.",
|
||||
"Shows what your buttons do right now, in play and in inventories.": "Wys wat jou knoppies nou doen, in die spel en in voorraadskerms.",
|
||||
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "Wys net wat jy met dit waarna jy kyk kan doen, en die hoof-voorraadsknoppies.",
|
||||
"Doesn't show what your buttons do.": "Wys nie wat jou knoppies doen nie.",
|
||||
"Shows the buttons of the controller you're using.": "Wys die knoppies van die beheerder wat jy gebruik.",
|
||||
"Always shows Xbox buttons.": "Wys altyd Xbox-knoppies.",
|
||||
"Always shows PlayStation buttons.": "Wys altyd PlayStation-knoppies.",
|
||||
"Always shows Nintendo buttons.": "Wys altyd Nintendo-knoppies.",
|
||||
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A, regs, bevestig en spring en B gaan terug, soos op 'n Switch.",
|
||||
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B, onder, bevestig en spring en A gaan terug, soos op ander beheerders.",
|
||||
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Maak Steam se sleutelbord oop op 'n Steam Deck onder Steam, en anders Horizon s'n.",
|
||||
"Always opens Horizon's keyboard.": "Maak altyd Horizon se sleutelbord oop.",
|
||||
"Always opens Steam's keyboard.": "Maak altyd Steam se sleutelbord oop.",
|
||||
"A DualSense's light shows your health, from green through amber to red.": "'n DualSense se lig wys jou gesondheid, van groen deur amber na rooi.",
|
||||
"A DualSense's light changes with the dimension you're in.": "'n DualSense se lig verander met die dimensie waarin jy is.",
|
||||
"A DualSense keeps its own light.": "'n DualSense hou sy eie lig.",
|
||||
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "Laat LT + A op bedieners anders as jou eie wêrelde vervaardig. Sommige bedieners reken dit as 'n makro.",
|
||||
"Show the buttons for what you can do, while you play with a controller.": "Wys die knoppies vir wat jy kan doen, terwyl jy met 'n beheerder speel.",
|
||||
"How fast the stick moves the crosshair in inventories.": "Hoe vinnig die stok die visier in voorraadskerms beweeg.",
|
||||
"How fast the right stick scrolls a list.": "Hoe vinnig die regterstok deur 'n lys rol.",
|
||||
"How long a held direction waits before it repeats.": "Hoe lank 'n gehoue rigting wag voordat dit herhaal.",
|
||||
"How long a held direction takes between repeats.": "Hoe lank 'n gehoue rigting tussen herhalings neem.",
|
||||
"Which on-screen keyboard opens when a text field is pressed.": "Watter sleutelbord op die skerm oopmaak wanneer 'n teksveld gedruk word.",
|
||||
"Every action back on its default button.": "Elke aksie terug op sy verstekknoppie.",
|
||||
"These settings belong to this player's profile.": "Hierdie instellings behoort aan hierdie speler se profiel.",
|
||||
"The controller driving this player, or else the first one connected.": "Die beheerder wat hierdie speler bestuur, of anders die eerste een wat gekoppel is.",
|
||||
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "Die USB-vervaardiger en -produk wat die stelsel rapporteer. Nuttig wanneer jy 'n beheerder rapporteer wat hom misdra.",
|
||||
"The glyph family the game picked from the controller's maker.": "Die glyph-familie wat die speletjie uit die beheerder se maker gekies het.",
|
||||
"Follow the controller, or always show one family's buttons.": "Volg die beheerder, of wys altyd een familie se knoppies.",
|
||||
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "Of 'n Nintendo-beheerder se A en B volgens waar hulle sit of volgens hul letters werk.",
|
||||
"The buttons menus show right now.": "Die knoppies wat spyskaarte nou wys.",
|
||||
"Rumble when something happens to you.": "Vibreer wanneer iets met jou gebeur.",
|
||||
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "Die beheerder hou aan speel terwyl hierdie venster nie gefokus is nie, en fokusverlies sal nie pouseer nie.",
|
||||
"A DualSense's triggers push back on bows and tools.": "'n DualSense se snellers druk terug op boë en gereedskap.",
|
||||
"What a DualSense's light shows, over USB or Bluetooth.": "Wat 'n DualSense se lig wys, oor USB of Bluetooth.",
|
||||
"Player profile": "Spelerprofiel",
|
||||
"Vendor and product": "Vervaardiger en produk",
|
||||
"Detected glyphs": "Bespeurde glyphs",
|
||||
"Glyphs in use": "Glyphs in gebruik",
|
||||
"Unknown": "Onbekend",
|
||||
"No controller": "Geen beheerder",
|
||||
"Editing: %s": "Redigeer: %s",
|
||||
"Press the button for this. %s cancels.": "Druk die knoppie hiervoor. %s kanselleer.",
|
||||
"This button also does: %s": "Hierdie knoppie doen ook: %s",
|
||||
"Not bound: press this row, then the button that should do it.": "Nie gebonde nie: druk hierdie ry, dan die knoppie wat dit moet doen.",
|
||||
"horizon.pad.notice.connected.press": "Druk om dit te gebruik",
|
||||
"horizon.recipe_transfer.missing": "Ontbrekende items",
|
||||
"horizon.recipe_transfer.no_room": "Voorraad vol",
|
||||
"horizon.recipe_transfer.unavailable": "Resep pas nie in hierdie spyskaart nie",
|
||||
"horizon.recipe_transfer.unsupported": "Bediener vereis 'n bekende resep",
|
||||
"horizon.studio.timeline.freecam_on": "Vrye kamera: AAN",
|
||||
"horizon.studio.timeline.freecam_off": "Vrye kamera: AF",
|
||||
"horizon.studio.timeline.freecam_short": "Kam",
|
||||
"horizon.studio.timeline.freecam_active_short": "Kam ✓",
|
||||
"horizon.studio.timeline.add_keyframe_short": "+ Sleutel",
|
||||
"horizon.studio.timeline.delete_keyframe_short": "− Verwyder",
|
||||
"horizon.studio.timeline.split": "Splits",
|
||||
"horizon.studio.timeline.marker": "Merkpunt %s",
|
||||
"horizon.studio.timeline.seconds": "%s s",
|
||||
"horizon.studio.timeline.scene_count": "%s tonele",
|
||||
"horizon.studio.timeline.scene_title": "Toneel %s",
|
||||
"horizon.studio.timeline.sequence": "Sekwens",
|
||||
"horizon.studio.topbar.title": "✦ Studio",
|
||||
"horizon.studio.topbar.file": "Lêer",
|
||||
"horizon.studio.topbar.camera": "Kamera",
|
||||
"horizon.studio.topbar.view": "Uitsig",
|
||||
"horizon.studio.topbar.file_short": "Lêer",
|
||||
"horizon.studio.topbar.camera_short": "Kam",
|
||||
"horizon.studio.topbar.view_short": "Uitsig",
|
||||
"horizon.studio.topbar.game_pause_menu": "⏸ Pousemenu",
|
||||
"horizon.studio.topbar.pause_short": "Pouse",
|
||||
"horizon.studio.topbar.open_recordings": "📁 Maak opnames-lêergids oop",
|
||||
"horizon.studio.topbar.close_replay": "✕ Maak herhaling toe",
|
||||
"horizon.studio.topbar.exit": "Verlaat",
|
||||
"horizon.studio.topbar.toggle_freecam": "Wissel vrye kamera",
|
||||
"horizon.studio.topbar.reset_to_player": "👤 Herstel na speler",
|
||||
"horizon.studio.topbar.unlock_freecam": "🔓 Ontsluit vrye kamera",
|
||||
"horizon.studio.topbar.lock_to_spline": "🔒 Vast aan spline",
|
||||
"horizon.studio.topbar.add_keyframe": "➕ Voeg sleutelraam by",
|
||||
"horizon.studio.topbar.add_marker": "Voeg merkpunt by",
|
||||
"horizon.studio.topbar.clear_keyframes": "🗑 Maak sleutelrame skoon",
|
||||
"horizon.studio.topbar.timeline_hud": "⏱ Tydlyn",
|
||||
"horizon.studio.topbar.split_scene": "Splits toneel"
|
||||
}
|
||||
@@ -1,14 +1,74 @@
|
||||
{
|
||||
"horizon.extras.header.world": "العالم",
|
||||
"horizon.extras.pregen": "التوليد المسبق للقطع",
|
||||
"horizon.extras.pregen.tooltip": "يفعّل /pregen في عوالم اللاعب الفردي وشبكة LAN. مع تفعيل أوامر الغش، استخدم /pregen start لتوليد منطقة قبل استكشافها. لا يفعّل الأمر في الخوادم التي تنضم إليها.",
|
||||
"horizon.options.controller": "المراقب المالي",
|
||||
"horizon.options.three_d_skin_layers": "طبقات الجلد ثلاثية الأبعاد",
|
||||
"horizon.options.vibration": "الاهتزاز",
|
||||
"horizon.options.play_in_background": "اللعب في الخلفية",
|
||||
"horizon.options.adaptive_triggers": "المشغلات التكيفية",
|
||||
"horizon.options.gameplay_guide": "دليل اللعب",
|
||||
"horizon.options.gameplay_guide.basic": "أساسي",
|
||||
"horizon.options.gameplay_guide.full": "كامل",
|
||||
"horizon.options.gameplay_guide.off": "إيقاف",
|
||||
"horizon.controller.guide.use": "استخدام",
|
||||
"horizon.controller.guide.open": "فتح",
|
||||
"horizon.controller.guide.ride": "الركوب",
|
||||
"horizon.controller.guide.trade": "المتاجرة",
|
||||
"horizon.controller.guide.feed": "إطعام",
|
||||
"horizon.controller.guide.place": "وضع",
|
||||
"horizon.controller.guide.eat": "أكل",
|
||||
"horizon.controller.guide.drink": "شرب",
|
||||
"horizon.controller.guide.shoot": "إطلاق",
|
||||
"horizon.controller.guide.load": "تحميل",
|
||||
"horizon.controller.guide.throw": "رمي",
|
||||
"horizon.controller.guide.raise": "رفع",
|
||||
"horizon.controller.guide.equip": "تجهيز",
|
||||
"horizon.controller.guide.fish": "صيد السمك",
|
||||
"horizon.controller.guide.zoom": "تكبير",
|
||||
"horizon.controller.guide.boost": "تعزيز",
|
||||
"horizon.controller.guide.break": "كسر",
|
||||
"horizon.controller.guide.attack": "هجوم",
|
||||
"horizon.controller.guide.spectate": "مشاهدة",
|
||||
"horizon.controller.guide.spectator_menu": "قائمة المشاهدة",
|
||||
"horizon.controller.guide.release_shoot": "أفلت للإطلاق",
|
||||
"horizon.controller.guide.release_throw": "أفلت للرمي",
|
||||
"horizon.controller.guide.release_stop": "أفلت للتوقف",
|
||||
"horizon.controller.guide.fly": "طيران",
|
||||
"horizon.controller.guide.fly_up": "الطيران لأعلى",
|
||||
"horizon.controller.guide.fly_down": "الطيران لأسفل",
|
||||
"horizon.controller.guide.swim_up": "السباحة لأعلى",
|
||||
"horizon.controller.guide.swim_down": "السباحة لأسفل",
|
||||
"horizon.controller.guide.glide": "انزلاق",
|
||||
"horizon.controller.guide.dismount": "النزول",
|
||||
"horizon.controller.guide.stand": "الوقوف",
|
||||
"horizon.controller.guide.drop": "إسقاط العنصر",
|
||||
"horizon.controller.guide.swap": "تبديل اليدين",
|
||||
"horizon.controller.guide.take": "أخذ",
|
||||
"horizon.controller.guide.take_half": "أخذ النصف",
|
||||
"horizon.controller.guide.put": "إنزال",
|
||||
"horizon.controller.guide.put_one": "وضع واحد",
|
||||
"horizon.controller.guide.swap_stack": "تبديل",
|
||||
"horizon.controller.guide.quick_move": "نقل سريع",
|
||||
"horizon.controller.guide.drop_stack": "إسقاط الكومة",
|
||||
"horizon.controller.guide.place_recipe": "وضع الوصفة",
|
||||
"horizon.controller.guide.place_recipe_all": "وضع الكل",
|
||||
"horizon.controller.guide.alternatives": "بدائل",
|
||||
"horizon.controller.guide.select": "اختيار",
|
||||
"horizon.controller.guide.close": "إغلاق",
|
||||
"horizon.controller.guide.pages": "الصفحات",
|
||||
"horizon.controller.guide.categories": "الفئات",
|
||||
"horizon.controller.guide.tabs": "علامات التبويب",
|
||||
"horizon.options.lightbar": "الشريط الضوئي",
|
||||
"horizon.options.look_sensitivity": "حساسية النظر",
|
||||
"horizon.options.invert_x": "عكس محور النظر X",
|
||||
"horizon.options.invert_y": "عكس محور النظر Y",
|
||||
"horizon.options.craft_on_servers": "الصناعة السريعة على الخوادم",
|
||||
"horizon.controller.direct_craft": "صناعة سريعة",
|
||||
"horizon.options.sneak_button": "زر التسلل",
|
||||
"horizon.options.move_deadzone": "المنطقة الميتة للحركة",
|
||||
"horizon.options.look_deadzone": "المنطقة الميتة للنظر",
|
||||
"horizon.options.trigger_threshold": "حد المشغّل",
|
||||
"horizon.options.button_glyphs": "الحروف الرسومية للأزرار",
|
||||
"horizon.options.on_screen_keyboard": "لوحة المفاتيح على الشاشة",
|
||||
"horizon.options.chat_delay": "تأخير الدردشة",
|
||||
@@ -18,10 +78,52 @@
|
||||
"horizon.options.glyphs.xbox": "اكس بوكس",
|
||||
"horizon.options.glyphs.playstation": "بلاي ستيشن",
|
||||
"horizon.options.glyphs.nintendo": "نينتندو",
|
||||
"horizon.options.nintendo_buttons": "أزرار نينتندو",
|
||||
"horizon.options.nintendo_buttons.position": "حسب الموضع",
|
||||
"horizon.options.nintendo_buttons.letter": "حسب الحرف",
|
||||
"horizon.options.keyboard.auto": "تلقائي",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "البخار",
|
||||
"horizon.options.unavailable": "هذه الميزة غير متوفرة بعد في Horizon.",
|
||||
"horizon.options.search": "إعدادات البحث...",
|
||||
"horizon.studio.record.usage": "الاستخدام: /studio record <start [name]|stop|status>",
|
||||
"horizon.studio.record.started": "بدأ تسجيل الاستوديو: %s",
|
||||
"horizon.studio.record.saved": "تم حفظ تسجيل الاستوديو: %s",
|
||||
"horizon.studio.record.error": "خطأ في تسجيل الاستوديو: %s",
|
||||
"horizon.studio.record.status.active": "تسجيل الاستوديو نشط — %s ث، %s حزمة، %s KB (%s)",
|
||||
"horizon.studio.record.status.inactive": "تسجيل الاستوديو غير نشط",
|
||||
"horizon.studio.replay.usage": "الاستخدام: /studio replay <open PATH|pause|resume|speed MULTIPLIER|seek MS|status|close>",
|
||||
"horizon.studio.replay.open.usage": "الاستخدام: /studio replay open <PATH>",
|
||||
"horizon.studio.replay.opened": "تم فتح إعادة تشغيل الاستوديو: %s (%s ث، %s حزمة)",
|
||||
"horizon.studio.replay.open_error": "تعذّر فتح إعادة التشغيل: %s",
|
||||
"horizon.studio.replay.paused": "تم إيقاف إعادة تشغيل الاستوديو مؤقتًا",
|
||||
"horizon.studio.replay.resumed": "تم استئناف إعادة تشغيل الاستوديو",
|
||||
"horizon.studio.replay.inactive": "لا توجد إعادة تشغيل استوديو نشطة",
|
||||
"horizon.studio.replay.speed.usage": "الاستخدام: /studio replay speed <MULTIPLIER>",
|
||||
"horizon.studio.replay.speed.set": "ضُبطت سرعة إعادة تشغيل الاستوديو على %s×",
|
||||
"horizon.studio.replay.speed.invalid": "سرعة إعادة تشغيل غير صالحة",
|
||||
"horizon.studio.replay.seek.usage": "الاستخدام: /studio replay seek <MILLISECONDS>",
|
||||
"horizon.studio.replay.seeked": "تم الانتقال في إعادة تشغيل الاستوديو إلى %s مللي ثانية (%s ث)",
|
||||
"horizon.studio.replay.seek.error": "تعذّر الانتقال في إعادة التشغيل: %s",
|
||||
"horizon.studio.replay.seek.backward_requires_standalone": "الرجوع للخلف متاح فقط لإعادة تشغيل مستقلة.",
|
||||
"horizon.studio.replay.seek.invalid": "موضع إعادة تشغيل غير صالح",
|
||||
"horizon.studio.replay.status": "إعادة تشغيل الاستوديو: %s / %s مللي ثانية (%s%%)، السرعة %s×، متوقفة مؤقتًا %s، منتهية %s",
|
||||
"horizon.studio.replay.closed": "تم إغلاق إعادة تشغيل الاستوديو",
|
||||
"horizon.studio.freecam.enabled": "تم تفعيل الكاميرا الحرة للاستوديو. استخدم WASD للحركة، والمسافة وShift للصعود أو الهبوط، وControl للحركة أسرع.",
|
||||
"horizon.studio.freecam.disabled": "تم إيقاف الكاميرا الحرة للاستوديو",
|
||||
"horizon.studio.freecam.unavailable": "تعذّر على الكاميرا الحرة للاستوديو العثور على كاميرا اللاعب",
|
||||
"horizon.studio.freecam.replay_required": "الكاميرا الحرة للاستوديو متاحة أثناء تشغيل إعادة التشغيل",
|
||||
"horizon.studio.freecam.usage": "الاستخدام: /studio freecam <on|off>",
|
||||
"horizon.studio.keyframe.added": "أُضيف مفتاح إطار كاميرا عند %s مللي ثانية. يُحفظ المسار عند إغلاق إعادة التشغيل.",
|
||||
"horizon.studio.keyframe.cleared": "تم مسح مسار الكاميرا. يُحفظ التغيير عند إغلاق إعادة التشغيل.",
|
||||
"horizon.studio.keyframe.removed": "أُزيل مفتاح إطار الكاميرا %s. يُحفظ التغيير عند إغلاق إعادة التشغيل.",
|
||||
"horizon.studio.keyframe.save_error": "تعذّر حفظ مسار الكاميرا: %s",
|
||||
"horizon.studio.keyframe.remove_usage": "الاستخدام: /studio keyframe remove <INDEX>",
|
||||
"horizon.studio.keyframe.index_invalid": "فهرس مفتاح إطار الكاميرا خارج النطاق",
|
||||
"horizon.studio.keyframe.usage": "الاستخدام: /studio keyframe <add|remove INDEX|clear>",
|
||||
"horizon.studio.path.locked": "تم تفعيل تشغيل مسار كاميرا الاستوديو",
|
||||
"horizon.studio.path.unlocked": "تم إيقاف تشغيل مسار كاميرا الاستوديو",
|
||||
"horizon.studio.path.usage": "الاستخدام: /studio path <lock|unlock> (أضف مفتاح إطار واحدًا على الأقل أولًا)",
|
||||
"horizon.pages.mods": "تعديل",
|
||||
"horizon.pages.shader_packs": "حزم شادر",
|
||||
"horizon.pages.shader_options": "خيارات التظليل",
|
||||
@@ -29,6 +131,7 @@
|
||||
"horizon.pages.controller_binds": "ربط وحدة التحكم",
|
||||
"horizon.pages.extras": "إضافات Horizon",
|
||||
"horizon.pages.settings": "إعدادات Horizon",
|
||||
"horizon.pages.studio": "استوديو إعادة التشغيل",
|
||||
"horizon.pages.agent": "وكيل منظمة العفو الدولية",
|
||||
"horizon.split.leave": "غادر",
|
||||
"horizon.pages.split_settings": "إعدادات تقسيم الشاشة",
|
||||
@@ -49,6 +152,7 @@
|
||||
"horizon.pages.music.empty": "لا توجد مقطوعات موسيقية: لا يتم تحميل أصوات اللعبة.",
|
||||
"horizon.pages.music.tooltip": "المسارات المضمنة، حسب الموقف: العب واحدة الآن، أو قم بإيقاف تشغيل أي منها حتى تتوقف اللعبة عن اختيارها.",
|
||||
"horizon.pages.settings.tooltip": "إعدادات Horizon الخاصة: الرسومات ووحدات التحكم والإضافات والتعديلات.",
|
||||
"horizon.pages.studio.tooltip": "تصفّح التسجيلات المحفوظة وبياناتها الوصفية.",
|
||||
"horizon.pages.graphics.tooltip": "الرسومات ببساطة: كل إعداد يوضح تكلفته.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "كل إعدادات الرسومات، بما في ذلك Horizon، وتكلفة كل منها.",
|
||||
"horizon.pages.controller.tooltip": "وحدات التحكم: الأزرار والعصي والاهتزاز والتنقل في القائمة.",
|
||||
@@ -69,6 +173,14 @@
|
||||
"horizon.pages.open.horizon_graphics": "رسومات Horizon...",
|
||||
"horizon.pages.open.controller_binds": "ربط وحدة التحكم...",
|
||||
"horizon.pages.open.horizon_settings": "إعدادات Horizon...",
|
||||
"horizon.pages.open.studio": "استوديو إعادة التشغيل...",
|
||||
"horizon.studio.saved_recordings": "التسجيلات المحفوظة",
|
||||
"horizon.studio.empty": "لم يتم العثور على تسجيلات في مجلد التسجيلات.",
|
||||
"horizon.studio.empty_hint": "استخدم /studio record start <name> في عالم، أو ألقِ ملفات .mcpr هناك.",
|
||||
"horizon.studio.open_folder": "فتح التسجيلات",
|
||||
"horizon.studio.open_replay": "تشغيل إعادة التشغيل",
|
||||
"horizon.studio.refresh": "تحديث",
|
||||
"horizon.studio.unknown_server": "غير معروف",
|
||||
"horizon.pages.open.music": "موسيقى...",
|
||||
"horizon.mods.empty": "لا توجد تعديلات: ضع ملفات .hmod في مجلد Horizon-mods",
|
||||
"horizon.mods.stopped": " (متوقف)",
|
||||
@@ -77,6 +189,28 @@
|
||||
"horizon.mods.settings": "الإعدادات",
|
||||
"horizon.mods.in_world": " (اترك العالم ليتغير)",
|
||||
"horizon.mods.open_folder": "افتح مجلد التعديل",
|
||||
"horizon.mods.browse": "تصفح التعديلات...",
|
||||
"horizon.mods.browse.title": "تصفح التعديلات",
|
||||
"horizon.mods.browse.search": "بحث عن التعديلات...",
|
||||
"horizon.mods.browse.project": "تعديل",
|
||||
"horizon.mods.browse.loading": "قراءة سجل تعديلات Horizon...",
|
||||
"horizon.mods.browse.failed": "تعذّرت قراءة سجل التعديلات: %s",
|
||||
"horizon.mods.browse.none": "لم يُعثر على أي تعديل",
|
||||
"horizon.mods.browse.has_update": " (تحديث)",
|
||||
"horizon.mods.browse.is_installed": " (مثبّت)",
|
||||
"horizon.mods.browse.by": "بواسطة %s",
|
||||
"horizon.mods.browse.installing": "جارٍ التثبيت...",
|
||||
"horizon.mods.browse.install": "تثبيت",
|
||||
"horizon.mods.browse.update": "تحديث",
|
||||
"horizon.mods.browse.installed": "مثبّت",
|
||||
"horizon.mods.browse.open_page": "فتح الصفحة",
|
||||
"horizon.mods.browse.enabled": "تعديل",
|
||||
"horizon.mods.browse.changelog": "سجل التغييرات",
|
||||
"horizon.mods.browse.done": "تم تثبيت %s %s وهو مفعّل.",
|
||||
"horizon.mods.browse.install_failed": "تعذّر تثبيته: %s",
|
||||
"horizon.mods.switch.worldgen": "يغيّر توليد العالم",
|
||||
"horizon.mods.switch.registries": "يضيف كتلًا أو عناصر أو مدخلات سجل أخرى",
|
||||
"horizon.mods.switch.restart": "أعد تشغيل اللعبة لتطبيق %s: فهي تضيف كتلًا أو عناصر أو توليد عالم.",
|
||||
"horizon.shader_options.profile": "الملف الشخصي",
|
||||
"horizon.agent.copy_details": "نسخ تفاصيل الاتصال",
|
||||
"horizon.multiplayer.join": "انضم",
|
||||
@@ -88,6 +222,7 @@
|
||||
"LOD Distance": "مسافة اللد",
|
||||
"Far Detail": "تفاصيل بعيدة",
|
||||
"Cave Culling": "إعدام الكهف",
|
||||
"GPU Culling": "استبعاد GPU",
|
||||
"Entity Culling": "إعدام الكيان",
|
||||
"Chunk Updates": "تحديثات القطعة",
|
||||
"Upload Budget": "تحميل الميزانية",
|
||||
@@ -101,10 +236,15 @@
|
||||
"Resolution Scale": "مقياس القرار",
|
||||
"Anti-aliasing": "مكافحة التعرج",
|
||||
"Upscaling": "الارتقاء",
|
||||
"Upscaling Sharpness": "حدة الارتقاء",
|
||||
"Paper White": "ورقة بيضاء",
|
||||
"Peak Brightness": "ذروة السطوع",
|
||||
"Black Level": "المستوى الأسود",
|
||||
"UI Brightness": "سطوع واجهة المستخدم",
|
||||
"HDR Lighting": "إضاءة HDR",
|
||||
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "تتجاوز مصادر الضوء والشمس وضوء الشمس اللون الأبيض. إيقاف: كما في SDR فقط.",
|
||||
"Smooth Gradients": "تدرجات ناعمة",
|
||||
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "إطار 16 بت: لا تظهر نطاقات في السماوات والظلال الداكنة. ذاكرة VRAM أكثر قليلًا. عند البدء التالي.",
|
||||
"Battery Mode": "وضع البطارية",
|
||||
"Graphics API": "واجهة برمجة تطبيقات الرسومات",
|
||||
"Translucent Batching": "الخلط الشفاف",
|
||||
@@ -148,6 +288,10 @@
|
||||
"Your display or system doesn't offer HDR output.": "لا توفر شاشتك أو نظامك مخرجات HDR.",
|
||||
"Turn HDR on to use it.": "قم بتشغيل HDR لاستخدامه.",
|
||||
"Your GPU or graphics API doesn't support it.": "وحدة معالجة الرسومات (GPU) أو واجهة برمجة تطبيقات الرسومات الخاصة بك لا تدعمها.",
|
||||
"Not with software rendering, which runs it on the CPU.": "غير متاح مع العرض البرمجي، الذي ينفّذه على CPU.",
|
||||
"Out of video memory: off until the next start.": "نفدت ذاكرة الفيديو: إيقاف حتى البدء التالي.",
|
||||
"The graphics device was lost: off until the next start.": "فُقد جهاز الرسومات: إيقاف حتى البدء التالي.",
|
||||
"Not on DirectX 12 yet: try the Vulkan graphics API.": "غير متاح على DirectX 12 بعد: جرّب واجهة برمجة تطبيقات الرسومات Vulkan.",
|
||||
"%s chunks": "%s قطع",
|
||||
"%s per frame": "%s لكل إطار",
|
||||
"%s MiB/frame": "%s MiB/الإطار",
|
||||
@@ -163,13 +307,15 @@
|
||||
"Size of menus and the HUD.": "حجم القوائم وHUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "يضبط الخيارات أدناه معًا. تغيير واحد يجعله مخصصًا.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "الحمم البركانية والنار وأشعة الشمس تتألق أكثر من اللون الأبيض. يحتاج إلى شاشة HDR.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "اللون الأسود الداكن لشاشتك. 0 لـ OLED؛ تتوهج شاشات LCD قليلاً.",
|
||||
"Lifts shadows onto your display's darkest black. 0: true black, no lift.": "يرفع الظلال إلى أغمق أسود في شاشتك. 0: أسود حقيقي، دون رفع.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "كيف تحصل على النقاط البارزة الساطعة. تلقائي: ذروة تقارير النظام الخاص بك.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "كيف تبدو الورق والقوائم البيضاء الزاهية. تلقائي: سطح المكتب الخاص بك باللون الأبيض.",
|
||||
"How bright white looks in the world. Auto: your desktop's white.": "مدى سطوع الأبيض في العالم. تلقائي: أبيض سطح المكتب.",
|
||||
"How bright the HUD and menus are, against paper white.": "مدى سطوع شاشة HUD والقوائم مقابل الورق الأبيض.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "إلى أي مدى يستمر الغوغاء والمحاصيل والآلات في العمل. وقت وحدة المعالجة المركزية.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "إلى أي مدى يتم رسم الغوغاء والعناصر، كنسبة من المعدل الطبيعي.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "يتخطى التضاريس المخفية خلف الأقسام المغلقة. قبالة تكاليف الكثير.",
|
||||
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "يستبعد التضاريس القريبة على GPU: وقت CPU أقل عند مسافة عرض كبيرة، وGPU أكثر.",
|
||||
"Auto: tries it where your CPU limits the frame, keeps it if frames get faster.": "تلقائي: يجرّبه حيث يحدّ CPU من الإطار، ويبقيه إذا أصبحت الإطارات أسرع.",
|
||||
"Hides entities behind opaque blocks.": "يخفي الكيانات خلف الكتل غير الشفافة.",
|
||||
"No entity occlusion culling.": "لا يوجد إعدام انسداد الكيان.",
|
||||
"CPU occlusion test runs this frame.": "يقوم اختبار انسداد وحدة المعالجة المركزية بتشغيل هذا الإطار.",
|
||||
@@ -193,6 +339,7 @@
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "بكسل العالم لكل بكسل الشاشة. أقل من 100% أسرع على وحدة معالجة الرسومات.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "ينعم الحواف الخشنة. FXAA: أكثر ليونة. TAA: قد تتبع الأشياء الأكثر سلاسة وتحريكًا.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "يجعل العالم أصغر حجمًا ويعيد بنائه باستخدام TAA. أسرع وأكثر ليونة.",
|
||||
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "يزيد حدة الصورة المرتقاة. أعلى: حواف أوضح ووميض أكثر.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "إخراج HDR على شاشة HDR: تتألق الحمم البركانية والنار وأشعة الشمس بشكل أكثر سطوعًا.",
|
||||
"How frames reach the screen while VSync is off.": "كيفية وصول الإطارات إلى الشاشة أثناء إيقاف تشغيل VSync.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "ينام بكفاءة حتى الإطار التالي؛ يدور بدقة، مثل الفانيليا.",
|
||||
@@ -212,6 +359,7 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "معدل الإطارات وأوقات الإطار خلال اللعبة، مع رسم بياني.",
|
||||
"horizon.multiplayer.finding_friend": "البحث عن عالم صديقك...",
|
||||
"horizon.multiplayer.not_signed_in": "هذا الخادم في وضع الاتصال بالإنترنت. قم بتسجيل الدخول باستخدام حساب Microsoft الخاص بك في Horizon Launcher للانضمام إليه.",
|
||||
"horizon.realms.not_signed_in": "سجّل الدخول بحساب Microsoft في مشغّل Horizon للعب على Realms.",
|
||||
"horizon.multiplayer.invalid_address": "عنوان الخادم غير صالح: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "تواصل مباشرة مع صديقك..",
|
||||
"horizon.multiplayer.connected_friend_relay": "متصل بصديقك من خلال التتابع...",
|
||||
@@ -241,6 +389,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "إخراج أكثر سطوعًا من اللون الأبيض على شاشة HDR.",
|
||||
"Results for \"%s\"": "نتائج ل \"%s\"",
|
||||
"No settings match.": "لا توجد إعدادات متطابقة.",
|
||||
"horizon.plain.official": "Horizon الرسمي مع المشغّل والتعديلات والتحديثات: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "لا ينتمي إلى Mojang أو Microsoft",
|
||||
"horizon.extras.header.lighting": "الإضاءة",
|
||||
"horizon.extras.header.third_person": "شخص ثالث",
|
||||
@@ -264,9 +413,12 @@
|
||||
"horizon.extras.no_ai": "لا يوجد ذكاء اصطناعي",
|
||||
"horizon.extras.agent_control": "مراقبة الوكيل",
|
||||
"horizon.extras.discord_presence": "وجود الخلاف الغني",
|
||||
"horizon.extras.zoom": "تكبير",
|
||||
"horizon.extras.zoom_mod_install": "التكبير: ثبّته من التعديلات",
|
||||
"horizon.extras.splitscreen": "تقسيم الشاشة",
|
||||
"horizon.extras.chat_animation": "دردشة سلسة",
|
||||
"horizon.extras.chat_indicator": "مؤشر الرسالة",
|
||||
"horizon.extras.wheel_cycles_list_settings.tooltip": "تغيّر عجلة التمرير فوق زر في قائمة الإعدادات قيمته. عند الإيقاف، كما في الفانيليا، تمرّر العجلة القائمة.",
|
||||
"horizon.extras.wheel_cycles_settings": "عجلة التمرير تغير الإعدادات",
|
||||
"horizon.extras.pack_browser": "متصفح حزمة الموارد",
|
||||
"horizon.extras.fullbright.tooltip": "إظهار الكتل والكيانات في الضوء الكامل، حتى في المناطق المظلمة.",
|
||||
@@ -327,6 +479,7 @@
|
||||
"horizon.friends.stopped": "تم إيقاف مشاركة رمز الدعوة.",
|
||||
"horizon.friends.short_code_changed": "استمر شخص ما في تجربة رمز قصير خاطئ، لذلك تغير.",
|
||||
"horizon.friends.wrong_code": "حاول شخص ما رمزا خاطئا.",
|
||||
"horizon.friends.relay_stopped": "رموز قصيرة خاطئة كثيرة، لذا أُوقف التتابع حتى تشارك مجددًا. يمكن للأصدقاء الانضمام بالرمز الكامل.",
|
||||
"horizon.friends.code_ready": "رمز الدعوة الخاص بك جاهز: قائمة الألعاب، العب مع الأصدقاء. انسخها وأرسلها إلى أصدقائك.",
|
||||
"horizon.friends.share_failed": "لا يمكن مشاركة العالم: %s",
|
||||
"horizon.friends.finding_route": "التعرف على كيفية وصول الأصدقاء إليك...",
|
||||
@@ -346,6 +499,7 @@
|
||||
"horizon.splitscreen.setup.new_window": "نافذة جديدة",
|
||||
"horizon.splitscreen.setup.fill_monitor": "املأ %s",
|
||||
"horizon.splitscreen.setup.done": "تم",
|
||||
"horizon.splitscreen.setup.switch": "%s: %s",
|
||||
"horizon.splitscreen.setup.on": "على",
|
||||
"horizon.splitscreen.setup.off": "إيقاف",
|
||||
"horizon.splitscreen.setup.player": "اللاعب %s",
|
||||
@@ -403,6 +557,9 @@
|
||||
"horizon.splitscreen.setup.help.give_up_pad": "تتوقف وحدة التحكم الخاصة بك عن قيادتك ويُعرض عليك الانضمام (أ).",
|
||||
"horizon.splitscreen.setup.note.held_at": "%s إلى %s: يسقطها A هنا، ويعيدها B.",
|
||||
"horizon.splitscreen.setup.note.held": "%s تم الالتقاط: يسار/يمين يختار المكان، يسقط A، ويعيده B.",
|
||||
"horizon.splitscreen.online_only": "هذا الخادم في وضع الاتصال: لا يمكن للملفات الشخصية غير المتصلة الانضمام إليه، وكل لاعب يحتاج حساب Minecraft خاصًا به.",
|
||||
"horizon.splitscreen.own_account": "يحتاج هذا الخادم حساب Minecraft خاصًا بكل لاعب",
|
||||
"horizon.splitscreen.local_only": "يمكن للاعبين الآخرين الانضمام إلى هذا الخادم فقط بحساب Minecraft خاص بهم: أما الملفات الشخصية غير المتصلة فتلعب في عالمك، على LAN أو على خادم في شبكتك المحلية.",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "يخفي النماذج خلف الكتل المعتمة. غير متزامن يتحقق من خارج الموضوع؛ علامة الكيانات.",
|
||||
"Block Entities": "حظر الكيانات",
|
||||
"Shulker Boxes": "صناديق شوكر",
|
||||
@@ -421,6 +578,8 @@
|
||||
"Reuses statue geometry until its block state changes.": "يعيد استخدام هندسة التمثال حتى تتغير حالة الكتلة الخاصة به.",
|
||||
"Leaf Culling": "إعدام الأوراق",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "وجه كل ورقة. أوراق الفانيليا الفاخرة.",
|
||||
"Opaque Ice": "جليد معتم",
|
||||
"Draws ice in the solid pass, without its see-through texture.": "يرسم الجليد في مرور الصلب، دون قوامه الشفاف.",
|
||||
"Distant Plant Dither": "ثبات النبات البعيد",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "يحافظ على النباتات الجميلة في عبوات عالية الدقة من البهتان بعيدًا.",
|
||||
"Screenshot Quality": "جودة لقطة الشاشة",
|
||||
@@ -428,7 +587,18 @@
|
||||
"Frame Generation": "جيل الإطار",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "يُظهر إطارًا مكوّنًا بين كل إطارين حقيقيين: أكثر سلاسة وتأخرًا قليلًا.",
|
||||
"Shadow Cache": "ذاكرة التخزين المؤقت الظل",
|
||||
"Shaders": "الشيدرات",
|
||||
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "حزم الشيدر، من Horizon ومن مجلد shaders/، وخياراتها.",
|
||||
"Shader packs": "حزم الشيدر",
|
||||
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "الإعداد المسبق للفانيليا نفسها: يضبط خيارات الفانيليا فقط، كما يفعل Minecraft.",
|
||||
"Fullscreen takes the display over, at the mode below.": "ملء الشاشة يستحوذ على الشاشة، بالوضع أدناه.",
|
||||
"The display mode exclusive fullscreen uses. Applies when you leave.": "وضع الشاشة الذي يستخدمه ملء الشاشة الحصري. يُطبّق عند المغادرة.",
|
||||
"Blurs the world behind menus.": "يضبّب العالم خلف القوائم.",
|
||||
"How textures are sampled: sharper at a slant with Anisotropic.": "كيفية أخذ عينات القوام: أكثر حدة عند الميل مع Anisotropic.",
|
||||
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "مدى وصول تصفية Anisotropic. الأعلى أكثر حدة عند الميل.",
|
||||
"Only with Anisotropic texture filtering.": "فقط مع تصفية القوام Anisotropic.",
|
||||
"Shader Packs...": "حزم التظليل...",
|
||||
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "يختار حزمة شيدر، من Horizon أو من مجلد shaders/، ويضبط خياراتها.",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "يعيد استخدام خريطة ظل الحزمة عندما تتطابق الشمس والعرض والعالم. إيقاف بشكل افتراضي.",
|
||||
"Find BlueMap": "ابحث عن بلو ماب",
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.": "على الخوادم، يتم العثور على خريطة الويب BlueMap الخاصة بهم للتضاريس البعيدة. الانضمام التالي.",
|
||||
@@ -436,6 +606,8 @@
|
||||
"How far around you BlueMap tiles are downloaded. Next join.": "إلى أي مدى يتم تنزيل بلاط BlueMap من حولك. الانضمام التالي.",
|
||||
"Synchronous": "متزامن",
|
||||
"Asynchronous": "غير متزامن",
|
||||
"Hidden Faces": "الأوجه المخفية",
|
||||
"Drops leaf faces behind two leaf layers: faster, same crowns.": "يسقط أوجه الأوراق خلف طبقتَي أوراق: أسرع، والتِيجان نفسها.",
|
||||
"Solid": "صلب",
|
||||
"Solid Aggressive": "العدوانية الصلبة",
|
||||
"Hollow": "جوفاء",
|
||||
@@ -457,6 +629,10 @@
|
||||
"Auto (%s nits)": "تلقائي (%s شمعة في المتر المربع)",
|
||||
"%.2f nits": "%.2f القمل",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "يخفي النماذج خلف الكتل المعتمة. تحديث الكيانات مع حزم مخصصة.",
|
||||
"Entity Culling Precheck": "فحص استبعاد الكيانات المسبق",
|
||||
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "يشغّل فحص AABB للغوغاء بعد هذا العدد من الغوغاء المحمّلين. صفر يعطّله.",
|
||||
"%s mobs": "%s غوغاء",
|
||||
"Only applies to asynchronous entity culling.": "ينطبق فقط على استبعاد الكيانات غير المتزامن.",
|
||||
"horizon.extras.repro_keys": "تسجيل الاختصارات",
|
||||
"horizon.extras.repro_keys.tooltip": "يقوم F3+R ببدء التسجيل النسخي أو إيقافه ويقوم F3+E بكتابة الحزمة الخاصة به. متوقف بشكل افتراضي: تسجيل ينسخ العالم.",
|
||||
"horizon.agent.record_session": "ابدأ جلسة التسجيل",
|
||||
@@ -468,6 +644,17 @@
|
||||
"horizon.repro.error": "استنساخ: %s",
|
||||
"horizon.repro.no_recording": "لا يوجد تسجيل للتصدير حتى الآن.",
|
||||
"horizon.repro.replay_started": "بدأت إعادة التشغيل: %s",
|
||||
"horizon.splitscreen.profiles.offline": " (غير متصل)",
|
||||
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
|
||||
"horizon.splitscreen.profiles.sign_in": "سجّل الدخول مجددًا في المشغّل",
|
||||
"horizon.splitscreen.profiles.online": "Microsoft متصل",
|
||||
"horizon.splitscreen.account_unavailable": "%s غير متصل. سجّل الدخول مجددًا في المشغّل، ثم أعد تشغيل اللعبة.",
|
||||
"horizon.splitscreen.account_expired": "انتهت صلاحية تسجيل دخول %s إلى Microsoft. سجّل الدخول مجددًا في المشغّل، ثم أعد تشغيل اللعبة.",
|
||||
"Particle Culling": "استبعاد الجسيمات",
|
||||
"Draws every particle.": "يرسم كل جسيم.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "يتخطى الجسيمات خارج الشاشة ودون البكسل والمخفية بالتضاريس.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "يتخطى الجسيمات خارج الشاشة ودون البكسل والمخفية بالتضاريس بعمل أقل في خيط العرض.",
|
||||
"horizon.pack.add": "+",
|
||||
"horizon.pack.addInfo": "إضافة حزمة موارد من Modrinth",
|
||||
"horizon.pack.addWorking": "يسأل مودرنث...",
|
||||
"horizon.pack.addNone": "لم يتم العثور على حزم الموارد",
|
||||
@@ -481,10 +668,28 @@
|
||||
"horizon.pack.filter.realistic": "واقعية",
|
||||
"horizon.pack.filter.simplistic": "تبسيط",
|
||||
"horizon.pack.filter.vanillaLike": "تشبه الفانيليا",
|
||||
"horizon.pack.filter.x16": "16x",
|
||||
"horizon.pack.filter.x32": "32x",
|
||||
"horizon.pack.filter.x64": "64x",
|
||||
"horizon.pack.addFailed": "لا يمكن إضافة الحزمة: %s",
|
||||
"horizon.pack.options": "خيارات الحزمة",
|
||||
"horizon.pack.optionsInfo": "خيارات هذه الحزمة",
|
||||
"horizon.pack.options.title": "خيارات الحزمة",
|
||||
"horizon.pack.options.general": "عام",
|
||||
"horizon.pack.options.presets": "إعدادات مسبقة",
|
||||
"horizon.pack.options.presets.custom": "مخصص",
|
||||
"horizon.pack.options.impact": "تأثير الأداء: %s",
|
||||
"horizon.pack.options.impact.low": "منخفض",
|
||||
"horizon.pack.options.impact.medium": "متوسط",
|
||||
"horizon.pack.options.impact.high": "مرتفع",
|
||||
"horizon.pack.options.impact.varies": "يتفاوت",
|
||||
"horizon.extras.header.cosmetics": "مستحضرات التجميل",
|
||||
"horizon.extras.cosmetics_mine": "مستحضرات التجميل الخاصة بي",
|
||||
"horizon.extras.cosmetics_mine.tooltip": "مخفي: يراك كل لاعب في Horizon بدون مستحضرات التجميل الخاصة بك، بما في ذلك أنت. نفس مفتاح خزانة المشغل.",
|
||||
"key.emote": "عجلة التعبيرات",
|
||||
"horizon.emote_wheel.title": "التعبيرات",
|
||||
"horizon.emote_wheel.empty": "لا توجد تعبيرات بعد",
|
||||
"horizon.emote_wheel.help": "أفلت أو انقر للتشغيل · 1–8 · Esc يغلق",
|
||||
"horizon.extras.cosmetics_others": "مستحضرات التجميل للاعبين الآخرين",
|
||||
"horizon.extras.cosmetics_others.tooltip": "ما يرتديه لاعبو Horizon الآخرون: كل شيء، فقط أنماط بطاقة الاسم الخاصة بهم، أو لا شيء. مخفيًا، اللعبة لا تسأل أبدًا عما يرتدونه.",
|
||||
"horizon.extras.cosmetics_hide_fx": "إخفاء آثار وهالات اللاعبين الآخرين",
|
||||
@@ -508,5 +713,349 @@
|
||||
"Pack settings": "إعدادات الحزمة",
|
||||
"Built-in settings": "الإعدادات المدمجة",
|
||||
"%s settings": "إعدادات %s",
|
||||
"Settings made by the pack's author: they change only this pack.": "إعدادات وضعها مؤلف الحزمة: تؤثر على هذه الحزمة فقط."
|
||||
"Settings made by the pack's author: they change only this pack.": "إعدادات وضعها مؤلف الحزمة: تؤثر على هذه الحزمة فقط.",
|
||||
"horizon.moderation.duration.minute": "%s دقيقة",
|
||||
"horizon.moderation.duration.minutes": "%s دقائق",
|
||||
"horizon.moderation.duration.hour": "%s ساعة",
|
||||
"horizon.moderation.duration.hours": "%s ساعات",
|
||||
"horizon.moderation.duration.day": "%s يوم",
|
||||
"horizon.moderation.duration.days": "%s أيام",
|
||||
"horizon.moderation.reason": "%1$s: %2$s",
|
||||
"horizon.moderation.mutelist.empty": "لا توجد حالات كتم",
|
||||
"horizon.moderation.mutelist.count": "هناك %s حالة كتم:",
|
||||
"horizon.moderation.chat_filter": "مرشح الدردشة",
|
||||
"horizon.moderation.until_unmuted": "حتى رفع الكتم",
|
||||
"horizon.moderation.remaining": "لمدة %s أخرى",
|
||||
"horizon.moderation.mutelist.entry": "تم كتم %1$s بواسطة %2$s %3$s",
|
||||
"horizon.moderation.invalid_duration": "%s ليس مدة: 10m، 2h، 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <player> [duration] [reason]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <player> <duration> [reason]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <player>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
"horizon.moderation.for_duration": "لمدة %s",
|
||||
"horizon.moderation.muted": "تم كتم %1$s %2$s",
|
||||
"horizon.moderation.unmuted": "رُفع الكتم عن %s",
|
||||
"horizon.moderation.not_muted": "%s غير مكتوم",
|
||||
"horizon.tempban.usage": "/tempban <player> <duration> [reason]",
|
||||
"horizon.tempban.invalid_duration": "%s ليس مدة: 30m، 12h، 7d",
|
||||
"horizon.tempban.dedicated_required": "تتطلب الحظور المؤقتة خادمًا مخصصًا",
|
||||
"horizon.tempban.success": "%1$s لمدة %2$s، حتى %3$s",
|
||||
"horizon.tempban.list.empty": "لا توجد حظور مؤقتة",
|
||||
"horizon.tempban.list.count": "هناك %s حظر مؤقت:",
|
||||
"horizon.tempban.list.entry": "تم حظر %1$s بواسطة %2$s %3$s (حتى %4$s)",
|
||||
"horizon.options.pointer_speed": "سرعة مؤشر القوائم",
|
||||
"horizon.options.scroll_speed": "سرعة تمرير القوائم",
|
||||
"horizon.options.repeat_delay": "تأخير تكرار التنقل",
|
||||
"horizon.options.repeat_interval": "فاصل تكرار التنقل",
|
||||
"horizon.options.milliseconds": "%s مللي ثانية",
|
||||
"horizon.extras.cosmetics_solo": "في اللاعب الفردي",
|
||||
"horizon.extras.cosmetics_solo.tooltip": "إظهار مستحضرات التجميل وشارة علامة اسمك في عوالم اللاعب الفردي أيضًا (علامة اسمك مع علامة الاسم الخاصة). إيقاف: على الخوادم فقط.",
|
||||
"horizon.extras.horizon_marks": "شعارات Horizon",
|
||||
"horizon.extras.horizon_marks.tooltip": "إظهار شعار Horizon بجانب لاعبي Horizon: شعار صغير قبل أسماء اللاعبين الآخرين الذين يستخدمون Horizon، فوق رؤوسهم وفي قائمة اللاعبين. يراه لاعبو Horizon فقط: لا تتلقى الخوادم شيئًا.",
|
||||
"horizon.extras.horizon_me": "إظهاري كـ Horizon",
|
||||
"horizon.extras.horizon_me.tooltip": "إظهاري كلاعب Horizon: يرى لاعبو Horizon الآخرون الشعار بجانب اسمك. عند التشغيل، تجيب الخدمة أي شخص يسأل عنك أنك لعبت Horizon خلال الساعات الثلاث الماضية؛ وعند الإيقاف، لا تفيد عنك أبدًا.",
|
||||
"horizon.pages.pvp": "PvP وHUD",
|
||||
"horizon.pages.pvp.tooltip": "إعدادات التصويب وHUD وPvP، كلها متوقفة افتراضيًا. لا شيء هنا يُظهر ما لا تُظهره الفانيليا.",
|
||||
"horizon.pages.open.pvp": "PvP وHUD...",
|
||||
"horizon.pvp.header.crosshair": "التصويب",
|
||||
"horizon.pvp.crosshair": "تصويب مخصص",
|
||||
"horizon.pvp.crosshair_shape": "الشكل",
|
||||
"horizon.pvp.crosshair_size": "الحجم",
|
||||
"horizon.pvp.crosshair_gap": "الفجوة",
|
||||
"horizon.pvp.crosshair_thickness": "السماكة",
|
||||
"horizon.pvp.crosshair_colour": "اللون",
|
||||
"horizon.pvp.crosshair_outline": "الحدود",
|
||||
"horizon.pvp.crosshair_dot": "نقطة المركز",
|
||||
"horizon.pvp.crosshair_hover": "اللون على الكيانات",
|
||||
"horizon.pvp.crosshair.tooltip": "يرسم تصويبك الخاص بدلًا من تصويب الفانيليا. إعداداته الأولى تشبه تصويب الفانيليا.",
|
||||
"horizon.pvp.crosshair_shape.tooltip": "صليب أو T أو X أو دائرة أو مربع أو نقطة فقط.",
|
||||
"horizon.pvp.crosshair_size.tooltip": "طول كل ذراع، بكسلات واجهة المستخدم.",
|
||||
"horizon.pvp.crosshair_gap.tooltip": "المسافة الفارغة في الوسط.",
|
||||
"horizon.pvp.crosshair_thickness.tooltip": "عرض كل خط.",
|
||||
"horizon.pvp.crosshair_colour.tooltip": "يعرض Inverted اللون تحته معكوسًا، كما يفعل تصويب الفانيليا.",
|
||||
"horizon.pvp.crosshair_outline.tooltip": "حد أسود رفيع، ليظهر على أي خلفية.",
|
||||
"horizon.pvp.crosshair_dot.tooltip": "نقطة في وسط التصويب.",
|
||||
"horizon.pvp.crosshair_hover.tooltip": "لون آخر حين يكون التصويب فوق غوغاء أو لاعب يمكنك إصابته، كما يعرفه مؤشر هجوم الفانيليا.",
|
||||
"horizon.pvp.colour.inverted": "معكوس",
|
||||
"horizon.pvp.colour.white": "أبيض",
|
||||
"horizon.pvp.colour.red": "أحمر",
|
||||
"horizon.pvp.colour.orange": "برتقالي",
|
||||
"horizon.pvp.colour.yellow": "أصفر",
|
||||
"horizon.pvp.colour.green": "أخضر",
|
||||
"horizon.pvp.colour.cyan": "سماوي",
|
||||
"horizon.pvp.colour.blue": "أزرق",
|
||||
"horizon.pvp.colour.magenta": "أرجواني",
|
||||
"horizon.pvp.colour.black": "أسود",
|
||||
"horizon.pvp.shape.cross": "صليب",
|
||||
"horizon.pvp.shape.t": "T",
|
||||
"horizon.pvp.shape.x": "X",
|
||||
"horizon.pvp.shape.circle": "دائرة",
|
||||
"horizon.pvp.shape.square": "مربع",
|
||||
"horizon.pvp.shape.dot": "نقطة",
|
||||
"horizon.pvp.outline": "حدود كتلة مخصصة",
|
||||
"horizon.pvp.outline.tooltip": "يغيّر الحدود حول الكتلة التي تنظر إليها.",
|
||||
"horizon.pvp.outline_colour": "اللون",
|
||||
"horizon.pvp.outline_colour.tooltip": "لون الحدود. الأسود بنسبة 40% هو لون الفانيليا.",
|
||||
"horizon.pvp.outline_opacity": "العتامة",
|
||||
"horizon.pvp.outline_opacity.tooltip": "مدى صلابة الحدود.",
|
||||
"horizon.pvp.outline_thickness": "السماكة",
|
||||
"horizon.pvp.outline_thickness.tooltip": "عرض الحدود، بمضاعفات عرض الفانيليا.",
|
||||
"horizon.pvp.outline_fill": "التعبئة",
|
||||
"horizon.pvp.outline_fill.tooltip": "يلوّن أوجه الكتلة التي تنظر إليها بلطف.",
|
||||
"horizon.pvp.hit": "لون إصابة مخصص",
|
||||
"horizon.pvp.hit.tooltip": "يغيّر لون وميض الغوغاء واللاعبين عند إصابتهم. الأحمر بنسبة 30% هو لون الفانيليا.",
|
||||
"horizon.pvp.hit_colour": "اللون",
|
||||
"horizon.pvp.hit_colour.tooltip": "اللون الذي يومض به الغوغاء واللاعبون المصابون.",
|
||||
"horizon.pvp.hit_opacity": "العتامة",
|
||||
"horizon.pvp.hit_opacity.tooltip": "مقدار اللون الذي يغطيهم.",
|
||||
"horizon.pvp.header.outline": "حدود الكتلة",
|
||||
"horizon.pvp.header.hit": "لون الإصابة",
|
||||
"horizon.pvp.hud_armour": "العتاد",
|
||||
"horizon.pvp.hud_armour.tooltip": "العتاد الذي ترتديه، من الرأس أولًا، مع عدد الاستخدامات المتبقية لكل قطعة.",
|
||||
"horizon.pvp.hud_armour_corner": "زاوية العتاد",
|
||||
"horizon.pvp.hud_armour_corner.tooltip": "أين يظهر العتاد.",
|
||||
"horizon.pvp.hud_armour_percent": "المتانة بالنسبة المئوية",
|
||||
"horizon.pvp.hud_armour_percent.tooltip": "الاستخدامات المتبقية كنسبة من الحد الأقصى للقطعة بدلًا من رقم.",
|
||||
"horizon.pvp.hud_effects": "التأثيرات",
|
||||
"horizon.pvp.hud_effects.tooltip": "تأثيراتك بأسمائها ومستوياتها ووقتها المتبقي، مكان الأيقونات في أعلى اليمين.",
|
||||
"horizon.pvp.hud_effects_corner": "زاوية التأثيرات",
|
||||
"horizon.pvp.hud_effects_corner.tooltip": "أين تظهر التأثيرات.",
|
||||
"horizon.pvp.hud_held": "عدد العنصر المحمول",
|
||||
"horizon.pvp.hud_held.tooltip": "كم من العنصر في يدك يحمله مخزونك؛ الأسهم مع قوس أو قوس نشاب.",
|
||||
"horizon.pvp.hud_held_corner": "زاوية العدد المحمول",
|
||||
"horizon.pvp.hud_held_corner.tooltip": "أين يظهر العدد.",
|
||||
"horizon.pvp.hud_background": "خلفيات الأدوات",
|
||||
"horizon.pvp.hud_background.tooltip": "صندوق داكن خلف كل أداة.",
|
||||
"horizon.pvp.hud_opacity": "عتامة الخلفية",
|
||||
"horizon.pvp.hud_opacity.tooltip": "مدى قتامة الصناديق خلف الأدوات.",
|
||||
"horizon.pvp.hud_text_colour": "لون النص",
|
||||
"horizon.pvp.hud_text_colour.tooltip": "لون نص الأدوات. تحتفظ المتانة بتدرجها من الأخضر إلى الأحمر.",
|
||||
"horizon.pvp.hud_shadow": "ظل النص",
|
||||
"horizon.pvp.hud_shadow.tooltip": "ظل تحت نص الأدوات، كما لنص HUD في الفانيليا.",
|
||||
"horizon.pvp.header.hud": "أدوات HUD",
|
||||
"horizon.pvp.corner.top_left": "أعلى اليسار",
|
||||
"horizon.pvp.corner.top_right": "أعلى اليمين",
|
||||
"horizon.pvp.corner.bottom_left": "أسفل اليسار",
|
||||
"horizon.pvp.corner.bottom_right": "أسفل اليمين",
|
||||
"horizon.pvp.hud_ping": "البينغ",
|
||||
"horizon.pvp.hud_ping.tooltip": "زمن الاستجابة الذي يبلغ عنه الخادم لك، بالمللي ثانية. متعدد اللاعبين فقط.",
|
||||
"horizon.pvp.hud_ping_corner": "زاوية البينغ",
|
||||
"horizon.pvp.hud_ping_corner.tooltip": "أين يظهر البينغ.",
|
||||
"horizon.pvp.hud_coords": "الإحداثيات",
|
||||
"horizon.pvp.hud_coords.tooltip": "موضعك بالكتل والاتجاه الذي تواجهه، كما يعرضهما F3. مخفي عندما يقلّل العالم أو الخادم من F3.",
|
||||
"horizon.pvp.hud_coords_corner": "زاوية الإحداثيات",
|
||||
"horizon.pvp.hud_coords_corner.tooltip": "أين تظهر الإحداثيات.",
|
||||
"horizon.pvp.hud_server": "عنوان الخادم",
|
||||
"horizon.pvp.hud_server.tooltip": "عنوان الخادم الذي تلعب عليه. متعدد اللاعبين فقط.",
|
||||
"horizon.pvp.hud_server_corner": "زاوية الخادم",
|
||||
"horizon.pvp.hud_server_corner.tooltip": "أين يظهر العنوان.",
|
||||
"horizon.pvp.hud_combo": "عداد الضربات المتتالية",
|
||||
"horizon.pvp.hud_combo.tooltip": "الضربات المتتالية التي أكدها الخادم، حتى تُصاب أو تمرّ 3 ثوانٍ دون ضربة.",
|
||||
"horizon.pvp.hud_combo_corner": "زاوية الضربات المتتالية",
|
||||
"horizon.pvp.hud_combo_corner.tooltip": "أين تظهر الضربات المتتالية.",
|
||||
"horizon.pvp.hud.ping": "البينغ: %s مللي ثانية",
|
||||
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
|
||||
"horizon.pvp.hud.facing": "الاتجاه: %s",
|
||||
"horizon.pvp.hud.combo": "الضربات المتتالية: %s",
|
||||
"horizon.pvp.hud.direction.north": "شمال",
|
||||
"horizon.pvp.hud.direction.south": "جنوب",
|
||||
"horizon.pvp.hud.direction.east": "شرق",
|
||||
"horizon.pvp.hud.direction.west": "غرب",
|
||||
"horizon.pvp.hud_fps": "FPS",
|
||||
"horizon.pvp.hud_fps.tooltip": "الإطارات المرسومة في الثانية الأخيرة.",
|
||||
"horizon.pvp.hud_fps_corner": "زاوية FPS",
|
||||
"horizon.pvp.hud_fps_corner.tooltip": "أين يظهر FPS.",
|
||||
"horizon.pvp.hud_cps": "النقرات في الثانية",
|
||||
"horizon.pvp.hud_cps.tooltip": "نقراتك اليمنى واليسرى في الثانية الأخيرة.",
|
||||
"horizon.pvp.hud_cps_corner": "زاوية CPS",
|
||||
"horizon.pvp.hud_cps_corner.tooltip": "أين تظهر النقرات في الثانية.",
|
||||
"horizon.pvp.hud_keys": "ضغطات المفاتيح",
|
||||
"horizon.pvp.hud_keys.tooltip": "مفاتيح حركتك وأزرار الفأرة والقفز، مضاءة أثناء إمساكها، مع النقرات في الثانية.",
|
||||
"horizon.pvp.hud_keys_corner": "زاوية ضغطات المفاتيح",
|
||||
"horizon.pvp.hud_keys_corner.tooltip": "أين تظهر ضغطات المفاتيح.",
|
||||
"horizon.pvp.hud_clock": "الساعة",
|
||||
"horizon.pvp.hud_clock.tooltip": "وقت حاسوبك.",
|
||||
"horizon.pvp.hud_clock_corner": "زاوية الساعة",
|
||||
"horizon.pvp.hud_clock_corner.tooltip": "أين تظهر الساعة.",
|
||||
"horizon.pvp.hud_reach": "مدى الوصول",
|
||||
"horizon.pvp.hud_reach.tooltip": "إلى أي مدى من عينيك وصلت آخر ضربة، لـ3 ثوانٍ. إنه يقيس فقط: يبقى مدى وصولك كما في الفانيليا.",
|
||||
"horizon.pvp.hud_reach_corner": "زاوية مدى الوصول",
|
||||
"horizon.pvp.hud_reach_corner.tooltip": "أين يظهر مدى الوصول.",
|
||||
"horizon.pvp.hud_speed": "السرعة",
|
||||
"horizon.pvp.hud_speed.tooltip": "سرعة تحركك على الأرض، بالكتل في الثانية.",
|
||||
"horizon.pvp.hud_speed_corner": "زاوية السرعة",
|
||||
"horizon.pvp.hud_speed_corner.tooltip": "أين تظهر السرعة.",
|
||||
"horizon.pvp.hud.fps": "%s FPS",
|
||||
"horizon.pvp.hud.cps": "CPS: %s | %s",
|
||||
"horizon.pvp.hud.reach": "مدى الوصول: %s",
|
||||
"horizon.pvp.hud.speed": "السرعة: %s كتلة/ث",
|
||||
"horizon.pvp.hud.keys.attack": "LMB",
|
||||
"horizon.pvp.hud.keys.use": "RMB",
|
||||
"horizon.pvp.hud.keys.cps": "%s CPS",
|
||||
"horizon.pvp.numeric_enchants": "مستويات السحر كأرقام",
|
||||
"horizon.pvp.numeric_enchants.tooltip": "تُظهر التلميحات وطاولة السحر Sharpness 5 بدلًا من Sharpness V.",
|
||||
"horizon.pvp.header.items": "العناصر",
|
||||
"horizon.pvp.fire_lower": "خفض النار",
|
||||
"horizon.pvp.fire_lower.tooltip": "ينزل النيران التي تراها أثناء الاحتراق، لتغطي مساحة أقل من الشاشة.",
|
||||
"horizon.pvp.shield_size": "حجم الدرع",
|
||||
"horizon.pvp.shield_size.tooltip": "حجم الدرع في يدك، بمنظور الشخص الأول.",
|
||||
"horizon.pvp.totem_size": "حجم حركة التميمة",
|
||||
"horizon.pvp.totem_size.tooltip": "حجم التميمة التي تملأ الشاشة عندما تنقذك.",
|
||||
"horizon.pvp.glint_colour": "لون لمعان السحر",
|
||||
"horizon.pvp.glint_colour.tooltip": "لون اللمعان على العناصر والعتاد المسحور. إيقاف هو أرجواني الفانيليا.",
|
||||
"horizon.pvp.clear_water": "ماء صافٍ",
|
||||
"horizon.pvp.clear_water.tooltip": "تحت الماء، أرِ إلى المدى الذي تسمح به الفانيليا بعد 30 ثانية هناك، من البداية.",
|
||||
"horizon.pvp.clear_lava": "حمم صافية",
|
||||
"horizon.pvp.clear_lava.tooltip": "في الحمم، أرِ إلى المدى الذي تسمح به مقاومة النار في الفانيليا، دون التأثير.",
|
||||
"horizon.pvp.header.visual": "تعديلات بصرية",
|
||||
"horizon.pvp.view_time": "وقت اليوم",
|
||||
"horizon.pvp.view_time.tooltip": "وقت اليوم الذي تراه: السماء وضوؤها فقط. يستمر وقت العالم ولا يُرسل شيء إلى الخادم.",
|
||||
"horizon.pvp.view_weather": "الطقس",
|
||||
"horizon.pvp.view_weather.tooltip": "الطقس الذي تراه: المطر وضوؤه والضباب فقط. يستمر طقس العالم ولا يُرسل شيء إلى الخادم.",
|
||||
"horizon.pvp.header.view": "عرضك",
|
||||
"horizon.pvp.view.world": "عرض العالم",
|
||||
"horizon.pvp.time.morning": "الصباح",
|
||||
"horizon.pvp.time.noon": "الظهر",
|
||||
"horizon.pvp.time.sunset": "الغروب",
|
||||
"horizon.pvp.time.midnight": "منتصف الليل",
|
||||
"horizon.pvp.weather.clear": "صافٍ",
|
||||
"horizon.pvp.weather.rain": "مطر",
|
||||
"horizon.pvp.weather.thunder": "رعد",
|
||||
"horizon.chat.timestamp": "[%s] ",
|
||||
"horizon.chat.repeats": " (x%s)",
|
||||
"horizon.pvp.header.chat": "الدردشة",
|
||||
"horizon.chat.copied": "تم نسخ الرسالة",
|
||||
"horizon.chat.search": "بحث في الدردشة:",
|
||||
"horizon.pvp.chat_timestamps": "الطوابع الزمنية",
|
||||
"horizon.pvp.chat_timestamps.tooltip": "وقت وصول كل رسالة، قبلها.",
|
||||
"horizon.pvp.chat_compact": "دردشة مدمجة",
|
||||
"horizon.pvp.chat_compact.tooltip": "الرسالة المتكررة على التوالي تظهر مرة واحدة، مع عدد مرات وصولها: (x3).",
|
||||
"horizon.pvp.chat_copy": "نسخ بالنقر",
|
||||
"horizon.pvp.chat_copy.tooltip": "النقر على رسالة في الدردشة المفتوحة ينسخ نصها، ما لم يفعل النقر شيئًا آخر.",
|
||||
"horizon.pvp.chat_search": "بحث الدردشة",
|
||||
"horizon.pvp.chat_search.tooltip": "Ctrl+F في الدردشة المفتوحة: ما تكتبه يُظهر فقط الرسائل التي تحتويه. Enter أو Ctrl+F مجددًا يوقفه.",
|
||||
"horizon.pvp.preset": "إعداد PvP المسبق",
|
||||
"horizon.pvp.preset.tooltip": "يفعّل التصويب المخصص وأداة العتاد والتأثيرات وتبديل الركض والتسلل وخفض النار وعداد FPS وتعديلات ضغطات المفاتيح إن كانت مثبتة. تبقى إعداداتك لكل منها.",
|
||||
"horizon.pvp.hud_editor_key": "مفتاح محرر HUD",
|
||||
"horizon.pvp.hud_editor_key.tooltip": "يفتح محرر HUD من اللعبة. انقر، ثم اضغط مفتاحًا؛ Esc يلغي الربط.",
|
||||
"horizon.pvp.editor": "تحرير HUD",
|
||||
"horizon.pvp.editor.tooltip": "حرّك الأدوات في اللعبة: اسحبها إلى أي مكان، وغيّر حجمها بالعجلة، وانقر بزر الفأرة الأيمن على أداة لإعادتها إلى زاويتها.",
|
||||
"horizon.pvp.editor.hint": "اسحب الأدوات · العجلة: الحجم · الزر الأيمن: العودة إلى الزاوية",
|
||||
"horizon.pvp.combat_feel": "إحساس القتال",
|
||||
"horizon.pvp.combat_feel.tooltip": "يرسم Legacy القتال كما في 1.8.9: العنصر في يدك، وضربته، والأكل، والقوس، والسيف المرفوع أثناء إمساك الاستخدام، دون الانخفاض بعد كل ضربة أو مؤشر الهجوم. المظهر فقط: ما ترسله إلى الخادم يبقى كما هو.",
|
||||
"horizon.pvp.combat_indicator": "مؤشر الهجوم في Legacy",
|
||||
"horizon.pvp.combat_indicator.tooltip": "يحتفظ بمؤشر الهجوم في Legacy، الذي لم يكن في 1.8.9.",
|
||||
"horizon.pvp.combat_feel.modern": "حديث",
|
||||
"horizon.pvp.combat_feel.legacy": "Legacy (1.8.9)",
|
||||
"horizon.pvp.header.combat": "إحساس القتال",
|
||||
"horizon.pvp.combat_sounds": "أصوات ضرب 1.8",
|
||||
"horizon.pvp.combat_sounds.tooltip": "في Legacy، يستبعد أصوات الهجوم التي أضافها 1.9 (القوية، الضعيفة، الكنس، الحرجة...). تبقى أصوات الإصابة.",
|
||||
"horizon.pvp.combat_rules": "قتال 1.8 في العوالم التي تستضيفها",
|
||||
"horizon.pvp.combat_rules.tooltip": "اللاعب الفردي والعوالم التي تفتحها على LAN تقاتل بقواعد 1.8.9: لا تبريد هجوم أو كنس، وضرر أسلحة 1.8، والقوة، والحدّة، والضربات الحرجة. الخوادم التي تنضم إليها تحتفظ بقواعدها.",
|
||||
"Bindings": "الربط",
|
||||
"Play": "اللعب",
|
||||
"Device": "الجهاز",
|
||||
"Look": "النظر",
|
||||
"Movement": "الحركة",
|
||||
"Menus": "القوائم",
|
||||
"Actions": "الإجراءات",
|
||||
"Reset": "إعادة الضبط",
|
||||
"Detected": "المكتشف",
|
||||
"Glyphs": "الحروف الرسومية",
|
||||
"Feedback": "التغذية الراجعة",
|
||||
"Controller": "وحدة التحكم",
|
||||
"How the sticks turn and move you, and how menus repeat and scroll.": "كيف تديرك العصي وتحركك، وكيف تتكرر القوائم وتمرّ.",
|
||||
"Which button does what. Each player has their own.": "أي زر يفعل ماذا. لكل لاعب ربطه الخاص.",
|
||||
"The controller the game sees, its button glyphs and its feedback.": "وحدة التحكم التي تراها اللعبة، وحروف أزرارها وتغذيتها الراجعة.",
|
||||
"Press this row, then the button that should do it.": "اضغط هذا الصف، ثم الزر الذي يجب أن يفعل ذلك.",
|
||||
"Off, the game ignores every controller for this player.": "عند الإيقاف، تتجاهل اللعبة كل وحدات التحكم لهذا اللاعب.",
|
||||
"How fast the right stick turns the view.": "مدى سرعة إدارة العصا اليمنى للعرض.",
|
||||
"Turn the view the other way, left and right.": "إدارة العرض في الاتجاه الآخر، يمينًا ويسارًا.",
|
||||
"Turn the view the other way, up and down.": "إدارة العرض في الاتجاه الآخر، أعلى وأسفل.",
|
||||
"How far the right stick moves before the view turns. Raise it if the view drifts.": "إلى أي مدى تتحرك العصا اليمنى قبل أن يدور العرض. ارفعه إذا كان العرض ينزلق.",
|
||||
"Hold the button to sneak, or press it once to start and again to stop.": "أمسك الزر للتسلل، أو اضغطه مرة للبدء ومرة أخرى للتوقف.",
|
||||
"How far the left stick moves before you walk. Raise it if you drift.": "إلى أي مدى تتحرك العصا اليسرى قبل أن تمشي. ارفعه إذا كنت تنزلق.",
|
||||
"How far a trigger is pulled before it counts as pressed.": "إلى أي مدى يُسحب المشغّل قبل أن يُحتسب مضغوطًا.",
|
||||
"Shows what your buttons do right now, in play and in inventories.": "يعرض ما تفعله أزرارك الآن، في اللعب وفي المخزون.",
|
||||
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "يعرض فقط ما يمكنك فعله بما تنظر إليه، وأزرار المخزون الرئيسية.",
|
||||
"Doesn't show what your buttons do.": "لا يعرض ما تفعله أزرارك.",
|
||||
"Shows the buttons of the controller you're using.": "يعرض أزرار وحدة التحكم التي تستخدمها.",
|
||||
"Always shows Xbox buttons.": "يعرض أزرار Xbox دائمًا.",
|
||||
"Always shows PlayStation buttons.": "يعرض أزرار PlayStation دائمًا.",
|
||||
"Always shows Nintendo buttons.": "يعرض أزرار Nintendo دائمًا.",
|
||||
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A، على اليمين، يؤكد ويقفز وB يعود، كما في Switch.",
|
||||
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B، في الأسفل، يؤكد ويقفز وA يعود، كما في وحدات التحكم الأخرى.",
|
||||
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "يفتح لوحة مفاتيح Steam على Steam Deck تحت Steam، ولوحة Horizon فيما عدا ذلك.",
|
||||
"Always opens Horizon's keyboard.": "يفتح لوحة مفاتيح Horizon دائمًا.",
|
||||
"Always opens Steam's keyboard.": "يفتح لوحة مفاتيح Steam دائمًا.",
|
||||
"A DualSense's light shows your health, from green through amber to red.": "يعرض ضوء DualSense صحتك، من الأخضر عبر الكهرماني إلى الأحمر.",
|
||||
"A DualSense's light changes with the dimension you're in.": "يتغير ضوء DualSense مع البعد الذي أنت فيه.",
|
||||
"A DualSense keeps its own light.": "يحتفظ DualSense بضوئه الخاص.",
|
||||
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "يتيح LT + A للصناعة على خوادم غير عوالمك. بعض الخوادم تعتبره ماكرو.",
|
||||
"Show the buttons for what you can do, while you play with a controller.": "إظهار أزرار ما يمكنك فعله أثناء اللعب بوحدة تحكم.",
|
||||
"How fast the stick moves the crosshair in inventories.": "مدى سرعة تحريك العصا للتصويب في المخزون.",
|
||||
"How fast the right stick scrolls a list.": "مدى سرعة تمرير العصا اليمنى للقائمة.",
|
||||
"How long a held direction waits before it repeats.": "كم ينتظر اتجاه ممسوك قبل أن يتكرر.",
|
||||
"How long a held direction takes between repeats.": "كم يستغرق اتجاه ممسوك بين التكرارات.",
|
||||
"Which on-screen keyboard opens when a text field is pressed.": "أي لوحة مفاتيح على الشاشة تُفتح عند الضغط على حقل نص.",
|
||||
"Every action back on its default button.": "إعادة كل إجراء إلى زرّه الافتراضي.",
|
||||
"These settings belong to this player's profile.": "تنتمي هذه الإعدادات إلى الملف الشخصي لهذا اللاعب.",
|
||||
"The controller driving this player, or else the first one connected.": "وحدة التحكم التي تقود هذا اللاعب، أو الأولى المتصلة إن لم توجد.",
|
||||
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "مورّد ومنتج USB كما يبلّغ عنه النظام. مفيد عند الإبلاغ عن وحدة تحكم تسيء التصرف.",
|
||||
"The glyph family the game picked from the controller's maker.": "عائلة الحروف الرسومية التي اختارتها اللعبة من صانع وحدة التحكم.",
|
||||
"Follow the controller, or always show one family's buttons.": "اتبع وحدة التحكم، أو اعرض أزرار عائلة واحدة دائمًا.",
|
||||
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "هل يعمل A وB في وحدة تحكم Nintendo حسب موضعهما أم حسب حرفيهما.",
|
||||
"The buttons menus show right now.": "الأزرار التي تعرضها القوائم الآن.",
|
||||
"Rumble when something happens to you.": "اهتزاز عند حدوث شيء لك.",
|
||||
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "تستمر وحدة التحكم في اللعب بينما هذه النافذة غير مركّزة، وفقدان التركيز لن يوقف اللعب.",
|
||||
"A DualSense's triggers push back on bows and tools.": "تدفع مشغلات DualSense للخلف على الأقواس والأدوات.",
|
||||
"What a DualSense's light shows, over USB or Bluetooth.": "ما يعرضه ضوء DualSense، عبر USB أو Bluetooth.",
|
||||
"Player profile": "ملف اللاعب الشخصي",
|
||||
"Vendor and product": "المورّد والمنتج",
|
||||
"Detected glyphs": "الحروف الرسومية المكتشفة",
|
||||
"Glyphs in use": "الحروف الرسومية المستخدمة",
|
||||
"Unknown": "غير معروف",
|
||||
"No controller": "لا توجد وحدة تحكم",
|
||||
"Editing: %s": "التحرير: %s",
|
||||
"Press the button for this. %s cancels.": "اضغط الزر المخصص لهذا. %s يلغي.",
|
||||
"This button also does: %s": "هذا الزر يفعل أيضًا: %s",
|
||||
"Not bound: press this row, then the button that should do it.": "غير مربوط: اضغط هذا الصف، ثم الزر الذي يجب أن يفعل ذلك.",
|
||||
"horizon.pad.notice.connected.press": "اضغط للاستخدام",
|
||||
"horizon.recipe_transfer.missing": "عناصر ناقصة",
|
||||
"horizon.recipe_transfer.no_room": "المخزون ممتلئ",
|
||||
"horizon.recipe_transfer.unavailable": "الوصفة لا تناسب هذه القائمة",
|
||||
"horizon.recipe_transfer.unsupported": "يتطلب الخادم وصفة معروفة",
|
||||
"horizon.studio.timeline.freecam_on": "الكاميرا الحرة: تشغيل",
|
||||
"horizon.studio.timeline.freecam_off": "الكاميرا الحرة: إيقاف",
|
||||
"horizon.studio.timeline.freecam_short": "كام",
|
||||
"horizon.studio.timeline.freecam_active_short": "كام ✓",
|
||||
"horizon.studio.timeline.add_keyframe_short": "+ مفتاح",
|
||||
"horizon.studio.timeline.delete_keyframe_short": "− حذف",
|
||||
"horizon.studio.timeline.split": "تقسيم",
|
||||
"horizon.studio.timeline.marker": "علامة %s",
|
||||
"horizon.studio.timeline.seconds": "%s ث",
|
||||
"horizon.studio.timeline.scene_count": "%s مشاهد",
|
||||
"horizon.studio.timeline.scene_title": "مشهد %s",
|
||||
"horizon.studio.timeline.sequence": "تسلسل",
|
||||
"horizon.studio.topbar.title": "✦ الاستوديو",
|
||||
"horizon.studio.topbar.file": "ملف",
|
||||
"horizon.studio.topbar.camera": "الكاميرا",
|
||||
"horizon.studio.topbar.view": "عرض",
|
||||
"horizon.studio.topbar.file_short": "ملف",
|
||||
"horizon.studio.topbar.camera_short": "كام",
|
||||
"horizon.studio.topbar.view_short": "عرض",
|
||||
"horizon.studio.topbar.game_pause_menu": "⏸ قائمة الإيقاف المؤقت",
|
||||
"horizon.studio.topbar.pause_short": "إيقاف مؤقت",
|
||||
"horizon.studio.topbar.open_recordings": "📁 فتح مجلد التسجيلات",
|
||||
"horizon.studio.topbar.close_replay": "✕ إغلاق إعادة التشغيل",
|
||||
"horizon.studio.topbar.exit": "خروج",
|
||||
"horizon.studio.topbar.toggle_freecam": "تبديل الكاميرا الحرة",
|
||||
"horizon.studio.topbar.reset_to_player": "👤 العودة إلى اللاعب",
|
||||
"horizon.studio.topbar.unlock_freecam": "🔓 فك قفل الكاميرا الحرة",
|
||||
"horizon.studio.topbar.lock_to_spline": "🔒 القفل على المنحنى",
|
||||
"horizon.studio.topbar.add_keyframe": "➕ إضافة مفتاح إطار",
|
||||
"horizon.studio.topbar.add_marker": "إضافة علامة",
|
||||
"horizon.studio.topbar.clear_keyframes": "🗑 مسح مفاتيح الإطارات",
|
||||
"horizon.studio.topbar.timeline_hud": "⏱ الخط الزمني",
|
||||
"horizon.studio.topbar.split_scene": "تقسيم المشهد"
|
||||
}
|
||||
@@ -147,9 +147,7 @@
|
||||
"Size of menus and the HUD.": "Menyuların ölçüsü və HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Aşağıdakı seçimləri birlikdə təyin edir. Birinin dəyişdirilməsi onu Xüsusi edir.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, od və günəş işığı ağdan daha parlaq parlayır. HDR displey lazımdır.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Ekranınız ən tünd qaradır. OLED üçün 0; LCD-lər bir az parlayır.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Parlaq məqamlar necə əldə edilir. Avtomatik: sisteminizin ən yüksək hesabatı.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Ağ kağız və menyular necə parlaq görünür. Avtomatik: masaüstünüz ağ rəngdədir.",
|
||||
"How bright the HUD and menus are, against paper white.": "HUD və menyular ağ kağıza qarşı nə qədər parlaqdır.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Kütlələr, məhsullar və maşınlar nə qədər işləməyə davam edir. CPU vaxtı.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Nə qədər uzağa mob və əşyalar çəkilir, normal bir pay olaraq.",
|
||||
|
||||
@@ -155,9 +155,7 @@
|
||||
"Size of menus and the HUD.": "Размер меню һәм HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Түбәндәге варианттарҙы бергә ҡуя. Бер үҙгәртеү уны Ҡулланыусы итә.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лава, ут һәм ҡояш нурҙары аҡтан да сағыуыраҡ балҡый. HDR дисплей кәрәк.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Һеҙҙең дисплей’ы иң ҡара ҡара. 0 өсөн OLED; ЖК-дисплейҙар бер аҙ балҡый.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Нисек сағыу өҫтөнлөктәр ала. Авто: пик һеҙҙең система отчеттар.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Нисек сағыу аҡ ҡағыҙ, менюлар күренә. Авто: һеҙҙең өҫтәл’ы аҡ.",
|
||||
"How bright the HUD and menus are, against paper white.": "Нисек сағыу HUD һәм менюлар, ҡағыҙ аҡ ҡаршы.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Күпме алыҫҡа мобтар, культуралар һәм машиналар эшләп тора. Процессор ваҡыты.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Нисек алыҫҡа мобтар һәм әйберҙәр тартыла, өлөшө булараҡ нормаль.",
|
||||
|
||||
@@ -150,9 +150,7 @@
|
||||
"Size of menus and the HUD.": "Памер меню і HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Разам усталёўвае параметры ніжэй. Змена аднаго робіць яго карыстальніцкім.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лава, агонь і сонечнае святло ззяюць ярчэй белага. Патрэбен дысплей HDR.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Самы цёмны чорны на вашым дысплеі. 0 для OLED; ВК-дысплеі трохі свецяцца.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Якія яркія моманты становяцца. Аўта: пік, пра які паведамляе ваша сістэма.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Як ярка выглядаюць белая папера і меню. Аўтаматычна: ваш працоўны стол белы.",
|
||||
"How bright the HUD and menus are, against paper white.": "Наколькі яркія HUD і меню на фоне белай паперы.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Як далёка працягваюць працаваць мобы, ураджай і машыны. працэсарны час.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Наколькі далёка намаляваны мобы і прадметы ў долі звычайнага.",
|
||||
|
||||
@@ -1,29 +1,137 @@
|
||||
{
|
||||
"horizon.extras.header.world": "Свят",
|
||||
"horizon.extras.pregen": "Предварително генериране на парчета",
|
||||
"horizon.extras.pregen.tooltip": "Включва /pregen във вашите светове за един играч и в LAN. С включени чийтове използвайте /pregen start, за да генерирате район, преди да го изследвате. Не включва командата на сървъри, към които се присъединявате.",
|
||||
"horizon.options.controller": "Контролер",
|
||||
"horizon.options.three_d_skin_layers": "3D слоеве на кожата",
|
||||
"horizon.options.vibration": "Вибрация",
|
||||
"horizon.options.play_in_background": "Игра във фон",
|
||||
"horizon.options.adaptive_triggers": "Адаптивни тригери",
|
||||
"horizon.options.gameplay_guide": "Ръководство за играта",
|
||||
"horizon.options.gameplay_guide.basic": "Основно",
|
||||
"horizon.options.gameplay_guide.full": "Пълно",
|
||||
"horizon.options.gameplay_guide.off": "Изкл.",
|
||||
"horizon.controller.guide.use": "Използване",
|
||||
"horizon.controller.guide.open": "Отваряне",
|
||||
"horizon.controller.guide.ride": "Яздене",
|
||||
"horizon.controller.guide.trade": "Търговия",
|
||||
"horizon.controller.guide.feed": "Хранене",
|
||||
"horizon.controller.guide.place": "Поставяне",
|
||||
"horizon.controller.guide.eat": "Ядене",
|
||||
"horizon.controller.guide.drink": "Пиене",
|
||||
"horizon.controller.guide.shoot": "Стрелба",
|
||||
"horizon.controller.guide.load": "Зареждане",
|
||||
"horizon.controller.guide.throw": "Хвърляне",
|
||||
"horizon.controller.guide.raise": "Вдигане",
|
||||
"horizon.controller.guide.equip": "Екипиране",
|
||||
"horizon.controller.guide.fish": "Риболов",
|
||||
"horizon.controller.guide.zoom": "Приближаване",
|
||||
"horizon.controller.guide.boost": "Ускорение",
|
||||
"horizon.controller.guide.break": "Разбиване",
|
||||
"horizon.controller.guide.attack": "Атака",
|
||||
"horizon.controller.guide.spectate": "Наблюдение",
|
||||
"horizon.controller.guide.spectator_menu": "Меню на наблюдателя",
|
||||
"horizon.controller.guide.release_shoot": "Пуснете, за да стреляте",
|
||||
"horizon.controller.guide.release_throw": "Пуснете, за да хвърлите",
|
||||
"horizon.controller.guide.release_stop": "Пуснете, за да спрете",
|
||||
"horizon.controller.guide.fly": "Летене",
|
||||
"horizon.controller.guide.fly_up": "Летене нагоре",
|
||||
"horizon.controller.guide.fly_down": "Летене надолу",
|
||||
"horizon.controller.guide.swim_up": "Плуване нагоре",
|
||||
"horizon.controller.guide.swim_down": "Плуване надолу",
|
||||
"horizon.controller.guide.glide": "Планиране",
|
||||
"horizon.controller.guide.dismount": "Слизане",
|
||||
"horizon.controller.guide.stand": "Изправяне",
|
||||
"horizon.controller.guide.drop": "Изпускане на предмет",
|
||||
"horizon.controller.guide.swap": "Разменяне на ръце",
|
||||
"horizon.controller.guide.take": "Вземане",
|
||||
"horizon.controller.guide.take_half": "Вземане на половина",
|
||||
"horizon.controller.guide.put": "Оставяне",
|
||||
"horizon.controller.guide.put_one": "Оставяне на един",
|
||||
"horizon.controller.guide.swap_stack": "Разменяне",
|
||||
"horizon.controller.guide.quick_move": "Бързо преместване",
|
||||
"horizon.controller.guide.drop_stack": "Изпускане на стека",
|
||||
"horizon.controller.guide.place_recipe": "Поставяне на рецепта",
|
||||
"horizon.controller.guide.place_recipe_all": "Поставяне на всички",
|
||||
"horizon.controller.guide.alternatives": "Алтернативи",
|
||||
"horizon.controller.guide.select": "Избор",
|
||||
"horizon.controller.guide.close": "Затваряне",
|
||||
"horizon.controller.guide.pages": "Страници",
|
||||
"horizon.controller.guide.categories": "Категории",
|
||||
"horizon.controller.guide.tabs": "Раздели",
|
||||
"horizon.options.lightbar": "Светлинна лента",
|
||||
"horizon.options.look_sensitivity": "Чувствителност на погледа",
|
||||
"horizon.options.invert_x": "Обръщане на оста X на погледа",
|
||||
"horizon.options.invert_y": "Обръщане на оста Y на погледа",
|
||||
"horizon.options.craft_on_servers": "Бързо изработване на сървъри",
|
||||
"horizon.controller.direct_craft": "Бързо изработване",
|
||||
"horizon.options.sneak_button": "Бутон за промъкване",
|
||||
"horizon.options.move_deadzone": "Мъртва зона на движението",
|
||||
"horizon.options.look_deadzone": "Мъртва зона на погледа",
|
||||
"horizon.options.trigger_threshold": "Праг на тригера",
|
||||
"horizon.options.button_glyphs": "Глифове на бутони",
|
||||
"horizon.options.on_screen_keyboard": "Екранна клавиатура",
|
||||
"horizon.options.chat_delay": "Забавяне на чата",
|
||||
"horizon.options.lightbar.health": "здраве",
|
||||
"horizon.options.lightbar.dimension": "Измерение",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.nintendo_buttons": "Бутони на Nintendo",
|
||||
"horizon.options.nintendo_buttons.position": "По позиция",
|
||||
"horizon.options.nintendo_buttons.letter": "По буква",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Пара",
|
||||
"horizon.options.unavailable": "Тази функция все още не е налична в Horizon.",
|
||||
"horizon.options.search": "Настройки за търсене...",
|
||||
"horizon.studio.record.usage": "Употреба: /studio record <start [name]|stop|status>",
|
||||
"horizon.studio.record.started": "Записът в Studio започна: %s",
|
||||
"horizon.studio.record.saved": "Записът в Studio е запазен: %s",
|
||||
"horizon.studio.record.error": "Грешка при запис в Studio: %s",
|
||||
"horizon.studio.record.status.active": "Записът в Studio е активен — %s с, %s пакета, %s KB (%s)",
|
||||
"horizon.studio.record.status.inactive": "Записът в Studio е неактивен",
|
||||
"horizon.studio.replay.usage": "Употреба: /studio replay <open PATH|pause|resume|speed MULTIPLIER|seek MS|status|close>",
|
||||
"horizon.studio.replay.open.usage": "Употреба: /studio replay open <PATH>",
|
||||
"horizon.studio.replay.opened": "Възпроизвеждането в Studio е отворено: %s (%s с, %s пакета)",
|
||||
"horizon.studio.replay.open_error": "Възпроизвеждането не можа да се отвори: %s",
|
||||
"horizon.studio.replay.paused": "Възпроизвеждането в Studio е на пауза",
|
||||
"horizon.studio.replay.resumed": "Възпроизвеждането в Studio продължи",
|
||||
"horizon.studio.replay.inactive": "Няма активно възпроизвеждане в Studio",
|
||||
"horizon.studio.replay.speed.usage": "Употреба: /studio replay speed <MULTIPLIER>",
|
||||
"horizon.studio.replay.speed.set": "Скоростта на възпроизвеждане в Studio е зададена на %s×",
|
||||
"horizon.studio.replay.speed.invalid": "Невалидна скорост на възпроизвеждане",
|
||||
"horizon.studio.replay.seek.usage": "Употреба: /studio replay seek <MILLISECONDS>",
|
||||
"horizon.studio.replay.seeked": "Възпроизвеждането в Studio прескочи до %s ms (%s с)",
|
||||
"horizon.studio.replay.seek.error": "Възпроизвеждането не можа да прескочи: %s",
|
||||
"horizon.studio.replay.seek.backward_requires_standalone": "Превъртането назад е налично само при самостоятелно възпроизвеждане.",
|
||||
"horizon.studio.replay.seek.invalid": "Невалидна позиция на възпроизвеждане",
|
||||
"horizon.studio.replay.status": "Възпроизвеждане в Studio: %s / %s ms (%s%%), скорост %s×, на пауза %s, завършено %s",
|
||||
"horizon.studio.replay.closed": "Възпроизвеждането в Studio е затворено",
|
||||
"horizon.studio.freecam.enabled": "Свободната камера в Studio е включена. Използвайте WASD за движение, Space и Shift за издигане или спускане и Control за по-бързо движение.",
|
||||
"horizon.studio.freecam.disabled": "Свободната камера в Studio е изключена",
|
||||
"horizon.studio.freecam.unavailable": "Свободната камера в Studio не намери камера на играч",
|
||||
"horizon.studio.freecam.replay_required": "Свободната камера в Studio е налична при възпроизвеждане на запис",
|
||||
"horizon.studio.freecam.usage": "Употреба: /studio freecam <on|off>",
|
||||
"horizon.studio.keyframe.added": "Добавен е ключов кадър на камерата на %s ms. Пътят се запазва, когато затворите възпроизвеждането.",
|
||||
"horizon.studio.keyframe.cleared": "Пътят на камерата е изчистен. Промяната се запазва, когато затворите възпроизвеждането.",
|
||||
"horizon.studio.keyframe.removed": "Премахнат е ключов кадър на камерата %s. Промяната се запазва, когато затворите възпроизвеждането.",
|
||||
"horizon.studio.keyframe.save_error": "Пътят на камерата не можа да се запази: %s",
|
||||
"horizon.studio.keyframe.remove_usage": "Употреба: /studio keyframe remove <INDEX>",
|
||||
"horizon.studio.keyframe.index_invalid": "Индексът на ключовия кадър е извън диапазона",
|
||||
"horizon.studio.keyframe.usage": "Употреба: /studio keyframe <add|remove INDEX|clear>",
|
||||
"horizon.studio.path.locked": "Възпроизвеждането на пътя на камерата в Studio е включено",
|
||||
"horizon.studio.path.unlocked": "Възпроизвеждането на пътя на камерата в Studio е спряно",
|
||||
"horizon.studio.path.usage": "Употреба: /studio path <lock|unlock> (първо добавете поне един ключов кадър)",
|
||||
"horizon.pages.mods": "Модификации",
|
||||
"horizon.pages.shader_packs": "Шейдърни пакети",
|
||||
"horizon.pages.shader_options": "Опции на шейдъра",
|
||||
"horizon.pages.controller": "Настройки на контролера",
|
||||
"horizon.pages.controller_binds": "Контролерът се свързва",
|
||||
"horizon.pages.extras": "Екстри на Horizon",
|
||||
"horizon.pages.settings": "Настройки на Horizon",
|
||||
"horizon.pages.studio": "Replay Studio",
|
||||
"horizon.pages.agent": "AI агент",
|
||||
"horizon.split.leave": "тръгвай",
|
||||
"horizon.pages.split_settings": "Настройки за разделен екран",
|
||||
@@ -44,6 +152,7 @@
|
||||
"horizon.pages.music.empty": "Няма музикални записи: звуците на играта не се зареждат.",
|
||||
"horizon.pages.music.tooltip": "Вградените песни според ситуацията: пуснете една сега или изключете някоя, така че играта да спре да я избира.",
|
||||
"horizon.pages.settings.tooltip": "Собствени настройки на Horizon: графики, контролери, екстри и модове.",
|
||||
"horizon.pages.studio.tooltip": "Разгледайте запазените записи и тяхната информация.",
|
||||
"horizon.pages.graphics.tooltip": "Графика просто: всяка настройка казва колко струва.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Всяка графична настройка, включително Horizon, с цената на всяка.",
|
||||
"horizon.pages.controller.tooltip": "Контролери: техните бутони, стикове, вибрация и навигация в менюто.",
|
||||
@@ -61,15 +170,47 @@
|
||||
"horizon.pages.open.extras": "Екстри...",
|
||||
"horizon.pages.open.mods": "Модове...",
|
||||
"horizon.pages.open.agent": "AI агент...",
|
||||
"horizon.pages.open.horizon_graphics": "Графика на Horizon...",
|
||||
"horizon.pages.open.controller_binds": "Контролерът се свързва...",
|
||||
"horizon.pages.open.horizon_settings": "Настройки на Horizon...",
|
||||
"horizon.pages.open.studio": "Replay Studio...",
|
||||
"horizon.studio.saved_recordings": "Запазени записи",
|
||||
"horizon.studio.empty": "В папката със записи не са намерени записи.",
|
||||
"horizon.studio.empty_hint": "Използвайте /studio record start <name> в свят или сложете там .mcpr файлове.",
|
||||
"horizon.studio.open_folder": "Отворете записите",
|
||||
"horizon.studio.open_replay": "Пуснете записа",
|
||||
"horizon.studio.refresh": "Опреснете",
|
||||
"horizon.studio.unknown_server": "Неизвестно",
|
||||
"horizon.pages.open.music": "музика...",
|
||||
"horizon.mods.empty": "Без модификации: поставете .hmod файлове в папката horizon-mods",
|
||||
"horizon.mods.stopped": " (спряно)",
|
||||
"horizon.mods.not_built": " (Не е построен)",
|
||||
"horizon.mods.dev": " (Разраб.)",
|
||||
"horizon.mods.settings": "Настройки",
|
||||
"horizon.mods.in_world": " (оставете света да се промени)",
|
||||
"horizon.mods.open_folder": "Отворете папката на Mod",
|
||||
"horizon.mods.browse": "Разглеждане на модове...",
|
||||
"horizon.mods.browse.title": "Разглеждане на модове",
|
||||
"horizon.mods.browse.search": "Търсене на модове...",
|
||||
"horizon.mods.browse.project": "Мод",
|
||||
"horizon.mods.browse.loading": "Четене на регистъра с модове на Horizon...",
|
||||
"horizon.mods.browse.failed": "Регистърът с модове не можа да се прочете: %s",
|
||||
"horizon.mods.browse.none": "Няма намерен мод",
|
||||
"horizon.mods.browse.has_update": " (актуализация)",
|
||||
"horizon.mods.browse.is_installed": " (инсталиран)",
|
||||
"horizon.mods.browse.by": "от %s",
|
||||
"horizon.mods.browse.installing": "Инсталиране...",
|
||||
"horizon.mods.browse.install": "Инсталирайте",
|
||||
"horizon.mods.browse.update": "Актуализирайте",
|
||||
"horizon.mods.browse.installed": "Инсталиран",
|
||||
"horizon.mods.browse.open_page": "Отворете страницата",
|
||||
"horizon.mods.browse.enabled": "Мод",
|
||||
"horizon.mods.browse.changelog": "Списък с промени",
|
||||
"horizon.mods.browse.done": "%s %s е инсталиран и включен.",
|
||||
"horizon.mods.browse.install_failed": "Не можа да се инсталира: %s",
|
||||
"horizon.mods.switch.worldgen": "променя генерирането на света",
|
||||
"horizon.mods.switch.registries": "добавя блокове, предмети или други записи в регистъра",
|
||||
"horizon.mods.switch.restart": "Рестартирайте играта, за да приложите %s: те добавят блокове, предмети или генериране на света.",
|
||||
"horizon.shader_options.profile": "Профил",
|
||||
"horizon.agent.copy_details": "Копиране на подробности за връзката",
|
||||
"horizon.multiplayer.join": "Присъединете се",
|
||||
@@ -81,6 +222,7 @@
|
||||
"LOD Distance": "LOD разстояние",
|
||||
"Far Detail": "Далечен детайл",
|
||||
"Cave Culling": "Пещерно избиване",
|
||||
"GPU Culling": "Отстраняване чрез GPU",
|
||||
"Entity Culling": "Отстраняване на субекти",
|
||||
"Chunk Updates": "Актуализации на парчета",
|
||||
"Upload Budget": "Бюджет за качване",
|
||||
@@ -92,11 +234,17 @@
|
||||
"Frame Pacing": "Темпо на рамката",
|
||||
"Worker Threads": "Работни нишки",
|
||||
"Resolution Scale": "Скала на резолюцията",
|
||||
"Anti-aliasing": "Антиалиасинг",
|
||||
"Upscaling": "Премащабиране",
|
||||
"Upscaling Sharpness": "Рязкост на премащабирането",
|
||||
"Paper White": "Бяла хартия",
|
||||
"Peak Brightness": "Пикова яркост",
|
||||
"Black Level": "Черно ниво",
|
||||
"UI Brightness": "Яркост на потребителския интерфейс",
|
||||
"HDR Lighting": "HDR осветление",
|
||||
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Източниците на светлина, слънцето и слънчевата светлина блестят отвъд бялото. Изкл.: точно като SDR.",
|
||||
"Smooth Gradients": "Плавни градиенти",
|
||||
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "16-битов кадър: тъмните небеса и сенки не се разслояват. Малко повече VRAM. Следващ старт.",
|
||||
"Battery Mode": "Режим на батерия",
|
||||
"Graphics API": "Графичен API",
|
||||
"Translucent Batching": "Полупрозрачно пакетиране",
|
||||
@@ -118,20 +266,32 @@
|
||||
"Efficient": "Ефективно",
|
||||
"Precise": "Прецизен",
|
||||
"Auto (VSync)": "Автоматично (VSync)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "Пощенска кутия",
|
||||
"Immediate": "Незабавно",
|
||||
"Vulkan": "Vulkan",
|
||||
"DirectX 12": "DirectX 12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS и графика",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "Без разходи",
|
||||
"Low cost": "Ниска цена",
|
||||
"Medium cost": "Средна цена",
|
||||
"High cost": "Висока цена",
|
||||
"Saves time": "Спестява време",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "Графика",
|
||||
"Done": "Готово",
|
||||
"This feature is not yet available in Horizon.": "Тази функция все още не е налична в Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Вашият дисплей или система не предлага HDR изход.",
|
||||
"Turn HDR on to use it.": "Включете HDR, за да го използвате.",
|
||||
"Your GPU or graphics API doesn't support it.": "Вашият GPU или графичен API не го поддържат.",
|
||||
"Not with software rendering, which runs it on the CPU.": "Не при софтуерно изобразяване, което го изпълнява на CPU.",
|
||||
"Out of video memory: off until the next start.": "Изчерпана видеопамет: изключено до следващия старт.",
|
||||
"The graphics device was lost: off until the next start.": "Графичното устройство беше загубено: изключено до следващия старт.",
|
||||
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Още не при DirectX 12: опитайте с графичния API Vulkan.",
|
||||
"%s chunks": "%s парчета",
|
||||
"%s per frame": "%s на кадър",
|
||||
"%s MiB/frame": "%s MiB/кадър",
|
||||
@@ -147,13 +307,15 @@
|
||||
"Size of menus and the HUD.": "Размер на менютата и HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Задава заедно опциите по-долу. Промяната на един го прави персонализиран.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лавата, огънят и слънчевата светлина блестят по-ярко от бялото. Има нужда от HDR дисплей.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Вашият дисплей е най-тъмно черен. 0 за OLED; LCD дисплеите светят малко.",
|
||||
"Lifts shadows onto your display's darkest black. 0: true black, no lift.": "Повдига сенките до най-тъмното черно на вашия дисплей. 0: истинско черно, без повдигане.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Колко ярки стават акцентите. Автоматично: пикът, който вашата система отчита.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Колко ярки изглеждат бялата хартия и менютата. Автоматично: вашият работен плот е бял.",
|
||||
"How bright white looks in the world. Auto: your desktop's white.": "Колко ярко изглежда бялото в света. Авто: бялото на вашия работен плот.",
|
||||
"How bright the HUD and menus are, against paper white.": "Колко ярки са HUD и менютата на бяла хартия.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Докъде продължават да работят тълпите, културите и машините. процесорно време.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Колко далеч са изтеглени тълпите и предметите, като дял от нормалното.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропуска терен, скрит зад затворени участъци. Off струва много.",
|
||||
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Отстранява близкия терен на GPU: по-малко CPU при голямо разстояние на изобразяване, повече GPU.",
|
||||
"Auto: tries it where your CPU limits the frame, keeps it if frames get faster.": "Авто: опитва го там, където CPU ограничава кадъра, и го запазва, ако кадрите станат по-бързи.",
|
||||
"Hides entities behind opaque blocks.": "Скрива обекти зад непрозрачни блокове.",
|
||||
"No entity occlusion culling.": "Без отстраняване на оклузия на обект.",
|
||||
"CPU occlusion test runs this frame.": "Тестът за запушване на процесора изпълнява този кадър.",
|
||||
@@ -177,6 +339,7 @@
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Световни пиксели на пиксел на екрана. Под 100% е по-бързо на GPU.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Изглажда назъбените ръбове. FXAA: по-мек. TAA: най-плавните, движещи се неща могат да се проточат.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Прави света по-малък и го възстановява с TAA. По-бързо, по-меко.",
|
||||
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Изостря премащабираната картина. По-високо: по-ясни ръбове, повече трептене.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR изход на HDR дисплей: лава, огън и слънчева светлина блестят по-ярко.",
|
||||
"How frames reach the screen while VSync is off.": "Как кадрите достигат до екрана, докато VSync е изключен.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Ефективно заспиване до следващия кадър; Прецизни завъртания, като ванилия.",
|
||||
@@ -196,6 +359,7 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "Скорост на кадрите и времена на кадрите в играта, с графика.",
|
||||
"horizon.multiplayer.finding_friend": "Намиране на света на вашия приятел...",
|
||||
"horizon.multiplayer.not_signed_in": "Този сървър е в онлайн режим. Влезте с вашия акаунт в Microsoft в стартовия панел на Horizon, за да се присъедините към него.",
|
||||
"horizon.realms.not_signed_in": "Влезте с вашия акаунт в Microsoft в стартовия панел на Horizon, за да играете в Realms.",
|
||||
"horizon.multiplayer.invalid_address": "Невалиден адрес на сървъра: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Свързан директно с вашия приятел...",
|
||||
"horizon.multiplayer.connected_friend_relay": "Свързан с вашия приятел чрез релето...",
|
||||
@@ -225,6 +389,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "По-ярък от бялото изход на HDR дисплей.",
|
||||
"Results for \"%s\"": "Резултати за \"%s\"",
|
||||
"No settings match.": "Няма съответстващи настройки.",
|
||||
"horizon.plain.official": "Официалният Horizon със стартов панел, модове и актуализации: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Не е свързан с Mojang или Microsoft",
|
||||
"horizon.extras.header.lighting": "Осветление",
|
||||
"horizon.extras.header.third_person": "Трето лице",
|
||||
@@ -248,9 +413,12 @@
|
||||
"horizon.extras.no_ai": "Без AI",
|
||||
"horizon.extras.agent_control": "Агентски контрол",
|
||||
"horizon.extras.discord_presence": "Богато присъствие на Discord",
|
||||
"horizon.extras.zoom": "Приближаване",
|
||||
"horizon.extras.zoom_mod_install": "Приближаване: инсталирайте от Модове",
|
||||
"horizon.extras.splitscreen": "Разделен екран",
|
||||
"horizon.extras.chat_animation": "Плавен чат",
|
||||
"horizon.extras.chat_indicator": "Индикатор за съобщение",
|
||||
"horizon.extras.wheel_cycles_list_settings.tooltip": "Колелцето за превъртане върху бутон в списък с настройки променя стойността му. Изключено, както във ванилия, колелцето превърта списъка.",
|
||||
"horizon.extras.wheel_cycles_settings": "Колелото за превъртане променя настройките",
|
||||
"horizon.extras.pack_browser": "Браузър за пакети с ресурси",
|
||||
"horizon.extras.fullbright.tooltip": "Показвайте блокове и обекти при пълна светлина, дори в тъмни зони.",
|
||||
@@ -311,9 +479,11 @@
|
||||
"horizon.friends.stopped": "Спря споделянето на кода за покана.",
|
||||
"horizon.friends.short_code_changed": "Някой продължаваше да опитва грешен кратък код, така че той се промени.",
|
||||
"horizon.friends.wrong_code": "Някой е опитал грешен код.",
|
||||
"horizon.friends.relay_stopped": "Твърде много грешни кратки кодове, затова релето е изключено, докато не споделите отново. Приятелите все още могат да се присъединят с пълния код.",
|
||||
"horizon.friends.code_ready": "Вашият код за покана е готов: Меню за игри, Играйте с приятели. Копирайте го и го изпратете на приятелите си.",
|
||||
"horizon.friends.share_failed": "Не можах да споделя света: %s",
|
||||
"horizon.friends.finding_route": "Търся как приятелите могат да се свържат с вас...",
|
||||
"horizon.osk.shift": "Shift",
|
||||
"horizon.osk.space": "пространство",
|
||||
"horizon.osk.delete": "Изтриване",
|
||||
"horizon.osk.type": "Тип",
|
||||
@@ -329,6 +499,7 @@
|
||||
"horizon.splitscreen.setup.new_window": "Нов прозорец",
|
||||
"horizon.splitscreen.setup.fill_monitor": "Попълнете %s",
|
||||
"horizon.splitscreen.setup.done": "Готово",
|
||||
"horizon.splitscreen.setup.switch": "%s: %s",
|
||||
"horizon.splitscreen.setup.on": "включено",
|
||||
"horizon.splitscreen.setup.off": "Изкл",
|
||||
"horizon.splitscreen.setup.player": "Играч %s",
|
||||
@@ -386,6 +557,9 @@
|
||||
"horizon.splitscreen.setup.help.give_up_pad": "Вашият контролер спира да ви кара и получава предложение да се присъедини (A).",
|
||||
"horizon.splitscreen.setup.note.held_at": "%s до %s: A го пуска тук, B го връща обратно.",
|
||||
"horizon.splitscreen.setup.note.held": "%s взето: ляв/десен избира къде, A пуска, B го връща обратно.",
|
||||
"horizon.splitscreen.online_only": "Този сървър е в онлайн режим: офлайн профилите не могат да се присъединят, всеки играч се нуждае от собствен акаунт в Minecraft.",
|
||||
"horizon.splitscreen.own_account": "Този сървър изисква собствен акаунт в Minecraft за всеки играч",
|
||||
"horizon.splitscreen.local_only": "Другите играчи могат да се присъединят към този сървър само със свой акаунт в Minecraft: офлайн профилите играят във вашия свят, в LAN или на сървър в локалната ви мрежа.",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Скрива модели зад непрозрачни блокове. Async проверява извън нишката; субекти отметка.",
|
||||
"Block Entities": "Блокиране на обекти",
|
||||
"Shulker Boxes": "Кутии Шулкер",
|
||||
@@ -404,11 +578,26 @@
|
||||
"Reuses statue geometry until its block state changes.": "Използва повторно геометрията на статуята, докато блоковото й състояние се промени.",
|
||||
"Leaf Culling": "Изрязване на листа",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Всеки лист лице. Елегантните листа на ванилия.",
|
||||
"Opaque Ice": "Непрозрачен лед",
|
||||
"Draws ice in the solid pass, without its see-through texture.": "Рисува леда в плътния проход, без прозрачната му текстура.",
|
||||
"Distant Plant Dither": "Дитеринг на далечни растения",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Предпазва фините растения в пакети с висока разделителна способност от избледняване.",
|
||||
"Screenshot Quality": "Качество на екранната снимка",
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Екранни снимки на F2 при толкова многократна разделителна способност на играта. По-високото отнема повече време.",
|
||||
"Frame Generation": "Генериране на рамка",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Показва измислен кадър между всеки два реални: по-гладък, с малко повече забавяне.",
|
||||
"Shadow Cache": "Кеш на сенките",
|
||||
"Shaders": "Шейдъри",
|
||||
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Шейдърни пакети — на Horizon и от папката shaders/ — и техните опции.",
|
||||
"Shader packs": "Шейдърни пакети",
|
||||
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "Собствената предварителна настройка на ванилия: задава само опциите на ванилия, както прави Minecraft.",
|
||||
"Fullscreen takes the display over, at the mode below.": "Целият екран поема дисплея, в режима по-долу.",
|
||||
"The display mode exclusive fullscreen uses. Applies when you leave.": "Режимът на дисплея, който използва ексклузивният цял екран. Прилага се при излизане.",
|
||||
"Blurs the world behind menus.": "Замъглява света зад менютата.",
|
||||
"How textures are sampled: sharper at a slant with Anisotropic.": "Как се семплират текстурите: по-ясно под наклон с анизотропно.",
|
||||
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "Докъде стига анизотропното филтриране. По-високо е по-ясно под наклон.",
|
||||
"Only with Anisotropic texture filtering.": "Само с анизотропно филтриране на текстурите.",
|
||||
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "Избира шейдърен пакет — на Horizon или от папката shaders/ — и задава опциите му.",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Използва повторно картата на сенките на глутницата, когато слънцето, гледката и света съвпадат. Изключено по подразбиране.",
|
||||
"Find BlueMap": "Намерете BlueMap",
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.": "На сървъри намира тяхната уеб карта BlueMap за далечен терен. Следващо присъединяване.",
|
||||
@@ -416,6 +605,8 @@
|
||||
"How far around you BlueMap tiles are downloaded. Next join.": "На какво разстояние около вас се изтеглят плочките BlueMap. Следващо присъединяване.",
|
||||
"Synchronous": "Синхронен",
|
||||
"Asynchronous": "Асинхронен",
|
||||
"Hidden Faces": "Скрити лица",
|
||||
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Изпуска листните лица зад два слоя листа: по-бързо, същите корони.",
|
||||
"Solid": "Твърди",
|
||||
"Solid Aggressive": "Твърди агресивни",
|
||||
"Hollow": "Кух",
|
||||
@@ -437,6 +628,10 @@
|
||||
"Auto (%s nits)": "Автоматично (%s нита)",
|
||||
"%.2f nits": "%.2f нита",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Скрива модели зад непрозрачни блокове. Актуализация на обекти. Изключване с персонализирани пакети.",
|
||||
"Entity Culling Precheck": "Предварителна проверка на отстраняването",
|
||||
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Изпълнява предварителната проверка на AABB за тълпите след толкова заредени тълпи. Нула я изключва.",
|
||||
"%s mobs": "%s тълпи",
|
||||
"Only applies to asynchronous entity culling.": "Прилага се само при асинхронно отстраняване на обекти.",
|
||||
"horizon.extras.repro_keys": "Преки пътища за запис",
|
||||
"horizon.extras.repro_keys.tooltip": "F3+R стартира или спира възпроизвеждане на запис и F3+E записва неговия пакет. Изключено по подразбиране: записът копира света.",
|
||||
"horizon.agent.record_session": "Започнете запис на сесия",
|
||||
@@ -448,7 +643,19 @@
|
||||
"horizon.repro.error": "Репро: %s",
|
||||
"horizon.repro.no_recording": "Все още няма запис за експортиране.",
|
||||
"horizon.repro.replay_started": "Стартирало повторение: %s",
|
||||
"horizon.splitscreen.profiles.offline": " (Офлайн)",
|
||||
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
|
||||
"horizon.splitscreen.profiles.sign_in": "Влезте отново в стартовия панел",
|
||||
"horizon.splitscreen.profiles.online": "Microsoft онлайн",
|
||||
"horizon.splitscreen.account_unavailable": "%s е офлайн. Влезте отново в стартовия панел, след което рестартирайте играта.",
|
||||
"horizon.splitscreen.account_expired": "Влизането на %s в Microsoft е изтекло. Влезте отново в стартовия панел, след което рестартирайте играта.",
|
||||
"Particle Culling": "Отстраняване на частици",
|
||||
"Draws every particle.": "Рисува всяка частица.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "Пропуска частици извън екрана, подпикселни и скрити от терена.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Пропуска частици извън екрана, подпикселни и скрити от терена с по-малко натоварване на нишката за изобразяване.",
|
||||
"horizon.pack.add": "+",
|
||||
"horizon.pack.addInfo": "Добавете ресурсен пакет от Modrinth",
|
||||
"horizon.pack.addWorking": "Питаме Modrinth...",
|
||||
"horizon.pack.addNone": "Няма намерени пакети с ресурси",
|
||||
"horizon.pack.addTitle": "Добавете пакет с ресурси",
|
||||
"horizon.pack.install": "Инсталирайте",
|
||||
@@ -460,10 +667,28 @@
|
||||
"horizon.pack.filter.realistic": "Реалистичен",
|
||||
"horizon.pack.filter.simplistic": "Опростено",
|
||||
"horizon.pack.filter.vanillaLike": "Подобно на ванилия",
|
||||
"horizon.pack.filter.x16": "16x",
|
||||
"horizon.pack.filter.x32": "32x",
|
||||
"horizon.pack.filter.x64": "64x",
|
||||
"horizon.pack.addFailed": "Пакетът не можа да бъде добавен: %s",
|
||||
"horizon.pack.options": "Опции на пакета",
|
||||
"horizon.pack.optionsInfo": "Опции на този пакет",
|
||||
"horizon.pack.options.title": "Опции на пакета",
|
||||
"horizon.pack.options.general": "Общи",
|
||||
"horizon.pack.options.presets": "Предварителни настройки",
|
||||
"horizon.pack.options.presets.custom": "Персонализирани",
|
||||
"horizon.pack.options.impact": "Влияние върху производителността: %s",
|
||||
"horizon.pack.options.impact.low": "ниско",
|
||||
"horizon.pack.options.impact.medium": "средно",
|
||||
"horizon.pack.options.impact.high": "високо",
|
||||
"horizon.pack.options.impact.varies": "различно",
|
||||
"horizon.extras.header.cosmetics": "Козметика",
|
||||
"horizon.extras.cosmetics_mine": "Моята козметика",
|
||||
"horizon.extras.cosmetics_mine.tooltip": "Скрит: всеки играч на Horizon ви вижда без вашата козметика, включително и вие. Същият превключвател като гардероба на стартера.",
|
||||
"key.emote": "Колело за емоции",
|
||||
"horizon.emote_wheel.title": "Емоции",
|
||||
"horizon.emote_wheel.empty": "Още няма емоции",
|
||||
"horizon.emote_wheel.help": "Пуснете или щракнете за възпроизвеждане · 1–8 · Esc затваря",
|
||||
"horizon.extras.cosmetics_others": "Козметика за други играчи",
|
||||
"horizon.extras.cosmetics_others.tooltip": "Какво носят другите играчи на Horizon: всичко, само техните стилове на имената или нищо. Скрито, играта никога не пита какво носят.",
|
||||
"horizon.extras.cosmetics_hide_fx": "Скриване на следите и аурите на другите играчи",
|
||||
@@ -488,5 +713,349 @@
|
||||
"Pack settings": "Настройки на пакета",
|
||||
"Built-in settings": "Вградени настройки",
|
||||
"%s settings": "Настройки на %s",
|
||||
"Settings made by the pack's author: they change only this pack.": "Настройки, направени от автора на пакета: променят само този пакет."
|
||||
"Settings made by the pack's author: they change only this pack.": "Настройки, направени от автора на пакета: променят само този пакет.",
|
||||
"horizon.moderation.duration.minute": "%s минута",
|
||||
"horizon.moderation.duration.minutes": "%s минути",
|
||||
"horizon.moderation.duration.hour": "%s час",
|
||||
"horizon.moderation.duration.hours": "%s часа",
|
||||
"horizon.moderation.duration.day": "%s ден",
|
||||
"horizon.moderation.duration.days": "%s дни",
|
||||
"horizon.moderation.reason": "%1$s: %2$s",
|
||||
"horizon.moderation.mutelist.empty": "Няма заглушени",
|
||||
"horizon.moderation.mutelist.count": "Има %s заглушени:",
|
||||
"horizon.moderation.chat_filter": "филтърът за чат",
|
||||
"horizon.moderation.until_unmuted": "до отмяна на заглушаването",
|
||||
"horizon.moderation.remaining": "още %s",
|
||||
"horizon.moderation.mutelist.entry": "%1$s беше заглушен от %2$s %3$s",
|
||||
"horizon.moderation.invalid_duration": "%s не е продължителност: 10m, 2h, 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <player> [duration] [reason]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <player> <duration> [reason]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <player>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
"horizon.moderation.for_duration": "за %s",
|
||||
"horizon.moderation.muted": "Заглушен %1$s %2$s",
|
||||
"horizon.moderation.unmuted": "Заглушаването на %s е отменено",
|
||||
"horizon.moderation.not_muted": "%s не е заглушен",
|
||||
"horizon.tempban.usage": "/tempban <player> <duration> [reason]",
|
||||
"horizon.tempban.invalid_duration": "%s не е продължителност: 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Временните банове изискват отделен сървър",
|
||||
"horizon.tempban.success": "%1$s за %2$s, до %3$s",
|
||||
"horizon.tempban.list.empty": "Няма временни банове",
|
||||
"horizon.tempban.list.count": "Има %s временни бана:",
|
||||
"horizon.tempban.list.entry": "%1$s беше баннат от %2$s %3$s (до %4$s)",
|
||||
"horizon.options.pointer_speed": "Скорост на показалеца в менютата",
|
||||
"horizon.options.scroll_speed": "Скорост на превъртане в менютата",
|
||||
"horizon.options.repeat_delay": "Забавяне на повторението при навигация",
|
||||
"horizon.options.repeat_interval": "Интервал на повторението при навигация",
|
||||
"horizon.options.milliseconds": "%s ms",
|
||||
"horizon.extras.cosmetics_solo": "В режим един играч",
|
||||
"horizon.extras.cosmetics_solo.tooltip": "Показвайте козметиката си и значката на етикета с име и в световете си за един играч (етикета с име чрез Собствен етикет с име). Изкл.: само на сървъри.",
|
||||
"horizon.extras.horizon_marks": "Лога на Horizon",
|
||||
"horizon.extras.horizon_marks.tooltip": "Показва логото на Horizon до играчите на Horizon: малко лого преди имената на другите играчи, които използват Horizon, над главите им и в списъка с играчи. Само играчите на Horizon го виждат: сървърите не получават нищо.",
|
||||
"horizon.extras.horizon_me": "Показвай ме като Horizon",
|
||||
"horizon.extras.horizon_me.tooltip": "Показва ме като играч на Horizon: другите играчи на Horizon виждат логото до името ви. Включено, услугата отговаря на всеки, който пита за вас, че сте играли Horizon през последните 3 часа; изключено, никога не ви докладва.",
|
||||
"horizon.pages.pvp": "PvP и HUD",
|
||||
"horizon.pages.pvp.tooltip": "Настройки за мерника, HUD и PvP, всички изключени по подразбиране. Нищо тук не показва онова, което ванилия не показва.",
|
||||
"horizon.pages.open.pvp": "PvP и HUD...",
|
||||
"horizon.pvp.header.crosshair": "Мерник",
|
||||
"horizon.pvp.crosshair": "Собствен мерник",
|
||||
"horizon.pvp.crosshair_shape": "Форма",
|
||||
"horizon.pvp.crosshair_size": "Размер",
|
||||
"horizon.pvp.crosshair_gap": "Отстояние",
|
||||
"horizon.pvp.crosshair_thickness": "Дебелина",
|
||||
"horizon.pvp.crosshair_colour": "Цвят",
|
||||
"horizon.pvp.crosshair_outline": "Контур",
|
||||
"horizon.pvp.crosshair_dot": "Точка в центъра",
|
||||
"horizon.pvp.crosshair_hover": "Цвят върху обекти",
|
||||
"horizon.pvp.crosshair.tooltip": "Рисува ваш собствен мерник вместо този на ванилия. Първите му настройки изглеждат като тези на ванилия.",
|
||||
"horizon.pvp.crosshair_shape.tooltip": "Кръст, T, X, кръг, квадрат или само точка.",
|
||||
"horizon.pvp.crosshair_size.tooltip": "Дължината на всяко рамо, в пиксели на интерфейса.",
|
||||
"horizon.pvp.crosshair_gap.tooltip": "Празното пространство в средата.",
|
||||
"horizon.pvp.crosshair_thickness.tooltip": "Колко широка е всяка линия.",
|
||||
"horizon.pvp.crosshair_colour.tooltip": "Инвертиран показва цвета под него обърнат, както прави мерникът на ванилия.",
|
||||
"horizon.pvp.crosshair_outline.tooltip": "Тънък черен ръб, за да се вижда на всякакъв фон.",
|
||||
"horizon.pvp.crosshair_dot.tooltip": "Точка в средата на мерника.",
|
||||
"horizon.pvp.crosshair_hover.tooltip": "Друг цвят, докато мерникът е върху тълпа или играч, когото можете да уцелите, както го разпознава индикаторът за атака на ванилия.",
|
||||
"horizon.pvp.colour.inverted": "Инвертиран",
|
||||
"horizon.pvp.colour.white": "Бял",
|
||||
"horizon.pvp.colour.red": "Червен",
|
||||
"horizon.pvp.colour.orange": "Оранжев",
|
||||
"horizon.pvp.colour.yellow": "Жълт",
|
||||
"horizon.pvp.colour.green": "Зелен",
|
||||
"horizon.pvp.colour.cyan": "Циан",
|
||||
"horizon.pvp.colour.blue": "Син",
|
||||
"horizon.pvp.colour.magenta": "Магента",
|
||||
"horizon.pvp.colour.black": "Черен",
|
||||
"horizon.pvp.shape.cross": "Кръст",
|
||||
"horizon.pvp.shape.t": "T",
|
||||
"horizon.pvp.shape.x": "X",
|
||||
"horizon.pvp.shape.circle": "Кръг",
|
||||
"horizon.pvp.shape.square": "Квадрат",
|
||||
"horizon.pvp.shape.dot": "Точка",
|
||||
"horizon.pvp.outline": "Собствен контур на блок",
|
||||
"horizon.pvp.outline.tooltip": "Променя контура около блока, който гледате.",
|
||||
"horizon.pvp.outline_colour": "Цвят",
|
||||
"horizon.pvp.outline_colour.tooltip": "Цветът на контура. Черно при 40% е на ванилия.",
|
||||
"horizon.pvp.outline_opacity": "Непрозрачност",
|
||||
"horizon.pvp.outline_opacity.tooltip": "Колко плътен е контурът.",
|
||||
"horizon.pvp.outline_thickness": "Дебелина",
|
||||
"horizon.pvp.outline_thickness.tooltip": "Ширината на контура, в пъти тази на ванилия.",
|
||||
"horizon.pvp.outline_fill": "Запълване",
|
||||
"horizon.pvp.outline_fill.tooltip": "Оцветява леко лицата на блока, който гледате.",
|
||||
"horizon.pvp.hit": "Собствен цвят при удар",
|
||||
"horizon.pvp.hit.tooltip": "Променя цвета, с който тълпите и играчите премигват, когато бъдат наранени. Червено при 30% е на ванилия.",
|
||||
"horizon.pvp.hit_colour": "Цвят",
|
||||
"horizon.pvp.hit_colour.tooltip": "Цветът, с който премигват наранените тълпи и играчи.",
|
||||
"horizon.pvp.hit_opacity": "Непрозрачност",
|
||||
"horizon.pvp.hit_opacity.tooltip": "Колко от цвета ги покрива.",
|
||||
"horizon.pvp.header.outline": "Контур на блок",
|
||||
"horizon.pvp.header.hit": "Цвят при удар",
|
||||
"horizon.pvp.hud_armour": "Броня",
|
||||
"horizon.pvp.hud_armour.tooltip": "Бронята, която носите, започвайки от главата, с оставащите използвания на всяка част.",
|
||||
"horizon.pvp.hud_armour_corner": "Ъгъл на бронята",
|
||||
"horizon.pvp.hud_armour_corner.tooltip": "Къде се показва бронята.",
|
||||
"horizon.pvp.hud_armour_percent": "Издръжливост в %",
|
||||
"horizon.pvp.hud_armour_percent.tooltip": "Оставащи използвания като процент от максимума на частта вместо като число.",
|
||||
"horizon.pvp.hud_effects": "Ефекти",
|
||||
"horizon.pvp.hud_effects.tooltip": "Вашите ефекти с имената, нивата и оставащото време, вместо иконите в горния десен ъгъл.",
|
||||
"horizon.pvp.hud_effects_corner": "Ъгъл на ефектите",
|
||||
"horizon.pvp.hud_effects_corner.tooltip": "Къде се показват ефектите.",
|
||||
"horizon.pvp.hud_held": "Брой на държания предмет",
|
||||
"horizon.pvp.hud_held.tooltip": "Колко от предмета в ръката ви има в инвентара ви; стрели с лък или арбалет.",
|
||||
"horizon.pvp.hud_held_corner": "Ъгъл на брояча",
|
||||
"horizon.pvp.hud_held_corner.tooltip": "Къде се показва броячът.",
|
||||
"horizon.pvp.hud_background": "Фонове на джаджите",
|
||||
"horizon.pvp.hud_background.tooltip": "Тъмна кутия зад всяка джаджа.",
|
||||
"horizon.pvp.hud_opacity": "Непрозрачност на фона",
|
||||
"horizon.pvp.hud_opacity.tooltip": "Колко тъмни са кутиите зад джаджите.",
|
||||
"horizon.pvp.hud_text_colour": "Цвят на текста",
|
||||
"horizon.pvp.hud_text_colour.tooltip": "Цветът на текста на джаджите. Издръжливостта запазва прехода си от зелено към червено.",
|
||||
"horizon.pvp.hud_shadow": "Сянка на текста",
|
||||
"horizon.pvp.hud_shadow.tooltip": "Сянка под текста на джаджите, каквато има текстът на HUD във ванилия.",
|
||||
"horizon.pvp.header.hud": "Джаджи на HUD",
|
||||
"horizon.pvp.corner.top_left": "Горе вляво",
|
||||
"horizon.pvp.corner.top_right": "Горе вдясно",
|
||||
"horizon.pvp.corner.bottom_left": "Долу вляво",
|
||||
"horizon.pvp.corner.bottom_right": "Долу вдясно",
|
||||
"horizon.pvp.hud_ping": "Ping",
|
||||
"horizon.pvp.hud_ping.tooltip": "Закъснението, което сървърът отчита за вас, в милисекунди. Само в мултиплейър.",
|
||||
"horizon.pvp.hud_ping_corner": "Ъгъл на ping",
|
||||
"horizon.pvp.hud_ping_corner.tooltip": "Къде се показва ping.",
|
||||
"horizon.pvp.hud_coords": "Координати",
|
||||
"horizon.pvp.hud_coords.tooltip": "Позицията ви в блокове и посоката, в която гледате, както ги показва F3. Скрити, когато светът или сървърът ограничава F3.",
|
||||
"horizon.pvp.hud_coords_corner": "Ъгъл на координатите",
|
||||
"horizon.pvp.hud_coords_corner.tooltip": "Къде се показват координатите.",
|
||||
"horizon.pvp.hud_server": "Адрес на сървъра",
|
||||
"horizon.pvp.hud_server.tooltip": "Адресът на сървъра, в който играете. Само в мултиплейър.",
|
||||
"horizon.pvp.hud_server_corner": "Ъгъл на сървъра",
|
||||
"horizon.pvp.hud_server_corner.tooltip": "Къде се показва адресът.",
|
||||
"horizon.pvp.hud_combo": "Брояч на комботата",
|
||||
"horizon.pvp.hud_combo.tooltip": "Последователни удари, потвърдени от сървъра, докато не бъдете наранени или не минат 3 секунди без удар.",
|
||||
"horizon.pvp.hud_combo_corner": "Ъгъл на комботата",
|
||||
"horizon.pvp.hud_combo_corner.tooltip": "Къде се показва комботата.",
|
||||
"horizon.pvp.hud.ping": "Ping: %s ms",
|
||||
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
|
||||
"horizon.pvp.hud.facing": "Посока: %s",
|
||||
"horizon.pvp.hud.combo": "Комбо: %s",
|
||||
"horizon.pvp.hud.direction.north": "Север",
|
||||
"horizon.pvp.hud.direction.south": "Юг",
|
||||
"horizon.pvp.hud.direction.east": "Изток",
|
||||
"horizon.pvp.hud.direction.west": "Запад",
|
||||
"horizon.pvp.hud_fps": "FPS",
|
||||
"horizon.pvp.hud_fps.tooltip": "Кадри, нарисувани през последната секунда.",
|
||||
"horizon.pvp.hud_fps_corner": "Ъгъл на FPS",
|
||||
"horizon.pvp.hud_fps_corner.tooltip": "Къде се показва FPS.",
|
||||
"horizon.pvp.hud_cps": "Щраквания в секунда",
|
||||
"horizon.pvp.hud_cps.tooltip": "Вашите леви и десни щраквания през последната секунда.",
|
||||
"horizon.pvp.hud_cps_corner": "Ъгъл на CPS",
|
||||
"horizon.pvp.hud_cps_corner.tooltip": "Къде се показват щракванията в секунда.",
|
||||
"horizon.pvp.hud_keys": "Натискания на клавиши",
|
||||
"horizon.pvp.hud_keys.tooltip": "Клавишите ви за движение, бутоните на мишката и скокът, осветени, докато ги задържате, с щраквания в секунда.",
|
||||
"horizon.pvp.hud_keys_corner": "Ъгъл на натисканията",
|
||||
"horizon.pvp.hud_keys_corner.tooltip": "Къде се показват натисканията.",
|
||||
"horizon.pvp.hud_clock": "Часовник",
|
||||
"horizon.pvp.hud_clock.tooltip": "Времето на вашия компютър.",
|
||||
"horizon.pvp.hud_clock_corner": "Ъгъл на часовника",
|
||||
"horizon.pvp.hud_clock_corner.tooltip": "Къде се показва часовникът.",
|
||||
"horizon.pvp.hud_reach": "Обсег",
|
||||
"horizon.pvp.hud_reach.tooltip": "Колко далеч от очите ви е попаднал последният удар, за 3 секунди. Само измерва: обсегът ви остава този на ванилия.",
|
||||
"horizon.pvp.hud_reach_corner": "Ъгъл на обсега",
|
||||
"horizon.pvp.hud_reach_corner.tooltip": "Къде се показва обсегът.",
|
||||
"horizon.pvp.hud_speed": "Скорост",
|
||||
"horizon.pvp.hud_speed.tooltip": "Колко бързо се движите по земята, в блокове в секунда.",
|
||||
"horizon.pvp.hud_speed_corner": "Ъгъл на скоростта",
|
||||
"horizon.pvp.hud_speed_corner.tooltip": "Къде се показва скоростта.",
|
||||
"horizon.pvp.hud.fps": "%s FPS",
|
||||
"horizon.pvp.hud.cps": "CPS: %s | %s",
|
||||
"horizon.pvp.hud.reach": "Обсег: %s",
|
||||
"horizon.pvp.hud.speed": "Скорост: %s б/с",
|
||||
"horizon.pvp.hud.keys.attack": "ЛКМ",
|
||||
"horizon.pvp.hud.keys.use": "ДКМ",
|
||||
"horizon.pvp.hud.keys.cps": "%s CPS",
|
||||
"horizon.pvp.numeric_enchants": "Нива на омагьосване с числа",
|
||||
"horizon.pvp.numeric_enchants.tooltip": "Подсказките и масата за омагьосване показват Острота 5 вместо Острота V.",
|
||||
"horizon.pvp.header.items": "Предмети",
|
||||
"horizon.pvp.fire_lower": "По-нисък огън",
|
||||
"horizon.pvp.fire_lower.tooltip": "Премества пламъците, които виждате, докато горите, надолу, за да закриват по-малко от екрана.",
|
||||
"horizon.pvp.shield_size": "Размер на щита",
|
||||
"horizon.pvp.shield_size.tooltip": "Размерът на щита в ръката ви, от първо лице.",
|
||||
"horizon.pvp.totem_size": "Размер на анимацията на тотема",
|
||||
"horizon.pvp.totem_size.tooltip": "Размерът на тотема, който изпълва екрана, когато ви спаси.",
|
||||
"horizon.pvp.glint_colour": "Цвят на блясъка на омагьосването",
|
||||
"horizon.pvp.glint_colour.tooltip": "Цветът на блясъка по омагьосаните предмети и броня. Изкл. е лилавото на ванилия.",
|
||||
"horizon.pvp.clear_water": "Бистра вода",
|
||||
"horizon.pvp.clear_water.tooltip": "Под вода виждайте толкова далеч, колкото ванилия ви позволява след 30 секунди там, още от началото.",
|
||||
"horizon.pvp.clear_lava": "Бистра лава",
|
||||
"horizon.pvp.clear_lava.tooltip": "В лава виждайте толкова далеч, колкото позволява Устойчивост на огън във ванилия, без ефекта.",
|
||||
"horizon.pvp.header.visual": "Визуални промени",
|
||||
"horizon.pvp.view_time": "Час от деня",
|
||||
"horizon.pvp.view_time.tooltip": "Часът от деня, който виждате: само небето и светлината му. Времето на света продължава и нищо не се изпраща към сървъра.",
|
||||
"horizon.pvp.view_weather": "Време",
|
||||
"horizon.pvp.view_weather.tooltip": "Времето, което виждате: само дъждът, светлината му и мъглата. Времето на света продължава и нищо не се изпраща към сървъра.",
|
||||
"horizon.pvp.header.view": "Вашата гледка",
|
||||
"horizon.pvp.view.world": "На света",
|
||||
"horizon.pvp.time.morning": "Сутрин",
|
||||
"horizon.pvp.time.noon": "Обед",
|
||||
"horizon.pvp.time.sunset": "Залез",
|
||||
"horizon.pvp.time.midnight": "Полунощ",
|
||||
"horizon.pvp.weather.clear": "Ясно",
|
||||
"horizon.pvp.weather.rain": "Дъжд",
|
||||
"horizon.pvp.weather.thunder": "Гръмотевица",
|
||||
"horizon.chat.timestamp": "[%s] ",
|
||||
"horizon.chat.repeats": " (x%s)",
|
||||
"horizon.pvp.header.chat": "Чат",
|
||||
"horizon.chat.copied": "Съобщението е копирано",
|
||||
"horizon.chat.search": "Търсене в чата:",
|
||||
"horizon.pvp.chat_timestamps": "Времеви маркери",
|
||||
"horizon.pvp.chat_timestamps.tooltip": "Времето, в което е пристигнало всяко съобщение, пред него.",
|
||||
"horizon.pvp.chat_compact": "Компактен чат",
|
||||
"horizon.pvp.chat_compact.tooltip": "Съобщение, повторено последователно, се показва веднъж, с броя на повторенията: (x3).",
|
||||
"horizon.pvp.chat_copy": "Копиране при щракване",
|
||||
"horizon.pvp.chat_copy.tooltip": "Щракването върху съобщение в отворения чат копира текста му, освен ако щракването не прави друго.",
|
||||
"horizon.pvp.chat_search": "Търсене в чата",
|
||||
"horizon.pvp.chat_search.tooltip": "Ctrl+F в отворения чат: написаното показва само съобщенията, които го съдържат. Enter или Ctrl+F отново спира.",
|
||||
"horizon.pvp.preset": "PvP предварителна настройка",
|
||||
"horizon.pvp.preset.tooltip": "Включва собствения мерник, джаджите за броня и ефекти, превключването на спринт и промъкване, по-ниския огън, както и модовете за брояч на FPS и натискания на клавиши, ако са инсталирани. Настройките ви за всяко от тях остават.",
|
||||
"horizon.pvp.hud_editor_key": "Клавиш за редактора на HUD",
|
||||
"horizon.pvp.hud_editor_key.tooltip": "Отваря редактора на HUD от играта. Щракнете, след това натиснете клавиш; Escape го развързва.",
|
||||
"horizon.pvp.editor": "Редактиране на HUD",
|
||||
"horizon.pvp.editor.tooltip": "Местете джаджите върху играта: плъзгайте ги навсякъде, променяйте размера им с колелцето, щракнете с десен бутон върху някоя, за да я върнете в ъгъла ѝ.",
|
||||
"horizon.pvp.editor.hint": "Плъзгайте джаджи · Колелце: размер · Десен бутон: обратно в ъгъла",
|
||||
"horizon.pvp.combat_feel": "Усещане за бой",
|
||||
"horizon.pvp.combat_feel.tooltip": "Legacy рисува боевете както 1.8.9: предметът в ръката ви, замахът му, храненето, лъкът и меч, вдигнат, докато задържате използване, без снижаването след всеки удар и без индикатора за атака. Само изгледът: това, което изпращате на сървър, остава същото.",
|
||||
"horizon.pvp.combat_indicator": "Индикатор за атака в Legacy",
|
||||
"horizon.pvp.combat_indicator.tooltip": "Запазва индикатора за атака в Legacy, който 1.8.9 нямаше.",
|
||||
"horizon.pvp.combat_feel.modern": "Модерно",
|
||||
"horizon.pvp.combat_feel.legacy": "Legacy (1.8.9)",
|
||||
"horizon.pvp.header.combat": "Усещане за бой",
|
||||
"horizon.pvp.combat_sounds": "Звуци при удар от 1.8",
|
||||
"horizon.pvp.combat_sounds.tooltip": "В Legacy пропуска звуците при атака, добавени в 1.9 (силна, слаба, замах, критична...). Звуците при нараняване остават.",
|
||||
"horizon.pvp.combat_rules": "Бой по 1.8 в светове, които домакинствате",
|
||||
"horizon.pvp.combat_rules.tooltip": "Играта за един играч и световете, които отваряте в LAN, се бият по правилата на 1.8.9: без изчакване на атаката и без замах, щети от оръжия по 1.8, Сила, Острота и критични удари. Сървърите, към които се присъединявате, запазват своите правила.",
|
||||
"Bindings": "Обвързвания",
|
||||
"Play": "Игра",
|
||||
"Device": "Устройство",
|
||||
"Look": "Поглед",
|
||||
"Movement": "Движение",
|
||||
"Menus": "Менюта",
|
||||
"Actions": "Действия",
|
||||
"Reset": "Нулиране",
|
||||
"Detected": "Открито",
|
||||
"Glyphs": "Глифове",
|
||||
"Feedback": "Обратна връзка",
|
||||
"Controller": "Контролер",
|
||||
"How the sticks turn and move you, and how menus repeat and scroll.": "Как стиковете ви обръщат и движат и как менютата се повтарят и превъртат.",
|
||||
"Which button does what. Each player has their own.": "Кой бутон какво прави. Всеки играч има свои.",
|
||||
"The controller the game sees, its button glyphs and its feedback.": "Контролерът, който играта вижда, глифовете на бутоните му и обратната му връзка.",
|
||||
"Press this row, then the button that should do it.": "Натиснете този ред, след това бутона, който трябва да го прави.",
|
||||
"Off, the game ignores every controller for this player.": "Изключено, играта пренебрегва всеки контролер за този играч.",
|
||||
"How fast the right stick turns the view.": "Колко бързо десният стик обръща гледката.",
|
||||
"Turn the view the other way, left and right.": "Обръща гледката в другата посока, наляво и надясно.",
|
||||
"Turn the view the other way, up and down.": "Обръща гледката в другата посока, нагоре и надолу.",
|
||||
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Колко се движи десният стик, преди гледката да се обърне. Увеличете го, ако гледката се движи сама.",
|
||||
"Hold the button to sneak, or press it once to start and again to stop.": "Задръжте бутона, за да се промъкнете, или го натиснете веднъж, за да започнете, и отново, за да спрете.",
|
||||
"How far the left stick moves before you walk. Raise it if you drift.": "Колко се движи левият стик, преди да тръгнете. Увеличете го, ако се движите сами.",
|
||||
"How far a trigger is pulled before it counts as pressed.": "Колко се натиска тригерът, преди да се брои за натиснат.",
|
||||
"Shows what your buttons do right now, in play and in inventories.": "Показва какво правят бутоните ви в момента, в игра и в инвентарите.",
|
||||
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "Показва само онова, което можете да направите с това, което гледате, и основните бутони на инвентара.",
|
||||
"Doesn't show what your buttons do.": "Не показва какво правят бутоните ви.",
|
||||
"Shows the buttons of the controller you're using.": "Показва бутоните на контролера, който използвате.",
|
||||
"Always shows Xbox buttons.": "Винаги показва бутоните на Xbox.",
|
||||
"Always shows PlayStation buttons.": "Винаги показва бутоните на PlayStation.",
|
||||
"Always shows Nintendo buttons.": "Винаги показва бутоните на Nintendo.",
|
||||
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A, отдясно, потвърждава и скача, а B се връща назад, както на Switch.",
|
||||
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B, отдолу, потвърждава и скача, а A се връща назад, както на други контролери.",
|
||||
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Отваря клавиатурата на Steam на Steam Deck под Steam, а иначе — тази на Horizon.",
|
||||
"Always opens Horizon's keyboard.": "Винаги отваря клавиатурата на Horizon.",
|
||||
"Always opens Steam's keyboard.": "Винаги отваря клавиатурата на Steam.",
|
||||
"A DualSense's light shows your health, from green through amber to red.": "Светлината на DualSense показва здравето ви — от зелено през кехлибарено до червено.",
|
||||
"A DualSense's light changes with the dimension you're in.": "Светлината на DualSense се променя според измерението, в което сте.",
|
||||
"A DualSense keeps its own light.": "DualSense запазва собствената си светлина.",
|
||||
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "Позволява LT + A да изработва на сървъри, различни от вашите светове. Някои сървъри го смятат за макрос.",
|
||||
"Show the buttons for what you can do, while you play with a controller.": "Показва бутоните за онова, което можете да направите, докато играете с контролер.",
|
||||
"How fast the stick moves the crosshair in inventories.": "Колко бързо стикът движи мерника в инвентарите.",
|
||||
"How fast the right stick scrolls a list.": "Колко бързо десният стик превърта списък.",
|
||||
"How long a held direction waits before it repeats.": "Колко дълго задържана посока чака, преди да се повтори.",
|
||||
"How long a held direction takes between repeats.": "Колко дълго задържана посока отнема между повторенията.",
|
||||
"Which on-screen keyboard opens when a text field is pressed.": "Коя екранна клавиатура се отваря при натискане на текстово поле.",
|
||||
"Every action back on its default button.": "Всяко действие обратно на бутона си по подразбиране.",
|
||||
"These settings belong to this player's profile.": "Тези настройки принадлежат на профила на този играч.",
|
||||
"The controller driving this player, or else the first one connected.": "Контролерът, който управлява този играч, или иначе първият свързан.",
|
||||
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "USB производителят и продуктът, които системата отчита. Полезно при докладване на контролер, който се държи неправилно.",
|
||||
"The glyph family the game picked from the controller's maker.": "Семейството глифове, което играта избра от производителя на контролера.",
|
||||
"Follow the controller, or always show one family's buttons.": "Следва контролера или винаги показва бутоните на едно семейство.",
|
||||
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "Дали A и B на контролер на Nintendo работят според позицията си или според буквите си.",
|
||||
"The buttons menus show right now.": "Бутоните, които менютата показват в момента.",
|
||||
"Rumble when something happens to you.": "Вибрация, когато ви се случи нещо.",
|
||||
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "Контролерът продължава да играе, докато този прозорец не е на фокус, и загубата на фокус няма да постави на пауза.",
|
||||
"A DualSense's triggers push back on bows and tools.": "Тригерите на DualSense оказват натиск при лъкове и инструменти.",
|
||||
"What a DualSense's light shows, over USB or Bluetooth.": "Какво показва светлината на DualSense, по USB или Bluetooth.",
|
||||
"Player profile": "Профил на играча",
|
||||
"Vendor and product": "Производител и продукт",
|
||||
"Detected glyphs": "Открити глифове",
|
||||
"Glyphs in use": "Глифове в употреба",
|
||||
"Unknown": "Неизвестно",
|
||||
"No controller": "Няма контролер",
|
||||
"Editing: %s": "Редактиране: %s",
|
||||
"Press the button for this. %s cancels.": "Натиснете бутона за това. %s отказва.",
|
||||
"This button also does: %s": "Този бутон също прави: %s",
|
||||
"Not bound: press this row, then the button that should do it.": "Не е обвързан: натиснете този ред, след това бутона, който трябва да го прави.",
|
||||
"horizon.pad.notice.connected.press": "Натиснете, за да го използвате",
|
||||
"horizon.recipe_transfer.missing": "Липсващи предмети",
|
||||
"horizon.recipe_transfer.no_room": "Инвентарът е пълен",
|
||||
"horizon.recipe_transfer.unavailable": "Рецептата не се побира в това меню",
|
||||
"horizon.recipe_transfer.unsupported": "Сървърът изисква позната рецепта",
|
||||
"horizon.studio.timeline.freecam_on": "Свободна камера: ВКЛ.",
|
||||
"horizon.studio.timeline.freecam_off": "Свободна камера: ИЗКЛ.",
|
||||
"horizon.studio.timeline.freecam_short": "Кам",
|
||||
"horizon.studio.timeline.freecam_active_short": "Кам ✓",
|
||||
"horizon.studio.timeline.add_keyframe_short": "+ Кл. кадър",
|
||||
"horizon.studio.timeline.delete_keyframe_short": "− Изтр.",
|
||||
"horizon.studio.timeline.split": "Разделяне",
|
||||
"horizon.studio.timeline.marker": "Маркер %s",
|
||||
"horizon.studio.timeline.seconds": "%s с",
|
||||
"horizon.studio.timeline.scene_count": "%s сцени",
|
||||
"horizon.studio.timeline.scene_title": "Сцена %s",
|
||||
"horizon.studio.timeline.sequence": "Последователност",
|
||||
"horizon.studio.topbar.title": "✦ Studio",
|
||||
"horizon.studio.topbar.file": "Файл",
|
||||
"horizon.studio.topbar.camera": "Камера",
|
||||
"horizon.studio.topbar.view": "Изглед",
|
||||
"horizon.studio.topbar.file_short": "Файл",
|
||||
"horizon.studio.topbar.camera_short": "Кам",
|
||||
"horizon.studio.topbar.view_short": "Изглед",
|
||||
"horizon.studio.topbar.game_pause_menu": "⏸ Меню за пауза",
|
||||
"horizon.studio.topbar.pause_short": "Пауза",
|
||||
"horizon.studio.topbar.open_recordings": "📁 Отвори папката със записи",
|
||||
"horizon.studio.topbar.close_replay": "✕ Затвори записа",
|
||||
"horizon.studio.topbar.exit": "Изход",
|
||||
"horizon.studio.topbar.toggle_freecam": "Превключи свободната камера",
|
||||
"horizon.studio.topbar.reset_to_player": "👤 Върни към играча",
|
||||
"horizon.studio.topbar.unlock_freecam": "🔓 Отключи свободната камера",
|
||||
"horizon.studio.topbar.lock_to_spline": "🔒 Заключи към сплайн",
|
||||
"horizon.studio.topbar.add_keyframe": "➕ Добави ключов кадър",
|
||||
"horizon.studio.topbar.add_marker": "Добави маркер",
|
||||
"horizon.studio.topbar.clear_keyframes": "🗑 Изчисти ключовите кадри",
|
||||
"horizon.studio.topbar.timeline_hud": "⏱ Времева линия",
|
||||
"horizon.studio.topbar.split_scene": "Раздели сцената"
|
||||
}
|
||||
@@ -148,9 +148,7 @@
|
||||
"Size of menus and the HUD.": "Ment ar mennozhioù hag an HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Lakaat a ra an dibarzhioù amañ dindan asambles. Cheñch unan a ra anezhañ Personelaet.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, tan ha gouloù an heol a lugern muioc'h eget ar gwenn. Ezhomm zo eus ur skramm HDR.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Du teñvalañ eo ho skramm. 0 evit OLED; LCDoù a lugern un tamm.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Pegen skedus e teu ar sklêrijennoù. Emgefre : ar pik a vez embannet gant ho reizhiad.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Pegen skedus e seblant ar paper gwenn hag ar mennozhioù. Emgefre : gwenn eo ho burev.",
|
||||
"How bright the HUD and menus are, against paper white.": "Pegen skedus eo an HUD hag ar mennozhioù, a-enep gwenn ar paper.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Pegen pell e kendalc'h ar mobs, ar c'hoadoù hag ar mekanikoù da labourat. Amzer ar CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Pegen pell e vez tennet ar mobs hag an traoù, evel ul lodenn eus ar pezh a vez graet normal.",
|
||||
|
||||
@@ -1,37 +1,158 @@
|
||||
{
|
||||
"horizon.extras.header.world": "Svijet",
|
||||
"horizon.extras.pregen": "Prethodno generisanje komada",
|
||||
"horizon.extras.pregen.tooltip": "Omogućava /pregen u vašim svjetovima za jednog igrača i na LAN-u. Uz uključene prevare koristite /pregen start da generišete područje prije nego ga istražite. Ne omogućava komandu na serverima kojima se pridružite.",
|
||||
"horizon.options.controller": "Kontroler",
|
||||
"horizon.options.three_d_skin_layers": "3D slojevi skina",
|
||||
"horizon.options.vibration": "Vibracije",
|
||||
"horizon.options.play_in_background": "Igra u pozadini",
|
||||
"horizon.options.adaptive_triggers": "Adaptivni okidači",
|
||||
"horizon.options.gameplay_guide": "Vodič kroz igru",
|
||||
"horizon.options.gameplay_guide.basic": "Osnovno",
|
||||
"horizon.options.gameplay_guide.full": "Pun",
|
||||
"horizon.options.gameplay_guide.off": "Isključeno",
|
||||
"horizon.controller.guide.use": "Koristi",
|
||||
"horizon.controller.guide.open": "Otvori",
|
||||
"horizon.controller.guide.ride": "Jahaj",
|
||||
"horizon.controller.guide.trade": "Trguj",
|
||||
"horizon.controller.guide.feed": "Nahrani",
|
||||
"horizon.controller.guide.place": "Postavi",
|
||||
"horizon.controller.guide.eat": "Jedi",
|
||||
"horizon.controller.guide.drink": "Pij",
|
||||
"horizon.controller.guide.shoot": "Pucaj",
|
||||
"horizon.controller.guide.load": "Napuni",
|
||||
"horizon.controller.guide.throw": "Baci",
|
||||
"horizon.controller.guide.raise": "Podigni",
|
||||
"horizon.controller.guide.equip": "Opremi",
|
||||
"horizon.controller.guide.fish": "Pecaj",
|
||||
"horizon.controller.guide.zoom": "Zum",
|
||||
"horizon.controller.guide.boost": "Ubrzaj",
|
||||
"horizon.controller.guide.break": "Razbij",
|
||||
"horizon.controller.guide.attack": "Napadni",
|
||||
"horizon.controller.guide.spectate": "Posmatraj",
|
||||
"horizon.controller.guide.spectator_menu": "Meni posmatrača",
|
||||
"horizon.controller.guide.release_shoot": "Otpusti za pucanje",
|
||||
"horizon.controller.guide.release_throw": "Otpusti za bacanje",
|
||||
"horizon.controller.guide.release_stop": "Otpusti za zaustavljanje",
|
||||
"horizon.controller.guide.fly": "Leti",
|
||||
"horizon.controller.guide.fly_up": "Leti gore",
|
||||
"horizon.controller.guide.fly_down": "Leti dolje",
|
||||
"horizon.controller.guide.swim_up": "Plivaj gore",
|
||||
"horizon.controller.guide.swim_down": "Plivaj dolje",
|
||||
"horizon.controller.guide.glide": "Jedri",
|
||||
"horizon.controller.guide.dismount": "Siđi",
|
||||
"horizon.controller.guide.stand": "Ustani",
|
||||
"horizon.controller.guide.drop": "Ispusti predmet",
|
||||
"horizon.controller.guide.swap": "Zamijeni ruke",
|
||||
"horizon.controller.guide.take": "Uzmi",
|
||||
"horizon.controller.guide.take_half": "Uzmi pola",
|
||||
"horizon.controller.guide.put": "Spusti",
|
||||
"horizon.controller.guide.put_one": "Stavi jedan",
|
||||
"horizon.controller.guide.swap_stack": "Zamijeni",
|
||||
"horizon.controller.guide.quick_move": "Brzo premjesti",
|
||||
"horizon.controller.guide.drop_stack": "Ispusti hrpu",
|
||||
"horizon.controller.guide.place_recipe": "Postavi recept",
|
||||
"horizon.controller.guide.place_recipe_all": "Postavi sve",
|
||||
"horizon.controller.guide.alternatives": "Alternative",
|
||||
"horizon.controller.guide.select": "Odaberi",
|
||||
"horizon.controller.guide.close": "Zatvori",
|
||||
"horizon.controller.guide.pages": "Stranice",
|
||||
"horizon.controller.guide.categories": "Kategorije",
|
||||
"horizon.controller.guide.tabs": "Kartice",
|
||||
"horizon.options.lightbar": "Svjetlosna traka",
|
||||
"horizon.options.look_sensitivity": "Osjetljivost pogleda",
|
||||
"horizon.options.invert_x": "Obrnuti pogled po X",
|
||||
"horizon.options.invert_y": "Obrnuti pogled po Y",
|
||||
"horizon.options.craft_on_servers": "Brza izrada na serverima",
|
||||
"horizon.controller.direct_craft": "Brza izrada",
|
||||
"horizon.options.sneak_button": "Dugme za šunjanje",
|
||||
"horizon.options.move_deadzone": "Mrtva zona kretanja",
|
||||
"horizon.options.look_deadzone": "Mrtva zona pogleda",
|
||||
"horizon.options.trigger_threshold": "Prag okidača",
|
||||
"horizon.options.button_glyphs": "Dugmad Glyphs",
|
||||
"horizon.options.on_screen_keyboard": "Tastatura na ekranu",
|
||||
"horizon.options.chat_delay": "Kašnjenje ćaskanja",
|
||||
"horizon.options.lightbar.health": "Zdravlje",
|
||||
"horizon.options.lightbar.dimension": "Dimenzija",
|
||||
"horizon.options.glyphs.auto": "Automatski",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.nintendo_buttons": "Nintendo dugmad",
|
||||
"horizon.options.nintendo_buttons.position": "Po položaju",
|
||||
"horizon.options.nintendo_buttons.letter": "Po slovu",
|
||||
"horizon.options.keyboard.auto": "Automatski",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Ova funkcija još nije dostupna u Horizon.",
|
||||
"horizon.options.search": "Postavke pretrage...",
|
||||
"horizon.studio.record.usage": "Upotreba: /studio record <start [name]|stop|status>",
|
||||
"horizon.studio.record.started": "Snimanje u Studiju je počelo: %s",
|
||||
"horizon.studio.record.saved": "Snimanje u Studiju je sačuvano: %s",
|
||||
"horizon.studio.record.error": "Greška pri snimanju u Studiju: %s",
|
||||
"horizon.studio.record.status.active": "Snimanje u Studiju je aktivno — %s s, %s paketa, %s KB (%s)",
|
||||
"horizon.studio.record.status.inactive": "Snimanje u Studiju nije aktivno",
|
||||
"horizon.studio.replay.usage": "Upotreba: /studio replay <open PATH|pause|resume|speed MULTIPLIER|seek MS|status|close>",
|
||||
"horizon.studio.replay.open.usage": "Upotreba: /studio replay open <PATH>",
|
||||
"horizon.studio.replay.opened": "Repriza u Studiju je otvorena: %s (%s s, %s paketa)",
|
||||
"horizon.studio.replay.open_error": "Nije moguće otvoriti reprizu: %s",
|
||||
"horizon.studio.replay.paused": "Repriza u Studiju je pauzirana",
|
||||
"horizon.studio.replay.resumed": "Repriza u Studiju je nastavljena",
|
||||
"horizon.studio.replay.inactive": "Nema aktivne reprize u Studiju",
|
||||
"horizon.studio.replay.speed.usage": "Upotreba: /studio replay speed <MULTIPLIER>",
|
||||
"horizon.studio.replay.speed.set": "Brzina reprize u Studiju je postavljena na %s×",
|
||||
"horizon.studio.replay.speed.invalid": "Nevažeća brzina reprize",
|
||||
"horizon.studio.replay.seek.usage": "Upotreba: /studio replay seek <MILLISECONDS>",
|
||||
"horizon.studio.replay.seeked": "Repriza u Studiju je premotana na %s ms (%s s)",
|
||||
"horizon.studio.replay.seek.error": "Nije moguće premotati reprizu: %s",
|
||||
"horizon.studio.replay.seek.backward_requires_standalone": "Premotavanje unazad je dostupno samo za samostalnu reprizu.",
|
||||
"horizon.studio.replay.seek.invalid": "Nevažeća pozicija reprize",
|
||||
"horizon.studio.replay.status": "Repriza u Studiju: %s / %s ms (%s%%), brzina %s×, pauzirano %s, završeno %s",
|
||||
"horizon.studio.replay.closed": "Repriza u Studiju je zatvorena",
|
||||
"horizon.studio.freecam.enabled": "Slobodna kamera u Studiju je uključena. Koristite WASD za kretanje, Space i Shift za uspon ili spuštanje, a Control za brže kretanje.",
|
||||
"horizon.studio.freecam.disabled": "Slobodna kamera u Studiju je isključena",
|
||||
"horizon.studio.freecam.unavailable": "Slobodna kamera u Studiju nije našla kameru igrača",
|
||||
"horizon.studio.freecam.replay_required": "Slobodna kamera u Studiju je dostupna tokom reprodukcije reprize",
|
||||
"horizon.studio.freecam.usage": "Upotreba: /studio freecam <on|off>",
|
||||
"horizon.studio.keyframe.added": "Dodat je ključni kadar kamere na %s ms. Putanja se čuva kada zatvorite reprizu.",
|
||||
"horizon.studio.keyframe.cleared": "Putanja kamere je očišćena. Promjena se čuva kada zatvorite reprizu.",
|
||||
"horizon.studio.keyframe.removed": "Uklonjen je ključni kadar kamere %s. Promjena se čuva kada zatvorite reprizu.",
|
||||
"horizon.studio.keyframe.save_error": "Nije moguće sačuvati putanju kamere: %s",
|
||||
"horizon.studio.keyframe.remove_usage": "Upotreba: /studio keyframe remove <INDEX>",
|
||||
"horizon.studio.keyframe.index_invalid": "Indeks ključnog kadra kamere je van opsega",
|
||||
"horizon.studio.keyframe.usage": "Upotreba: /studio keyframe <add|remove INDEX|clear>",
|
||||
"horizon.studio.path.locked": "Reprodukcija putanje kamere u Studiju je uključena",
|
||||
"horizon.studio.path.unlocked": "Reprodukcija putanje kamere u Studiju je zaustavljena",
|
||||
"horizon.studio.path.usage": "Upotreba: /studio path <lock|unlock> (prvo dodajte barem jedan ključni kadar)",
|
||||
"horizon.pages.mods": "Modovi",
|
||||
"horizon.pages.shader_packs": "Paketi šejdera",
|
||||
"horizon.pages.shader_options": "Opcije šejdera",
|
||||
"horizon.pages.controller": "Postavke kontrolera",
|
||||
"horizon.pages.controller_binds": "Kontroler se vezuje",
|
||||
"horizon.pages.extras": "Horizon Dodaci",
|
||||
"horizon.pages.settings": "Horizon Postavke",
|
||||
"horizon.pages.studio": "Studio za reprize",
|
||||
"horizon.pages.agent": "AI agent",
|
||||
"horizon.split.leave": "Odlazi",
|
||||
"horizon.pages.split_settings": "Splitscreen postavke",
|
||||
"horizon.pages.split_profiles": "ko igra?",
|
||||
"horizon.pages.split_setup": "Splitscreen podešavanje",
|
||||
"horizon.pages.music": "Muzika",
|
||||
"horizon.pages.music.menu": "Meni",
|
||||
"horizon.pages.music.overworld": "Gornji svijet",
|
||||
"horizon.pages.music.creative": "Kreativno",
|
||||
"horizon.pages.music.underwater": "Pod vodom",
|
||||
"horizon.pages.music.end": "Kraj",
|
||||
"horizon.pages.music.boss": "Šefe",
|
||||
"horizon.pages.music.credits": "Zasluge",
|
||||
"horizon.pages.music.overworld_biomes": "Biomi Gornjeg svijeta",
|
||||
"horizon.pages.music.nether_biomes": "Nether biomi",
|
||||
"horizon.pages.music.end_biomes": "Kraj bioma",
|
||||
"horizon.pages.music.other": "Ostala muzika",
|
||||
"horizon.pages.music.empty": "Nema muzičkih numera: zvuci igre se ne učitavaju.",
|
||||
"horizon.pages.music.tooltip": "Ugrađene numere, po situaciji: igrajte jednu sada ili isključite bilo koju da igra prestane da je bira.",
|
||||
"horizon.pages.settings.tooltip": "Horizon vlastite postavke: grafika, kontroleri, dodaci i modovi.",
|
||||
"horizon.pages.studio.tooltip": "Pregledajte sačuvane snimke i njihove metapodatke.",
|
||||
"horizon.pages.graphics.tooltip": "Grafika, jednostavno: svaka postavka govori koliko košta.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Svaka grafička postavka, Horizon je uključena, sa onim što svaka košta.",
|
||||
"horizon.pages.controller.tooltip": "Kontroleri: njihova dugmad, štapići, vibracije i navigacija u meniju.",
|
||||
@@ -45,61 +166,132 @@
|
||||
"horizon.pages.open.graphics_advanced": "Napredna grafika...",
|
||||
"horizon.pages.open.shader_packs": "Shader paketi...",
|
||||
"horizon.pages.open.controller": "Postavke kontrolera...",
|
||||
"horizon.pages.open.split_setup": "Splitscreen...",
|
||||
"horizon.pages.open.extras": "Dodaci...",
|
||||
"horizon.pages.open.mods": "Modovi...",
|
||||
"horizon.pages.open.agent": "AI agent...",
|
||||
"horizon.pages.open.horizon_graphics": "Horizon Grafika...",
|
||||
"horizon.pages.open.controller_binds": "Kontroler vezuje...",
|
||||
"horizon.pages.open.horizon_settings": "Horizon Postavke...",
|
||||
"horizon.pages.open.studio": "Studio za reprize...",
|
||||
"horizon.studio.saved_recordings": "Sačuvani snimci",
|
||||
"horizon.studio.empty": "U fascikli snimaka nema pronađenih snimaka.",
|
||||
"horizon.studio.empty_hint": "Koristite /studio record start <name> u svijetu ili tamo stavite .mcpr datoteke.",
|
||||
"horizon.studio.open_folder": "Otvori snimke",
|
||||
"horizon.studio.open_replay": "Pusti reprizu",
|
||||
"horizon.studio.refresh": "Osvježi",
|
||||
"horizon.studio.unknown_server": "Nepoznato",
|
||||
"horizon.pages.open.music": "muzika...",
|
||||
"horizon.mods.empty": "Bez modova: stavite .hmod fajlove u folder horizon-mods",
|
||||
"horizon.mods.stopped": " (zaustavljeno)",
|
||||
"horizon.mods.not_built": " (nije izgrađeno)",
|
||||
"horizon.mods.dev": " (dev)",
|
||||
"horizon.mods.settings": "Postavke",
|
||||
"horizon.mods.in_world": " (ostavi svijet da se promijeni)",
|
||||
"horizon.mods.open_folder": "Otvorite Mod folder",
|
||||
"horizon.mods.browse": "Pregledaj modove...",
|
||||
"horizon.mods.browse.title": "Pregledaj modove",
|
||||
"horizon.mods.browse.search": "Pretraži modove...",
|
||||
"horizon.mods.browse.project": "Mod",
|
||||
"horizon.mods.browse.loading": "Čitam Horizon registar modova...",
|
||||
"horizon.mods.browse.failed": "Nije bilo moguće pročitati registar modova: %s",
|
||||
"horizon.mods.browse.none": "Nije pronađen nijedan mod",
|
||||
"horizon.mods.browse.has_update": " (ažuriranje)",
|
||||
"horizon.mods.browse.is_installed": " (instalirano)",
|
||||
"horizon.mods.browse.by": "od %s",
|
||||
"horizon.mods.browse.installing": "Instaliranje...",
|
||||
"horizon.mods.browse.install": "Instaliraj",
|
||||
"horizon.mods.browse.update": "Ažuriraj",
|
||||
"horizon.mods.browse.installed": "Instalirano",
|
||||
"horizon.mods.browse.open_page": "Otvori stranicu",
|
||||
"horizon.mods.browse.enabled": "Mod",
|
||||
"horizon.mods.browse.changelog": "Historija izmjena",
|
||||
"horizon.mods.browse.done": "%s %s je instaliran i uključen.",
|
||||
"horizon.mods.browse.install_failed": "Nije bilo moguće instalirati: %s",
|
||||
"horizon.mods.switch.worldgen": "mijenja generisanje svijeta",
|
||||
"horizon.mods.switch.registries": "dodaje blokove, predmete ili druge unose u registar",
|
||||
"horizon.mods.switch.restart": "Restartujte igru da primijenite %s: dodaju blokove, predmete ili generisanje svijeta.",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"horizon.agent.copy_details": "Kopiraj detalje veze",
|
||||
"horizon.multiplayer.join": "Pridružite se",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Otkaži ponovno povezivanje",
|
||||
"horizon.multiplayer.join_friend": "Pridružite se prijatelju",
|
||||
"horizon.multiplayer.invite_code": "Pozivni kod",
|
||||
"horizon.multiplayer.relay": "Relej (opciono)",
|
||||
"Preset": "Predložak",
|
||||
"LOD Distance": "LOD Udaljenost",
|
||||
"Far Detail": "Daleki detalj",
|
||||
"Cave Culling": "Odbijanje pećina",
|
||||
"GPU Culling": "GPU odbijanje",
|
||||
"Entity Culling": "Uništavanje entiteta",
|
||||
"Chunk Updates": "Ažuriranja komada",
|
||||
"Upload Budget": "Budžet otpremanja",
|
||||
"Sky": "Nebo",
|
||||
"Fog": "Magla",
|
||||
"Weather": "Vrijeme",
|
||||
"Animated Textures": "Animirane teksture",
|
||||
"Present Mode": "Način prikaza",
|
||||
"Frame Pacing": "Tempo okvira",
|
||||
"Worker Threads": "Radne niti",
|
||||
"Resolution Scale": "Skala rezolucije",
|
||||
"Anti-aliasing": "Zaglađivanje",
|
||||
"Upscaling": "Povećanje",
|
||||
"Upscaling Sharpness": "Oštrina povećanja",
|
||||
"Paper White": "Papir bijeli",
|
||||
"Peak Brightness": "Vršna svjetlina",
|
||||
"Black Level": "Nivo crne",
|
||||
"UI Brightness": "Svjetlina interfejsa",
|
||||
"HDR Lighting": "HDR osvjetljenje",
|
||||
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Izvori svjetlosti, sunce i sunčeva svjetlost sijaju jače od bijelog. Isključeno: kao SDR.",
|
||||
"Smooth Gradients": "Glatki gradijenti",
|
||||
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "16-bitni okvir: tamno nebo i sjene se ne stepenuju. Malo više VRAM-a. Sljedeće pokretanje.",
|
||||
"Battery Mode": "Režim baterije",
|
||||
"Graphics API": "Grafički API",
|
||||
"Translucent Batching": "Grupisanje prozirnog",
|
||||
"Performance Overlay": "Preklapanje performansi",
|
||||
"Full Detail Distance": "Puna udaljenost do detalja",
|
||||
"On": "Uključeno",
|
||||
"Off": "Isključeno",
|
||||
"Unlimited": "Neograničeno",
|
||||
"Auto": "Automatski",
|
||||
"Custom": "Prilagođeno",
|
||||
"Performance": "Performanse",
|
||||
"Balanced": "Balansirano",
|
||||
"Quality": "Kvaliteta",
|
||||
"Ultra": "Ultra",
|
||||
"Ultra Performance": "Ultra performanse",
|
||||
"Native": "Izvorno",
|
||||
"Full": "Pun",
|
||||
"Reduced": "Smanjena",
|
||||
"Efficient": "Efikasno",
|
||||
"Precise": "Precizno",
|
||||
"Auto (VSync)": "automatski (VSync)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "Mailbox",
|
||||
"Immediate": "Odmah",
|
||||
"Vulkan": "Vulkan",
|
||||
"DirectX 12": "DirectX 12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS i Graf",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "Bez troškova",
|
||||
"Low cost": "Niska cijena",
|
||||
"Medium cost": "Srednji trošak",
|
||||
"High cost": "Visoka cijena",
|
||||
"Saves time": "Štedi vrijeme",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "Grafika",
|
||||
"Done": "Gotovo",
|
||||
"This feature is not yet available in Horizon.": "Ova funkcija još nije dostupna u Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Vaš ekran ili sistem ne nude HDR izlaz.",
|
||||
"Turn HDR on to use it.": "Uključite HDR da ga koristite.",
|
||||
"Your GPU or graphics API doesn't support it.": "Vaš GPU ili grafički API to ne podržava.",
|
||||
"Not with software rendering, which runs it on the CPU.": "Ne uz softversko renderovanje, koje ga izvodi na CPU-u.",
|
||||
"Out of video memory: off until the next start.": "Nema video memorije: isključeno do sljedećeg pokretanja.",
|
||||
"The graphics device was lost: off until the next start.": "Grafički uređaj je izgubljen: isključeno do sljedećeg pokretanja.",
|
||||
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Još ne na DirectX 12: probajte Vulkan grafički API.",
|
||||
"%s chunks": "%s komada",
|
||||
"%s per frame": "%s po kadru",
|
||||
"%s MiB/frame": "%s MiB/okvir",
|
||||
@@ -115,13 +307,15 @@
|
||||
"Size of menus and the HUD.": "Veličina menija i HUD-a.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Zajedno postavlja donje opcije. Promjenom jednog postaje prilagođeno.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, vatra i sunčeva svjetlost sijaju jače od bijele. Potreban je HDR ekran.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Vaš ekran je najtamnije crn. 0 za OLED; LCD-i malo svijetle.",
|
||||
"Lifts shadows onto your display's darkest black. 0: true black, no lift.": "Podiže sjene do najcrnje crne vašeg ekrana. 0: prava crna, bez podizanja.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Kako svetli naglasci postaju. Automatski: vrhunac o kojem vaš sistem prijavljuje.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Kako izgledaju svijetli bijeli papir i meniji. Auto: vaša radna površina je bijela.",
|
||||
"How bright white looks in the world. Auto: your desktop's white.": "Koliko svijetlo izgleda bijelo u svijetu. Automatski: bijela vaše radne površine.",
|
||||
"How bright the HUD and menus are, against paper white.": "Koliko su svijetli HUD i meniji, naspram bijelog papira.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Koliko mobovi, usjevi i mašine rade. CPU vrijeme.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Koliko su udaljeni mobovi i predmeti, kao udio normalnog.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Preskače teren skriven iza zatvorenih dijelova. Isključenje košta puno.",
|
||||
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Odbacuje obližnji teren na GPU-u: manje CPU-a na velikoj udaljenosti renderovanja, više GPU-a.",
|
||||
"Auto: tries it where your CPU limits the frame, keeps it if frames get faster.": "Automatski: pokušava tamo gdje vaš CPU ograničava okvir i zadržava to ako okviri postanu brži.",
|
||||
"Hides entities behind opaque blocks.": "Sakriva entitete iza neprozirnih blokova.",
|
||||
"No entity occlusion culling.": "Nema uklanjanja okluzije entiteta.",
|
||||
"CPU occlusion test runs this frame.": "CPU test okluzije pokreće ovaj okvir.",
|
||||
@@ -145,6 +339,7 @@
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Svjetski pikseli po pikselu ekrana. Ispod 100% je brži na GPU-u.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Zaglađuje nazubljene ivice. FXAA: mekše. TAA: najglađe, pokretne stvari mogu zaostajati.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Čini svijet manjim i obnavlja ga pomoću TAA. Brže, mekše.",
|
||||
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Izoštrava uvećanu sliku. Više: oštrije ivice, više treperenja.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR izlaz na HDR displeju: lava, vatra i sunčeva svetlost sijaju jače.",
|
||||
"How frames reach the screen while VSync is off.": "Kako okviri dolaze do ekrana dok je VSync isključen.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Efikasno spavanje do sljedećeg kadra; Precizno okretanje, poput vanile.",
|
||||
@@ -164,6 +359,7 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "Brzina kadrova i vrijeme kadrova u igri, sa grafikonom.",
|
||||
"horizon.multiplayer.finding_friend": "Pronalaženje svijeta svog prijatelja...",
|
||||
"horizon.multiplayer.not_signed_in": "Ovaj server je u online modu. Prijavite se sa svojim Microsoft nalogom u Horizon pokretaču da mu se pridružite.",
|
||||
"horizon.realms.not_signed_in": "Prijavite se svojim Microsoft nalogom u Horizon pokretaču da igrate na Realms-u.",
|
||||
"horizon.multiplayer.invalid_address": "Nevažeća adresa servera: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Povezani direktno sa vašim prijateljem...",
|
||||
"horizon.multiplayer.connected_friend_relay": "Povezani sa vašim prijateljem preko releja...",
|
||||
@@ -173,6 +369,8 @@
|
||||
"Search options": "Opcije pretraživanja",
|
||||
"Shortcut": "Prečica",
|
||||
"Distance": "Udaljenost",
|
||||
"Display": "Ekran",
|
||||
"Interface": "Interfejs",
|
||||
"Sky and weather": "Nebo i vrijeme",
|
||||
"Lighting and entities": "Rasvjeta i entiteti",
|
||||
"Textures": "Teksture",
|
||||
@@ -181,6 +379,7 @@
|
||||
"Culling": "Odbijanje",
|
||||
"Power": "Snaga",
|
||||
"Frames": "Okviri",
|
||||
"Overlay": "Preklapanje",
|
||||
"Output": "Izlaz",
|
||||
"Brightness": "Osvetljenost",
|
||||
"How far you see, the window and the HUD.": "Koliko daleko vidite, prozor i HUD.",
|
||||
@@ -190,13 +389,17 @@
|
||||
"Brighter-than-white output on an HDR display.": "Izlaz svjetliji od bijelog na HDR ekranu.",
|
||||
"Results for \"%s\"": "Rezultati za \"%s\"",
|
||||
"No settings match.": "Nijedna postavka se ne podudara.",
|
||||
"horizon.plain.official": "Zvanični Horizon sa pokretačem, modovima i ažuriranjima: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "Nije povezan sa Mojangom ili Microsoftom",
|
||||
"horizon.extras.header.lighting": "Osvetljenje",
|
||||
"horizon.extras.header.third_person": "Treća osoba",
|
||||
"horizon.extras.header.particles": "Čestice",
|
||||
"horizon.extras.header.inventory": "Inventar",
|
||||
"horizon.extras.header.chat": "Časkanje",
|
||||
"horizon.extras.header.multiplayer": "Više igrača",
|
||||
"horizon.extras.header.sound": "Zvuk",
|
||||
"horizon.extras.header.menus": "Meniji",
|
||||
"horizon.extras.fullbright": "Puno svjetlo",
|
||||
"horizon.extras.dynamic_lights": "Dinamička svjetla",
|
||||
"horizon.extras.own_name_tag": "Oznaka s vlastitim imenom",
|
||||
"horizon.extras.hide_block_particles": "Sakrijte čestice razbijanja bloka",
|
||||
@@ -208,8 +411,14 @@
|
||||
"horizon.extras.sort": "Dugmad za sortiranje",
|
||||
"horizon.extras.sort_key": "Ključ za sortiranje",
|
||||
"horizon.extras.no_ai": "Nema AI",
|
||||
"horizon.extras.agent_control": "Kontrola agenta",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.zoom": "Zum",
|
||||
"horizon.extras.zoom_mod_install": "Zum: instalirajte iz Modova",
|
||||
"horizon.extras.splitscreen": "Splitscreen",
|
||||
"horizon.extras.chat_animation": "Glatko ćaskanje",
|
||||
"horizon.extras.chat_indicator": "Indikator poruke",
|
||||
"horizon.extras.wheel_cycles_list_settings.tooltip": "Točkić preko dugmeta u listi postavki mijenja njegovu vrijednost. Isključeno, kao u vanili, točkić pomiče listu.",
|
||||
"horizon.extras.wheel_cycles_settings": "Točkić za pomicanje mijenja postavke",
|
||||
"horizon.extras.pack_browser": "Pregledač paketa resursa",
|
||||
"horizon.extras.fullbright.tooltip": "Prikažite blokove i entitete pri punom svjetlu, čak iu tamnim područjima.",
|
||||
@@ -261,6 +470,7 @@
|
||||
"horizon.friends.hint": "Prijatelji zalijepe kod iza dugmeta Prijatelji na naslovnom ekranu, u Direktnu vezu ili na Play stranicu pokretača.",
|
||||
"horizon.friends.copied": "Kopirano",
|
||||
"horizon.friends.copy_code": "Kopiraj kod",
|
||||
"horizon.friends.stop_sharing": "Prekini dijeljenje",
|
||||
"horizon.friends.none_yet": "Ovdje još nema prijatelja.",
|
||||
"horizon.friends.here_now": "Evo sada: %s",
|
||||
"horizon.friends.share_intro": "Podijelite ovaj svijet iza pozivnog koda: otvara se na LAN, a prijatelji na internetu se pridružuju kodu. Nijedan naš server nije uključen.",
|
||||
@@ -269,9 +479,12 @@
|
||||
"horizon.friends.stopped": "Zaustavljeno je dijeljenje pozivnog koda.",
|
||||
"horizon.friends.short_code_changed": "Neko je nastavio pokušavati s pogrešnim kratkim kodom, pa se to promijenilo.",
|
||||
"horizon.friends.wrong_code": "Neko je probao pogrešan kod.",
|
||||
"horizon.friends.relay_stopped": "Previše pogrešnih kratkih kodova, pa je relej isključen dok ponovo ne podijelite. Prijatelji se još mogu pridružiti s punim kodom.",
|
||||
"horizon.friends.code_ready": "Vaš pozivni kod je spreman: Meni igre, Igrajte se sa prijateljima. Kopirajte ga i pošaljite svojim prijateljima.",
|
||||
"horizon.friends.share_failed": "Ne mogu dijeliti svijet: %s",
|
||||
"horizon.friends.finding_route": "Pronalaženje načina na koji prijatelji mogu doći do vas…",
|
||||
"horizon.osk.shift": "Shift",
|
||||
"horizon.osk.space": "Razmak",
|
||||
"horizon.osk.delete": "Izbriši",
|
||||
"horizon.osk.type": "Tip",
|
||||
"horizon.osk.symbols": "Simboli",
|
||||
@@ -280,11 +493,14 @@
|
||||
"horizon.extras.chat_indicator.tooltip": "Traka u boji lijevo od linije za ćaskanje.",
|
||||
"horizon.extras.pack_browser.tooltip": "Select Resource Packs dobija dugme Dodaj koje pretražuje Modrinth i instalira paket u fasciklu sa paketima. Isključeno, igra nikada ne kontaktira Modrinth.",
|
||||
"horizon.extras.splitscreen.tooltip": "U svijetu se drugi kontrolor može pridružiti kao novi igrač: pritisnite A kada to zatraži, a zatim odaberite profil i gdje ćete igrati.",
|
||||
"horizon.splitscreen.setup.monitor": "Monitor %s",
|
||||
"horizon.splitscreen.setup.monitor_unknown": "monitor",
|
||||
"horizon.splitscreen.setup.window": "Prozor %s",
|
||||
"horizon.splitscreen.setup.new_window": "Novi prozor",
|
||||
"horizon.splitscreen.setup.fill_monitor": "Popuni %s",
|
||||
"horizon.splitscreen.setup.done": "Gotovo",
|
||||
"horizon.splitscreen.setup.switch": "%s: %s",
|
||||
"horizon.splitscreen.setup.on": "Uključeno",
|
||||
"horizon.splitscreen.setup.off": "Isključeno",
|
||||
"horizon.splitscreen.setup.player": "Igrač %s",
|
||||
"horizon.splitscreen.setup.revert": "Vrati",
|
||||
@@ -311,6 +527,7 @@
|
||||
"horizon.splitscreen.setup.identify": "Identifikujte",
|
||||
"horizon.splitscreen.setup.layout.two": "2 dijeljenja: %s",
|
||||
"horizon.splitscreen.setup.layout.three": "3 dijeljenja: %s",
|
||||
"horizon.splitscreen.setup.layout.two.0": "Automatski",
|
||||
"horizon.splitscreen.setup.layout.two.1": "Rame uz rame",
|
||||
"horizon.splitscreen.setup.layout.two.2": "Gornji i donji deo",
|
||||
"horizon.splitscreen.setup.layout.three.0": "Jedan širok na vrhu",
|
||||
@@ -325,6 +542,7 @@
|
||||
"horizon.splitscreen.setup.help.revert": "Poništite promjene koje još nisu primijenjene.",
|
||||
"horizon.splitscreen.setup.help.apply": "Igrajte onako kako je prikazano na karti.",
|
||||
"horizon.splitscreen.setup.help.unchanged": "Još se ništa nije promijenilo.",
|
||||
"horizon.splitscreen.setup.give_up_pad": "Predaj kontroler",
|
||||
"horizon.splitscreen.prompt.controller_connected": "Kontroler povezan",
|
||||
"horizon.splitscreen.prompt.controller_disconnected": "Kontroler je isključen",
|
||||
"horizon.splitscreen.prompt.controller_disconnected.hint": "Ponovo ga povežite ili pritisnite dugme na drugom",
|
||||
@@ -339,11 +557,16 @@
|
||||
"horizon.splitscreen.setup.help.give_up_pad": "Vaš kontrolor prestaje da vas vozi i nudi mu se pridruživanje (A).",
|
||||
"horizon.splitscreen.setup.note.held_at": "%s do %s: A ispušta je ovdje, B vraća.",
|
||||
"horizon.splitscreen.setup.note.held": "%s podignuto: lijevo/desno bira gdje, A pada, B vraća nazad.",
|
||||
"horizon.splitscreen.online_only": "Ovaj server je u online modu: offline profili mu se ne mogu pridružiti, svaki igrač treba vlastiti Minecraft nalog.",
|
||||
"horizon.splitscreen.own_account": "Ovaj server zahtijeva vlastiti Minecraft nalog za svakog igrača",
|
||||
"horizon.splitscreen.local_only": "Drugi igrači se mogu pridružiti ovom serveru samo sa svojim Minecraft nalogom: offline profili igraju u vašem svijetu, na LAN-u ili na serveru na vašoj lokalnoj mreži.",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Skriva modele iza neprozirnih blokova. Async provjerava off-thread; entiteti otkucaju.",
|
||||
"Block Entities": "Blokirajte entitete",
|
||||
"Shulker Boxes": "Kutije za šulkere",
|
||||
"Reuses settled boxes; lid movement stays animated.": "Ponovno koristi postavljene kutije; pokreti poklopca ostaju animirani.",
|
||||
"Signs": "Znakovi",
|
||||
"Reuses sign text until text, light, or font changes.": "Ponovo koristi potpisni tekst dok se ne promijeni tekst, svjetlo ili font.",
|
||||
"Decorated Pots": "Ukrašeni lonci",
|
||||
"Reuses still pots; break wobble and sherd changes redraw.": "Ponovno koristi lonce za mirovanje; prekinuti kolebanje i promjene u komadu ponovo nacrtati.",
|
||||
"Bells": "Zvona",
|
||||
"Reuses still bells; ringing stays animated.": "Ponovno koristi zvona za mirovanje; zvono ostaje animirano.",
|
||||
@@ -353,22 +576,45 @@
|
||||
"Reuses poles; flags and pattern layers still update.": "Ponovo koristi stubove; zastavice i slojevi uzoraka se i dalje ažuriraju.",
|
||||
"Copper Golem Statues": "Statue od bakrenog golema",
|
||||
"Reuses statue geometry until its block state changes.": "Ponovo koristi geometriju statue dok se stanje bloka ne promijeni.",
|
||||
"Leaf Culling": "Odbijanje lišća",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Svaki list lice. Vanilini fensi listovi.",
|
||||
"Opaque Ice": "Neprozirni led",
|
||||
"Draws ice in the solid pass, without its see-through texture.": "Crta led u čvrstom prolazu, bez njegove prozirne teksture.",
|
||||
"Distant Plant Dither": "Rastriranje dalekih biljaka",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Čuva fine biljke u pakovanjima visoke rezolucije da ne izblijede.",
|
||||
"Screenshot Quality": "Kvaliteta snimka zaslona",
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2 screenshotovi na ovoj rezoluciji koja je mnogo puta veća od igre. Više traje duže.",
|
||||
"Frame Generation": "Generacija okvira",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Prikazuje napravljeni okvir između svaka dva stvarna: glatkiji, malo više zaostajanja.",
|
||||
"Shadow Cache": "Keš senki",
|
||||
"Shaders": "Šejderi",
|
||||
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Paketi šejdera, Horizonovi i oni iz foldera shaders/, i njihove opcije.",
|
||||
"Shader packs": "Paketi šejdera",
|
||||
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "Vanilin vlastiti predložak: postavlja samo vaniline opcije, kako to Minecraft radi.",
|
||||
"Fullscreen takes the display over, at the mode below.": "Cijeli ekran preuzima prikaz, u načinu ispod.",
|
||||
"The display mode exclusive fullscreen uses. Applies when you leave.": "Način prikaza koji koristi isključivi cijeli ekran. Primjenjuje se kada izađete.",
|
||||
"Blurs the world behind menus.": "Zamagljuje svijet iza menija.",
|
||||
"How textures are sampled: sharper at a slant with Anisotropic.": "Kako se uzorkuju teksture: oštrije pod uglom uz anizotropno filtriranje.",
|
||||
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "Dokle ide anizotropno filtriranje. Više je oštrije pod uglom.",
|
||||
"Only with Anisotropic texture filtering.": "Samo uz anizotropno filtriranje tekstura.",
|
||||
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "Bira paket šejdera, Horizonov ili onaj iz foldera shaders/, i postavlja njegove opcije.",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Ponovo koristi mapu senki paketa kada se poklope sunce, pogled i svijet. Podrazumevano isključeno.",
|
||||
"Find BlueMap": "Pronađite BlueMap",
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.": "Na serverima pronalazi njihovu BlueMap web kartu za daleki teren. Sljedeći spoj.",
|
||||
"BlueMap Radius": "BlueMap radijus",
|
||||
"How far around you BlueMap tiles are downloaded. Next join.": "Koliko daleko oko vas se preuzimaju BlueMap pločice. Sljedeći spoj.",
|
||||
"Synchronous": "Sinhroni",
|
||||
"Asynchronous": "Asinhroni",
|
||||
"Hidden Faces": "Skrivena lica",
|
||||
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Odbacuje lica listova iza dva sloja lišća: brže, iste krošnje.",
|
||||
"Solid": "Čvrsto",
|
||||
"Solid Aggressive": "Čvrsto agresivno",
|
||||
"Hollow": "Šuplje",
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Listovi u potpunosti unutar krošnje crtaju čvrsto: manje providnih slojeva.",
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges.": "Listovi s jednom otvorenom stranom također crtaju čvrsto: brže, zdepanije ivice.",
|
||||
"Drops faces between leaves: faster, but gaps look through.": "Spušta lica između listova: brže, ali praznine gledaju kroz njih.",
|
||||
"Hotbar: Previous Slot": "Hotbar: prethodni slot",
|
||||
"Hotbar: Next Slot": "Hotbar: sljedeći slot",
|
||||
"horizon.agent.connected.one": "Povezano: 1 veza agenta",
|
||||
"horizon.agent.connected.many": "Povezano: %s veze agenta",
|
||||
"horizon.agent.connected.one_ago": "Povezano: 1 veza agenta, zadnja komanda prije %s s",
|
||||
@@ -382,6 +628,10 @@
|
||||
"Auto (%s nits)": "automatski (%s gnjida)",
|
||||
"%.2f nits": "%.2f gnjida",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Skriva modele iza neprozirnih blokova. Ažuriranje entiteta. Isključeno sa prilagođenim paketima.",
|
||||
"Entity Culling Precheck": "Prethodna provjera odbijanja entiteta",
|
||||
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Pokreće AABB prethodnu provjeru mobova nakon ovoliko učitanih mobova. Nula je isključuje.",
|
||||
"%s mobs": "%s mobova",
|
||||
"Only applies to asynchronous entity culling.": "Primjenjuje se samo na asinhrono odbijanje entiteta.",
|
||||
"horizon.extras.repro_keys": "Prečice za snimanje",
|
||||
"horizon.extras.repro_keys.tooltip": "F3+R pokreće ili zaustavlja repro snimanje, a F3+E upisuje njegov paket. Podrazumevano isključeno: snimak kopira svijet.",
|
||||
"horizon.agent.record_session": "Započnite snimanje sesije",
|
||||
@@ -390,8 +640,20 @@
|
||||
"horizon.repro.started": "Snimanje je počelo; F3+R ga zaustavlja i upisuje paket.",
|
||||
"horizon.repro.stopped": "Snimanje zaustavljeno: %s",
|
||||
"horizon.repro.bundle": "Repro paket: %s",
|
||||
"horizon.repro.error": "Repro: %s",
|
||||
"horizon.repro.no_recording": "Još uvijek nema snimka za izvoz.",
|
||||
"horizon.repro.replay_started": "Repriza je počela: %s",
|
||||
"horizon.splitscreen.profiles.offline": " (offline)",
|
||||
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
|
||||
"horizon.splitscreen.profiles.sign_in": "Prijavite se ponovo u pokretaču",
|
||||
"horizon.splitscreen.profiles.online": "Microsoft online",
|
||||
"horizon.splitscreen.account_unavailable": "%s je offline. Prijavite se ponovo u pokretaču, pa restartujte igru.",
|
||||
"horizon.splitscreen.account_expired": "%s: Microsoft prijava je istekla. Prijavite se ponovo u pokretaču, pa restartujte igru.",
|
||||
"Particle Culling": "Odbijanje čestica",
|
||||
"Draws every particle.": "Crta svaku česticu.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "Preskače čestice van ekrana, one manje od piksela i one skrivene terenom.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Preskače čestice van ekrana, one manje od piksela i one skrivene terenom uz manje rada na niti za renderovanje.",
|
||||
"horizon.pack.add": "+",
|
||||
"horizon.pack.addInfo": "Dodajte paket resursa iz Modrintha",
|
||||
"horizon.pack.addWorking": "Pitam Modrintha...",
|
||||
"horizon.pack.addNone": "Nije pronađen nijedan paket resursa",
|
||||
@@ -405,10 +667,28 @@
|
||||
"horizon.pack.filter.realistic": "Realističan",
|
||||
"horizon.pack.filter.simplistic": "Pojednostavljeno",
|
||||
"horizon.pack.filter.vanillaLike": "Nalik vaniliji",
|
||||
"horizon.pack.filter.x16": "16x",
|
||||
"horizon.pack.filter.x32": "32x",
|
||||
"horizon.pack.filter.x64": "64x",
|
||||
"horizon.pack.addFailed": "Paket se nije mogao dodati: %s",
|
||||
"horizon.pack.options": "Opcije paketa",
|
||||
"horizon.pack.optionsInfo": "Opcije ovog paketa",
|
||||
"horizon.pack.options.title": "Opcije paketa",
|
||||
"horizon.pack.options.general": "Opšte",
|
||||
"horizon.pack.options.presets": "Predlošci",
|
||||
"horizon.pack.options.presets.custom": "Prilagođeno",
|
||||
"horizon.pack.options.impact": "Utjecaj na performanse: %s",
|
||||
"horizon.pack.options.impact.low": "nizak",
|
||||
"horizon.pack.options.impact.medium": "srednji",
|
||||
"horizon.pack.options.impact.high": "visok",
|
||||
"horizon.pack.options.impact.varies": "varira",
|
||||
"horizon.extras.header.cosmetics": "Kozmetika",
|
||||
"horizon.extras.cosmetics_mine": "Moja kozmetika",
|
||||
"horizon.extras.cosmetics_mine.tooltip": "Skriveno: svaki Horizon igrač vas vidi bez vaše kozmetike, uključujući i vas. Isti prekidač kao Ormar lansera.",
|
||||
"key.emote": "Točkić emotikona",
|
||||
"horizon.emote_wheel.title": "Emotikone",
|
||||
"horizon.emote_wheel.empty": "Još nema emotikona",
|
||||
"horizon.emote_wheel.help": "Otpustite ili kliknite da pustite · 1–8 · Esc zatvara",
|
||||
"horizon.extras.cosmetics_others": "Kozmetika drugih igrača",
|
||||
"horizon.extras.cosmetics_others.tooltip": "Šta drugi Horizon igrači nose: sve, samo stilove oznaka sa imenom ili ništa. Skriveno, igra nikad ne pita šta nose.",
|
||||
"horizon.extras.cosmetics_hide_fx": "Sakrij tragove i aure drugih igrača",
|
||||
@@ -433,5 +713,349 @@
|
||||
"Pack settings": "Postavke paketa",
|
||||
"Built-in settings": "Ugrađene postavke",
|
||||
"%s settings": "Postavke za %s",
|
||||
"Settings made by the pack's author: they change only this pack.": "Postavke koje je napravio autor paketa: mijenjaju samo ovaj paket."
|
||||
"Settings made by the pack's author: they change only this pack.": "Postavke koje je napravio autor paketa: mijenjaju samo ovaj paket.",
|
||||
"horizon.moderation.duration.minute": "%s minuta",
|
||||
"horizon.moderation.duration.minutes": "%s minuta",
|
||||
"horizon.moderation.duration.hour": "%s sat",
|
||||
"horizon.moderation.duration.hours": "%s sata",
|
||||
"horizon.moderation.duration.day": "%s dan",
|
||||
"horizon.moderation.duration.days": "%s dana",
|
||||
"horizon.moderation.reason": "%1$s: %2$s",
|
||||
"horizon.moderation.mutelist.empty": "Nema utišanih igrača",
|
||||
"horizon.moderation.mutelist.count": "Ima %s utišanih igrača:",
|
||||
"horizon.moderation.chat_filter": "filter časkanja",
|
||||
"horizon.moderation.until_unmuted": "do ukidanja utišavanja",
|
||||
"horizon.moderation.remaining": "još %s",
|
||||
"horizon.moderation.mutelist.entry": "%2$s je utišao %1$s %3$s",
|
||||
"horizon.moderation.invalid_duration": "%s nije trajanje: 10m, 2h, 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <player> [duration] [reason]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <player> <duration> [reason]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <player>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
"horizon.moderation.for_duration": "na %s",
|
||||
"horizon.moderation.muted": "Utišan %1$s %2$s",
|
||||
"horizon.moderation.unmuted": "Utišavanje ukinuto: %s",
|
||||
"horizon.moderation.not_muted": "%s nije utišan",
|
||||
"horizon.tempban.usage": "/tempban <player> <duration> [reason]",
|
||||
"horizon.tempban.invalid_duration": "%s nije trajanje: 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Privremene zabrane zahtijevaju namjenski server",
|
||||
"horizon.tempban.success": "Zabranjen %1$s na %2$s, do %3$s",
|
||||
"horizon.tempban.list.empty": "Nema privremenih zabrana",
|
||||
"horizon.tempban.list.count": "Ima %s privremenih zabrana:",
|
||||
"horizon.tempban.list.entry": "%2$s je zabranio %1$s %3$s (do %4$s)",
|
||||
"horizon.options.pointer_speed": "Brzina pokazivača u meniju",
|
||||
"horizon.options.scroll_speed": "Brzina pomicanja menija",
|
||||
"horizon.options.repeat_delay": "Kašnjenje ponavljanja navigacije",
|
||||
"horizon.options.repeat_interval": "Interval ponavljanja navigacije",
|
||||
"horizon.options.milliseconds": "%s ms",
|
||||
"horizon.extras.cosmetics_solo": "U igri za jednog igrača",
|
||||
"horizon.extras.cosmetics_solo.tooltip": "Prikažite svoju kozmetiku i bedž svoje oznake s imenom i u svojim svjetovima za jednog igrača (svoju oznaku s imenom uz Vlastitu oznaku s imenom). Isključeno: samo na serverima.",
|
||||
"horizon.extras.horizon_marks": "Horizon logotipi",
|
||||
"horizon.extras.horizon_marks.tooltip": "Prikažite Horizon logo pored Horizon igrača: mali logo prije imena drugih igrača koji koriste Horizon, iznad njihovih glava i u listi igrača. Vide ga samo Horizon igrači: serveri ne primaju ništa.",
|
||||
"horizon.extras.horizon_me": "Prikaži me kao Horizon igrača",
|
||||
"horizon.extras.horizon_me.tooltip": "Prikažite se kao Horizon igrač: drugi Horizon igrači vide logo uz vaše ime. Uključeno, usluga svakome ko pita o vama odgovara da ste igrali Horizon u posljednja 3 sata; isključeno, nikada vas ne prijavljuje.",
|
||||
"horizon.pages.pvp": "PvP i HUD",
|
||||
"horizon.pages.pvp.tooltip": "Postavke nišana, HUD-a i PvP-a, sve isključene po zadanom. Ništa ovdje ne prikazuje ono što vanila ne prikazuje.",
|
||||
"horizon.pages.open.pvp": "PvP i HUD...",
|
||||
"horizon.pvp.header.crosshair": "Nišan",
|
||||
"horizon.pvp.crosshair": "Prilagođeni nišan",
|
||||
"horizon.pvp.crosshair_shape": "Oblik",
|
||||
"horizon.pvp.crosshair_size": "Veličina",
|
||||
"horizon.pvp.crosshair_gap": "Razmak",
|
||||
"horizon.pvp.crosshair_thickness": "Debljina",
|
||||
"horizon.pvp.crosshair_colour": "Boja",
|
||||
"horizon.pvp.crosshair_outline": "Kontura",
|
||||
"horizon.pvp.crosshair_dot": "Tačka u sredini",
|
||||
"horizon.pvp.crosshair_hover": "Boja na entitetima",
|
||||
"horizon.pvp.crosshair.tooltip": "Crta vaš vlastiti nišan umjesto vanilinog. Njegove prve postavke izgledaju kao vaniline.",
|
||||
"horizon.pvp.crosshair_shape.tooltip": "Krst, T, X, krug, kvadrat ili samo tačka.",
|
||||
"horizon.pvp.crosshair_size.tooltip": "Dužina svake ruke, u pikselima interfejsa.",
|
||||
"horizon.pvp.crosshair_gap.tooltip": "Prazan prostor u sredini.",
|
||||
"horizon.pvp.crosshair_thickness.tooltip": "Koliko je široka svaka linija.",
|
||||
"horizon.pvp.crosshair_colour.tooltip": "Invertovano prikazuje boju ispod sebe invertovanu, kao što radi vanilin nišan.",
|
||||
"horizon.pvp.crosshair_outline.tooltip": "Tanka crna ivica, da se vidi na svakoj pozadini.",
|
||||
"horizon.pvp.crosshair_dot.tooltip": "Tačka u sredini nišana.",
|
||||
"horizon.pvp.crosshair_hover.tooltip": "Druga boja dok je nišan iznad moba ili igrača kojeg možete udariti, kako to vanilin indikator napada prepoznaje.",
|
||||
"horizon.pvp.colour.inverted": "Invertovano",
|
||||
"horizon.pvp.colour.white": "Bijela",
|
||||
"horizon.pvp.colour.red": "Crvena",
|
||||
"horizon.pvp.colour.orange": "Narandžasta",
|
||||
"horizon.pvp.colour.yellow": "Žuta",
|
||||
"horizon.pvp.colour.green": "Zelena",
|
||||
"horizon.pvp.colour.cyan": "Tirkizna",
|
||||
"horizon.pvp.colour.blue": "Plava",
|
||||
"horizon.pvp.colour.magenta": "Magenta",
|
||||
"horizon.pvp.colour.black": "Crna",
|
||||
"horizon.pvp.shape.cross": "Krst",
|
||||
"horizon.pvp.shape.t": "T",
|
||||
"horizon.pvp.shape.x": "X",
|
||||
"horizon.pvp.shape.circle": "Krug",
|
||||
"horizon.pvp.shape.square": "Kvadrat",
|
||||
"horizon.pvp.shape.dot": "Tačka",
|
||||
"horizon.pvp.outline": "Prilagođena kontura bloka",
|
||||
"horizon.pvp.outline.tooltip": "Mijenja konturu oko bloka kojeg gledate.",
|
||||
"horizon.pvp.outline_colour": "Boja",
|
||||
"horizon.pvp.outline_colour.tooltip": "Boja konture. Crna na 40% je vanilina.",
|
||||
"horizon.pvp.outline_opacity": "Neprozirnost",
|
||||
"horizon.pvp.outline_opacity.tooltip": "Koliko je kontura čvrsta.",
|
||||
"horizon.pvp.outline_thickness": "Debljina",
|
||||
"horizon.pvp.outline_thickness.tooltip": "Širina konture, u odnosu na vanilinu.",
|
||||
"horizon.pvp.outline_fill": "Ispuna",
|
||||
"horizon.pvp.outline_fill.tooltip": "Blago nijansira strane bloka kojeg gledate.",
|
||||
"horizon.pvp.hit": "Prilagođena boja udarca",
|
||||
"horizon.pvp.hit.tooltip": "Mijenja boju kojom mobovi i igrači bljesnu kada su povrijeđeni. Crvena na 30% je vanilina.",
|
||||
"horizon.pvp.hit_colour": "Boja",
|
||||
"horizon.pvp.hit_colour.tooltip": "Boja kojom povrijeđeni mobovi i igrači bljesnu.",
|
||||
"horizon.pvp.hit_opacity": "Neprozirnost",
|
||||
"horizon.pvp.hit_opacity.tooltip": "Koliko ih ta boja prekriva.",
|
||||
"horizon.pvp.header.outline": "Kontura bloka",
|
||||
"horizon.pvp.header.hit": "Boja udarca",
|
||||
"horizon.pvp.hud_armour": "Oklop",
|
||||
"horizon.pvp.hud_armour.tooltip": "Oklop koji nosite, počevši od glave, s brojem preostalih upotreba svakog dijela.",
|
||||
"horizon.pvp.hud_armour_corner": "Ugao oklopa",
|
||||
"horizon.pvp.hud_armour_corner.tooltip": "Gdje se prikazuje oklop.",
|
||||
"horizon.pvp.hud_armour_percent": "Trajnost u %",
|
||||
"horizon.pvp.hud_armour_percent.tooltip": "Preostale upotrebe kao postotak maksimuma dijela umjesto kao broj.",
|
||||
"horizon.pvp.hud_effects": "Efekti",
|
||||
"horizon.pvp.hud_effects.tooltip": "Vaši efekti s imenima, nivoima i preostalim vremenom, umjesto ikona u gornjem desnom uglu.",
|
||||
"horizon.pvp.hud_effects_corner": "Ugao efekata",
|
||||
"horizon.pvp.hud_effects_corner.tooltip": "Gdje se prikazuju efekti.",
|
||||
"horizon.pvp.hud_held": "Broj držanih predmeta",
|
||||
"horizon.pvp.hud_held.tooltip": "Koliko predmeta iz vaše ruke ima u inventaru; strijele uz luk ili samostrel.",
|
||||
"horizon.pvp.hud_held_corner": "Ugao broja držanih",
|
||||
"horizon.pvp.hud_held_corner.tooltip": "Gdje se prikazuje broj.",
|
||||
"horizon.pvp.hud_background": "Pozadine vidžeta",
|
||||
"horizon.pvp.hud_background.tooltip": "Tamno polje iza svakog vidžeta.",
|
||||
"horizon.pvp.hud_opacity": "Neprozirnost pozadine",
|
||||
"horizon.pvp.hud_opacity.tooltip": "Koliko su tamna polja iza vidžeta.",
|
||||
"horizon.pvp.hud_text_colour": "Boja teksta",
|
||||
"horizon.pvp.hud_text_colour.tooltip": "Boja teksta vidžeta. Trajnost zadržava prelaz iz zelene u crvenu.",
|
||||
"horizon.pvp.hud_shadow": "Sjena teksta",
|
||||
"horizon.pvp.hud_shadow.tooltip": "Sjena ispod teksta vidžeta, kakvu ima vanilin HUD tekst.",
|
||||
"horizon.pvp.header.hud": "HUD vidžeti",
|
||||
"horizon.pvp.corner.top_left": "Gore lijevo",
|
||||
"horizon.pvp.corner.top_right": "Gore desno",
|
||||
"horizon.pvp.corner.bottom_left": "Dolje lijevo",
|
||||
"horizon.pvp.corner.bottom_right": "Dolje desno",
|
||||
"horizon.pvp.hud_ping": "Ping",
|
||||
"horizon.pvp.hud_ping.tooltip": "Kašnjenje koje server prijavljuje za vas, u milisekundama. Samo za više igrača.",
|
||||
"horizon.pvp.hud_ping_corner": "Ugao pinga",
|
||||
"horizon.pvp.hud_ping_corner.tooltip": "Gdje se prikazuje ping.",
|
||||
"horizon.pvp.hud_coords": "Koordinate",
|
||||
"horizon.pvp.hud_coords.tooltip": "Vaš položaj u blokovima i smjer u kojem gledate, kako ih prikazuje F3. Skriveno kada svijet ili server ograniči F3.",
|
||||
"horizon.pvp.hud_coords_corner": "Ugao koordinata",
|
||||
"horizon.pvp.hud_coords_corner.tooltip": "Gdje se prikazuju koordinate.",
|
||||
"horizon.pvp.hud_server": "Adresa servera",
|
||||
"horizon.pvp.hud_server.tooltip": "Adresa servera na kojem igrate. Samo za više igrača.",
|
||||
"horizon.pvp.hud_server_corner": "Ugao servera",
|
||||
"horizon.pvp.hud_server_corner.tooltip": "Gdje se prikazuje adresa.",
|
||||
"horizon.pvp.hud_combo": "Kombo brojač",
|
||||
"horizon.pvp.hud_combo.tooltip": "Uzastopni udarci koje je server potvrdio, dok ne budete povrijeđeni ili prođu 3 sekunde bez njih.",
|
||||
"horizon.pvp.hud_combo_corner": "Ugao komboa",
|
||||
"horizon.pvp.hud_combo_corner.tooltip": "Gdje se prikazuje kombo.",
|
||||
"horizon.pvp.hud.ping": "Ping: %s ms",
|
||||
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
|
||||
"horizon.pvp.hud.facing": "Smjer: %s",
|
||||
"horizon.pvp.hud.combo": "Kombo: %s",
|
||||
"horizon.pvp.hud.direction.north": "Sjever",
|
||||
"horizon.pvp.hud.direction.south": "Jug",
|
||||
"horizon.pvp.hud.direction.east": "Istok",
|
||||
"horizon.pvp.hud.direction.west": "Zapad",
|
||||
"horizon.pvp.hud_fps": "FPS",
|
||||
"horizon.pvp.hud_fps.tooltip": "Okviri nacrtani u posljednjoj sekundi.",
|
||||
"horizon.pvp.hud_fps_corner": "Ugao FPS-a",
|
||||
"horizon.pvp.hud_fps_corner.tooltip": "Gdje se prikazuje FPS.",
|
||||
"horizon.pvp.hud_cps": "Klikovi u sekundi",
|
||||
"horizon.pvp.hud_cps.tooltip": "Vaši lijevi i desni klikovi u posljednjoj sekundi.",
|
||||
"horizon.pvp.hud_cps_corner": "Ugao CPS-a",
|
||||
"horizon.pvp.hud_cps_corner.tooltip": "Gdje se prikazuju klikovi u sekundi.",
|
||||
"horizon.pvp.hud_keys": "Pritisci tipki",
|
||||
"horizon.pvp.hud_keys.tooltip": "Vaše tipke za kretanje, dugmad miša i skok, osvijetljeni dok ih držite, s klikovima u sekundi.",
|
||||
"horizon.pvp.hud_keys_corner": "Ugao pritisaka tipki",
|
||||
"horizon.pvp.hud_keys_corner.tooltip": "Gdje se prikazuju pritisci tipki.",
|
||||
"horizon.pvp.hud_clock": "Sat",
|
||||
"horizon.pvp.hud_clock.tooltip": "Vrijeme na vašem računaru.",
|
||||
"horizon.pvp.hud_clock_corner": "Ugao sata",
|
||||
"horizon.pvp.hud_clock_corner.tooltip": "Gdje se prikazuje sat.",
|
||||
"horizon.pvp.hud_reach": "Doseg",
|
||||
"horizon.pvp.hud_reach.tooltip": "Koliko daleko od vaših očiju je bio posljednji udarac, 3 sekunde. Samo mjeri: vaš doseg ostaje vanilin.",
|
||||
"horizon.pvp.hud_reach_corner": "Ugao dosega",
|
||||
"horizon.pvp.hud_reach_corner.tooltip": "Gdje se prikazuje doseg.",
|
||||
"horizon.pvp.hud_speed": "Brzina",
|
||||
"horizon.pvp.hud_speed.tooltip": "Koliko brzo se krećete po tlu, u blokovima u sekundi.",
|
||||
"horizon.pvp.hud_speed_corner": "Ugao brzine",
|
||||
"horizon.pvp.hud_speed_corner.tooltip": "Gdje se prikazuje brzina.",
|
||||
"horizon.pvp.hud.fps": "%s FPS",
|
||||
"horizon.pvp.hud.cps": "CPS: %s | %s",
|
||||
"horizon.pvp.hud.reach": "Doseg: %s",
|
||||
"horizon.pvp.hud.speed": "Brzina: %s b/s",
|
||||
"horizon.pvp.hud.keys.attack": "LMB",
|
||||
"horizon.pvp.hud.keys.use": "RMB",
|
||||
"horizon.pvp.hud.keys.cps": "%s CPS",
|
||||
"horizon.pvp.numeric_enchants": "Nivoi čaranja kao brojevi",
|
||||
"horizon.pvp.numeric_enchants.tooltip": "Opisi i sto za čaranje prikazuju Oštrinu 5 umjesto Oštrine V.",
|
||||
"horizon.pvp.header.items": "Predmeti",
|
||||
"horizon.pvp.fire_lower": "Niža vatra",
|
||||
"horizon.pvp.fire_lower.tooltip": "Spušta plamen koji vidite dok gorite, da prekriva manje ekrana.",
|
||||
"horizon.pvp.shield_size": "Veličina štita",
|
||||
"horizon.pvp.shield_size.tooltip": "Veličina štita u vašoj ruci, u prvom licu.",
|
||||
"horizon.pvp.totem_size": "Veličina animacije totema",
|
||||
"horizon.pvp.totem_size.tooltip": "Veličina totema koji ispuni ekran kada vas spasi.",
|
||||
"horizon.pvp.glint_colour": "Boja sjaja čaranja",
|
||||
"horizon.pvp.glint_colour.tooltip": "Boja svjetlucanja na začaranim predmetima i oklopu. Isključeno je vanilina ljubičasta.",
|
||||
"horizon.pvp.clear_water": "Čista voda",
|
||||
"horizon.pvp.clear_water.tooltip": "Pod vodom vidite onoliko koliko vam vanila dopušta nakon 30 sekundi, od početka.",
|
||||
"horizon.pvp.clear_lava": "Čista lava",
|
||||
"horizon.pvp.clear_lava.tooltip": "U lavi vidite onoliko koliko vam dopušta vanilina Otpornost na vatru, bez efekta.",
|
||||
"horizon.pvp.header.visual": "Vizualne izmjene",
|
||||
"horizon.pvp.view_time": "Doba dana",
|
||||
"horizon.pvp.view_time.tooltip": "Doba dana koje vidite: samo nebo i njegovo svjetlo. Vrijeme u svijetu teče dalje i ništa se ne šalje serveru.",
|
||||
"horizon.pvp.view_weather": "Vrijeme",
|
||||
"horizon.pvp.view_weather.tooltip": "Vrijeme koje vidite: samo kiša, njeno svjetlo i magla. Vrijeme u svijetu se nastavlja i ništa se ne šalje serveru.",
|
||||
"horizon.pvp.header.view": "Vaš pogled",
|
||||
"horizon.pvp.view.world": "Svjetsko",
|
||||
"horizon.pvp.time.morning": "Jutro",
|
||||
"horizon.pvp.time.noon": "Podne",
|
||||
"horizon.pvp.time.sunset": "Zalazak sunca",
|
||||
"horizon.pvp.time.midnight": "Ponoć",
|
||||
"horizon.pvp.weather.clear": "Vedro",
|
||||
"horizon.pvp.weather.rain": "Kiša",
|
||||
"horizon.pvp.weather.thunder": "Grmljavina",
|
||||
"horizon.chat.timestamp": "[%s] ",
|
||||
"horizon.chat.repeats": " (x%s)",
|
||||
"horizon.pvp.header.chat": "Časkanje",
|
||||
"horizon.chat.copied": "Poruka je kopirana",
|
||||
"horizon.chat.search": "Pretraži časkanje:",
|
||||
"horizon.pvp.chat_timestamps": "Vremenske oznake",
|
||||
"horizon.pvp.chat_timestamps.tooltip": "Vrijeme kada je svaka poruka došla, prije nje.",
|
||||
"horizon.pvp.chat_compact": "Kompaktno časkanje",
|
||||
"horizon.pvp.chat_compact.tooltip": "Poruka ponovljena uzastopno prikazuje se jednom, s brojem ponavljanja: (x3).",
|
||||
"horizon.pvp.chat_copy": "Kopiraj klikom",
|
||||
"horizon.pvp.chat_copy.tooltip": "Klik na poruku u otvorenom časkanju kopira njen tekst, osim ako klik radi nešto drugo.",
|
||||
"horizon.pvp.chat_search": "Pretraga časkanja",
|
||||
"horizon.pvp.chat_search.tooltip": "Ctrl+F u otvorenom časkanju: ono što upišete prikazuje samo poruke koje ga sadrže. Enter ili ponovo Ctrl+F zaustavlja.",
|
||||
"horizon.pvp.preset": "PvP predložak",
|
||||
"horizon.pvp.preset.tooltip": "Uključuje prilagođeni nišan, vidžete oklopa i efekata, prebacivanje sprintanja i šunjanja, nižu vatru, te brojač FPS-a i modove pritisaka tipki ako su instalirani. Vaše postavke za svaki ostaju.",
|
||||
"horizon.pvp.hud_editor_key": "Tipka za uređivanje HUD-a",
|
||||
"horizon.pvp.hud_editor_key.tooltip": "Otvara uređivač HUD-a iz igre. Kliknite, pa pritisnite tipku; Escape je odvezuje.",
|
||||
"horizon.pvp.editor": "Uredi HUD",
|
||||
"horizon.pvp.editor.tooltip": "Pomjerajte vidžete u igri: prevucite ih bilo gdje, mijenjajte veličinu točkićem, desni klik na vidžet ga vraća u njegov ugao.",
|
||||
"horizon.pvp.editor.hint": "Prevucite vidžete · Točkić: veličina · Desni klik: nazad u ugao",
|
||||
"horizon.pvp.combat_feel": "Osjećaj borbe",
|
||||
"horizon.pvp.combat_feel.tooltip": "Legacy crta borbe kao 1.8.9: predmet u vašoj ruci, njegov zamah, jedenje, luk i mač podignut dok držite tipku za korištenje, bez spuštanja nakon svakog udarca i bez indikatora napada. Samo izgled: ono što šaljete serveru ostaje isto.",
|
||||
"horizon.pvp.combat_indicator": "Indikator napada u Legacy",
|
||||
"horizon.pvp.combat_indicator.tooltip": "Zadržava indikator napada u Legacy, koji 1.8.9 nije imao.",
|
||||
"horizon.pvp.combat_feel.modern": "Moderno",
|
||||
"horizon.pvp.combat_feel.legacy": "Legacy (1.8.9)",
|
||||
"horizon.pvp.header.combat": "Osjećaj borbe",
|
||||
"horizon.pvp.combat_sounds": "Zvuci udaraca iz 1.8",
|
||||
"horizon.pvp.combat_sounds.tooltip": "U Legacy načinu izostavlja zvuke napada koje je dodala 1.9 (jak, slab, zamah, kritični...). Zvuci povrede ostaju.",
|
||||
"horizon.pvp.combat_rules": "1.8 borba u svjetovima koje ugostite",
|
||||
"horizon.pvp.combat_rules.tooltip": "Igranje za jednog igrača i svjetovi koje otvorite na LAN-u bore se po pravilima 1.8.9: nema hlađenja napada ni zamaha, šteta oružja iz 1.8, Snaga, Oštrina i kritični udarci. Serveri kojima se pridružite zadržavaju svoja pravila.",
|
||||
"Bindings": "Kontrole",
|
||||
"Play": "Igraj",
|
||||
"Device": "Uređaj",
|
||||
"Look": "Pogled",
|
||||
"Movement": "Kretanje",
|
||||
"Menus": "Meniji",
|
||||
"Actions": "Radnje",
|
||||
"Reset": "Resetuj",
|
||||
"Detected": "Otkriveno",
|
||||
"Glyphs": "Simboli",
|
||||
"Feedback": "Povratna informacija",
|
||||
"Controller": "Kontroler",
|
||||
"How the sticks turn and move you, and how menus repeat and scroll.": "Kako štapići skreću i pomjeraju vas, te kako se meniji ponavljaju i pomiču.",
|
||||
"Which button does what. Each player has their own.": "Koje dugme šta radi. Svaki igrač ima svoje.",
|
||||
"The controller the game sees, its button glyphs and its feedback.": "Kontroler koji igra vidi, njegovi simboli dugmadi i njegova povratna informacija.",
|
||||
"Press this row, then the button that should do it.": "Pritisnite ovaj red, pa dugme koje to treba da radi.",
|
||||
"Off, the game ignores every controller for this player.": "Isključeno, igra ignoriše svaki kontroler za ovog igrača.",
|
||||
"How fast the right stick turns the view.": "Koliko brzo desni štapić okreće pogled.",
|
||||
"Turn the view the other way, left and right.": "Okreće pogled na drugu stranu, lijevo i desno.",
|
||||
"Turn the view the other way, up and down.": "Okreće pogled na drugu stranu, gore i dolje.",
|
||||
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Koliko se desni štapić pomjeri prije nego se pogled okrene. Povećajte ako pogled šeta.",
|
||||
"Hold the button to sneak, or press it once to start and again to stop.": "Držite dugme za šunjanje ili ga pritisnite jednom da počne i ponovo da prestane.",
|
||||
"How far the left stick moves before you walk. Raise it if you drift.": "Koliko se lijevi štapić pomjeri prije nego prohodate. Povećajte ako šetate.",
|
||||
"How far a trigger is pulled before it counts as pressed.": "Koliko se okidač povuče prije nego se računa kao pritisnut.",
|
||||
"Shows what your buttons do right now, in play and in inventories.": "Prikazuje šta vaša dugmad rade upravo sada, u igri i u inventarima.",
|
||||
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "Prikazuje samo šta možete uraditi s onim što gledate, i glavna dugmad inventara.",
|
||||
"Doesn't show what your buttons do.": "Ne prikazuje šta vaša dugmad rade.",
|
||||
"Shows the buttons of the controller you're using.": "Prikazuje dugmad kontrolera koji koristite.",
|
||||
"Always shows Xbox buttons.": "Uvijek prikazuje Xbox dugmad.",
|
||||
"Always shows PlayStation buttons.": "Uvijek prikazuje PlayStation dugmad.",
|
||||
"Always shows Nintendo buttons.": "Uvijek prikazuje Nintendo dugmad.",
|
||||
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A, s desne strane, potvrđuje i skače, a B se vraća nazad, kao na Switchu.",
|
||||
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B, na dnu, potvrđuje i skače, a A se vraća nazad, kao na drugim kontrolerima.",
|
||||
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Otvara Steam tastaturu na Steam Decku pod Steamom, a inače Horizonovu.",
|
||||
"Always opens Horizon's keyboard.": "Uvijek otvara Horizonovu tastaturu.",
|
||||
"Always opens Steam's keyboard.": "Uvijek otvara Steam tastaturu.",
|
||||
"A DualSense's light shows your health, from green through amber to red.": "Svjetlo DualSensea prikazuje vaše zdravlje, od zelene preko jantarne do crvene.",
|
||||
"A DualSense's light changes with the dimension you're in.": "Svjetlo DualSensea mijenja se s dimenzijom u kojoj ste.",
|
||||
"A DualSense keeps its own light.": "DualSense zadržava svoje vlastito svjetlo.",
|
||||
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "Dopušta LT + A da izrađuje na serverima koji nisu vaši svjetovi. Neki serveri to smatraju makroom.",
|
||||
"Show the buttons for what you can do, while you play with a controller.": "Prikaži dugmad za ono što možete uraditi dok igrate s kontrolerom.",
|
||||
"How fast the stick moves the crosshair in inventories.": "Koliko brzo štapić pomiče nišan u inventarima.",
|
||||
"How fast the right stick scrolls a list.": "Koliko brzo desni štapić pomiče listu.",
|
||||
"How long a held direction waits before it repeats.": "Koliko dugo držani smjer čeka prije ponavljanja.",
|
||||
"How long a held direction takes between repeats.": "Koliko traje držani smjer između ponavljanja.",
|
||||
"Which on-screen keyboard opens when a text field is pressed.": "Koja tastatura na ekranu se otvara kada se pritisne tekstualno polje.",
|
||||
"Every action back on its default button.": "Svaka radnja nazad na svoje zadano dugme.",
|
||||
"These settings belong to this player's profile.": "Ove postavke pripadaju profilu ovog igrača.",
|
||||
"The controller driving this player, or else the first one connected.": "Kontroler koji upravlja ovim igračem ili, ako ga nema, prvi povezani.",
|
||||
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "USB proizvođač i proizvod koje sistem prijavljuje. Korisno kada prijavljujete kontroler koji se ne ponaša kako treba.",
|
||||
"The glyph family the game picked from the controller's maker.": "Porodica simbola koju je igra odabrala prema proizvođaču kontrolera.",
|
||||
"Follow the controller, or always show one family's buttons.": "Prati kontroler ili uvijek prikazuj dugmad jedne porodice.",
|
||||
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "Da li A i B na Nintendo kontroleru rade po položaju ili po slovima.",
|
||||
"The buttons menus show right now.": "Dugmad koja meniji prikazuju upravo sada.",
|
||||
"Rumble when something happens to you.": "Vibracija kada vam se nešto dogodi.",
|
||||
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "Kontroler nastavlja da radi dok ovaj prozor nije u fokusu, a gubitak fokusa neće pauzirati.",
|
||||
"A DualSense's triggers push back on bows and tools.": "Okidači DualSensea pružaju otpor na lukovima i alatima.",
|
||||
"What a DualSense's light shows, over USB or Bluetooth.": "Šta prikazuje svjetlo DualSensea, preko USB-a ili Bluetootha.",
|
||||
"Player profile": "Profil igrača",
|
||||
"Vendor and product": "Proizvođač i proizvod",
|
||||
"Detected glyphs": "Otkriveni simboli",
|
||||
"Glyphs in use": "Simboli u upotrebi",
|
||||
"Unknown": "Nepoznato",
|
||||
"No controller": "Nema kontrolera",
|
||||
"Editing: %s": "Uređivanje: %s",
|
||||
"Press the button for this. %s cancels.": "Pritisnite dugme za ovo. %s otkazuje.",
|
||||
"This button also does: %s": "Ovo dugme također radi: %s",
|
||||
"Not bound: press this row, then the button that should do it.": "Nije vezano: pritisnite ovaj red, pa dugme koje to treba da radi.",
|
||||
"horizon.pad.notice.connected.press": "Pritisnite da ga koristite",
|
||||
"horizon.recipe_transfer.missing": "Nedostaju predmeti",
|
||||
"horizon.recipe_transfer.no_room": "Inventar je pun",
|
||||
"horizon.recipe_transfer.unavailable": "Recept se ne uklapa u ovaj meni",
|
||||
"horizon.recipe_transfer.unsupported": "Server zahtijeva poznat recept",
|
||||
"horizon.studio.timeline.freecam_on": "Slobodna kamera: UKLJUČENA",
|
||||
"horizon.studio.timeline.freecam_off": "Slobodna kamera: ISKLJUČENA",
|
||||
"horizon.studio.timeline.freecam_short": "Kamera",
|
||||
"horizon.studio.timeline.freecam_active_short": "Kamera ✓",
|
||||
"horizon.studio.timeline.add_keyframe_short": "+ kadar",
|
||||
"horizon.studio.timeline.delete_keyframe_short": "− obriši",
|
||||
"horizon.studio.timeline.split": "Podijeli",
|
||||
"horizon.studio.timeline.marker": "Oznaka %s",
|
||||
"horizon.studio.timeline.seconds": "%s s",
|
||||
"horizon.studio.timeline.scene_count": "%s scena",
|
||||
"horizon.studio.timeline.scene_title": "Scena %s",
|
||||
"horizon.studio.timeline.sequence": "Sekvenca",
|
||||
"horizon.studio.topbar.title": "✦ Studio",
|
||||
"horizon.studio.topbar.file": "Datoteka",
|
||||
"horizon.studio.topbar.camera": "Kamera",
|
||||
"horizon.studio.topbar.view": "Prikaz",
|
||||
"horizon.studio.topbar.file_short": "Datoteka",
|
||||
"horizon.studio.topbar.camera_short": "Kamera",
|
||||
"horizon.studio.topbar.view_short": "Prikaz",
|
||||
"horizon.studio.topbar.game_pause_menu": "⏸ Meni za pauzu",
|
||||
"horizon.studio.topbar.pause_short": "Pauza",
|
||||
"horizon.studio.topbar.open_recordings": "📁 Otvori fasciklu snimaka",
|
||||
"horizon.studio.topbar.close_replay": "✕ Zatvori reprizu",
|
||||
"horizon.studio.topbar.exit": "Izlaz",
|
||||
"horizon.studio.topbar.toggle_freecam": "Uključi ili isključi slobodnu kameru",
|
||||
"horizon.studio.topbar.reset_to_player": "👤 Nazad na igrača",
|
||||
"horizon.studio.topbar.unlock_freecam": "🔓 Otključaj slobodnu kameru",
|
||||
"horizon.studio.topbar.lock_to_spline": "🔒 Zaključaj na spline",
|
||||
"horizon.studio.topbar.add_keyframe": "➕ Dodaj ključni kadar",
|
||||
"horizon.studio.topbar.add_marker": "Dodaj oznaku",
|
||||
"horizon.studio.topbar.clear_keyframes": "🗑 Očisti ključne kadrove",
|
||||
"horizon.studio.topbar.timeline_hud": "⏱ Vremenska linija",
|
||||
"horizon.studio.topbar.split_scene": "Podijeli scenu"
|
||||
}
|
||||
@@ -1,28 +1,137 @@
|
||||
{
|
||||
"horizon.extras.header.world": "Món",
|
||||
"horizon.extras.pregen": "Pregeneració de chunks",
|
||||
"horizon.extras.pregen.tooltip": "Activa /pregen als teus mons d'un jugador i de LAN. Amb les trampes activades, fes servir /pregen start per generar una zona abans d'explorar-la. No activa l'ordre als servidors on t'uneixes.",
|
||||
"horizon.options.controller": "Controlador",
|
||||
"horizon.options.three_d_skin_layers": "Capes de pell 3D",
|
||||
"horizon.options.vibration": "Vibració",
|
||||
"horizon.options.play_in_background": "Juga en segon pla",
|
||||
"horizon.options.adaptive_triggers": "Disparadors adaptatius",
|
||||
"horizon.options.gameplay_guide": "Guia de joc",
|
||||
"horizon.options.gameplay_guide.basic": "Bàsica",
|
||||
"horizon.options.gameplay_guide.full": "Completa",
|
||||
"horizon.options.gameplay_guide.off": "Apagada",
|
||||
"horizon.controller.guide.use": "Utilitza",
|
||||
"horizon.controller.guide.open": "Obre",
|
||||
"horizon.controller.guide.ride": "Munta",
|
||||
"horizon.controller.guide.trade": "Comercia",
|
||||
"horizon.controller.guide.feed": "Alimenta",
|
||||
"horizon.controller.guide.place": "Col·loca",
|
||||
"horizon.controller.guide.eat": "Menja",
|
||||
"horizon.controller.guide.drink": "Beu",
|
||||
"horizon.controller.guide.shoot": "Dispara",
|
||||
"horizon.controller.guide.load": "Carrega",
|
||||
"horizon.controller.guide.throw": "Llança",
|
||||
"horizon.controller.guide.raise": "Aixeca",
|
||||
"horizon.controller.guide.equip": "Equipa",
|
||||
"horizon.controller.guide.fish": "Pesca",
|
||||
"horizon.controller.guide.zoom": "Apropa",
|
||||
"horizon.controller.guide.boost": "Accelera",
|
||||
"horizon.controller.guide.break": "Trenca",
|
||||
"horizon.controller.guide.attack": "Ataca",
|
||||
"horizon.controller.guide.spectate": "Observa",
|
||||
"horizon.controller.guide.spectator_menu": "Menú d'espectador",
|
||||
"horizon.controller.guide.release_shoot": "Deixa anar per disparar",
|
||||
"horizon.controller.guide.release_throw": "Deixa anar per llançar",
|
||||
"horizon.controller.guide.release_stop": "Deixa anar per aturar",
|
||||
"horizon.controller.guide.fly": "Vola",
|
||||
"horizon.controller.guide.fly_up": "Vola amunt",
|
||||
"horizon.controller.guide.fly_down": "Vola avall",
|
||||
"horizon.controller.guide.swim_up": "Neda amunt",
|
||||
"horizon.controller.guide.swim_down": "Neda avall",
|
||||
"horizon.controller.guide.glide": "Planeja",
|
||||
"horizon.controller.guide.dismount": "Baixa",
|
||||
"horizon.controller.guide.stand": "Aixeca't",
|
||||
"horizon.controller.guide.drop": "Deixa anar l'objecte",
|
||||
"horizon.controller.guide.swap": "Canvia de mà",
|
||||
"horizon.controller.guide.take": "Agafa",
|
||||
"horizon.controller.guide.take_half": "Agafa'n la meitat",
|
||||
"horizon.controller.guide.put": "Deixa",
|
||||
"horizon.controller.guide.put_one": "Deixa'n un",
|
||||
"horizon.controller.guide.swap_stack": "Intercanvia",
|
||||
"horizon.controller.guide.quick_move": "Mou ràpid",
|
||||
"horizon.controller.guide.drop_stack": "Deixa anar la pila",
|
||||
"horizon.controller.guide.place_recipe": "Col·loca la recepta",
|
||||
"horizon.controller.guide.place_recipe_all": "Col·loca-ho tot",
|
||||
"horizon.controller.guide.alternatives": "Alternatives",
|
||||
"horizon.controller.guide.select": "Selecciona",
|
||||
"horizon.controller.guide.close": "Tanca",
|
||||
"horizon.controller.guide.pages": "Pàgines",
|
||||
"horizon.controller.guide.categories": "Categories",
|
||||
"horizon.controller.guide.tabs": "Pestanyes",
|
||||
"horizon.options.lightbar": "Barra de llums",
|
||||
"horizon.options.look_sensitivity": "Sensibilitat de la mirada",
|
||||
"horizon.options.invert_x": "Invertir la mirada X",
|
||||
"horizon.options.invert_y": "Invertir la mirada Y",
|
||||
"horizon.options.craft_on_servers": "Fabricació ràpida als servidors",
|
||||
"horizon.controller.direct_craft": "Fabricació ràpida",
|
||||
"horizon.options.sneak_button": "Botó d'ajupir-se",
|
||||
"horizon.options.move_deadzone": "Zona morta del moviment",
|
||||
"horizon.options.look_deadzone": "Zona morta de la vista",
|
||||
"horizon.options.trigger_threshold": "Llindar del gallet",
|
||||
"horizon.options.button_glyphs": "Glifos de botons",
|
||||
"horizon.options.on_screen_keyboard": "Teclat en pantalla",
|
||||
"horizon.options.chat_delay": "Retard de xat",
|
||||
"horizon.options.lightbar.health": "Salut",
|
||||
"horizon.options.lightbar.dimension": "Dimensió",
|
||||
"horizon.options.glyphs.auto": "Automàtic",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.nintendo_buttons": "Botons de Nintendo",
|
||||
"horizon.options.nintendo_buttons.position": "Per posició",
|
||||
"horizon.options.nintendo_buttons.letter": "Per lletra",
|
||||
"horizon.options.keyboard.auto": "Automàtic",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Aquesta funció encara no està disponible a Horizon.",
|
||||
"horizon.options.search": "Configuració de cerca...",
|
||||
"horizon.studio.record.usage": "Ús: /studio record <start [name]|stop|status>",
|
||||
"horizon.studio.record.started": "Enregistrament de l'Studio iniciat: %s",
|
||||
"horizon.studio.record.saved": "Enregistrament de l'Studio desat: %s",
|
||||
"horizon.studio.record.error": "Error de l'enregistrament de l'Studio: %s",
|
||||
"horizon.studio.record.status.active": "Enregistrament de l'Studio actiu — %s s, %s paquets, %s KB (%s)",
|
||||
"horizon.studio.record.status.inactive": "Enregistrament de l'Studio inactiu",
|
||||
"horizon.studio.replay.usage": "Ús: /studio replay <open PATH|pause|resume|speed MULTIPLIER|seek MS|status|close>",
|
||||
"horizon.studio.replay.open.usage": "Ús: /studio replay open <PATH>",
|
||||
"horizon.studio.replay.opened": "Reproducció de l'Studio oberta: %s (%s s, %s paquets)",
|
||||
"horizon.studio.replay.open_error": "No s'ha pogut obrir la reproducció: %s",
|
||||
"horizon.studio.replay.paused": "Reproducció de l'Studio en pausa",
|
||||
"horizon.studio.replay.resumed": "Reproducció de l'Studio represa",
|
||||
"horizon.studio.replay.inactive": "Cap reproducció de l'Studio activa",
|
||||
"horizon.studio.replay.speed.usage": "Ús: /studio replay speed <MULTIPLIER>",
|
||||
"horizon.studio.replay.speed.set": "Velocitat de la reproducció de l'Studio establerta a %s×",
|
||||
"horizon.studio.replay.speed.invalid": "Velocitat de reproducció no vàlida",
|
||||
"horizon.studio.replay.seek.usage": "Ús: /studio replay seek <MILLISECONDS>",
|
||||
"horizon.studio.replay.seeked": "Reproducció de l'Studio saltada a %s ms (%s s)",
|
||||
"horizon.studio.replay.seek.error": "No s'ha pogut saltar a la reproducció: %s",
|
||||
"horizon.studio.replay.seek.backward_requires_standalone": "Rebobinar només està disponible en una reproducció autònoma.",
|
||||
"horizon.studio.replay.seek.invalid": "Posició de reproducció no vàlida",
|
||||
"horizon.studio.replay.status": "Reproducció de l'Studio: %s / %s ms (%s%%), velocitat %s×, en pausa %s, acabada %s",
|
||||
"horizon.studio.replay.closed": "Reproducció de l'Studio tancada",
|
||||
"horizon.studio.freecam.enabled": "Càmera lliure de l'Studio activada. Fes servir WASD per moure't, Espai i Maj per pujar o baixar, i Control per moure't més de pressa.",
|
||||
"horizon.studio.freecam.disabled": "Càmera lliure de l'Studio desactivada",
|
||||
"horizon.studio.freecam.unavailable": "La càmera lliure de l'Studio no ha trobat cap càmera de jugador",
|
||||
"horizon.studio.freecam.replay_required": "La càmera lliure de l'Studio està disponible durant la reproducció",
|
||||
"horizon.studio.freecam.usage": "Ús: /studio freecam <on|off>",
|
||||
"horizon.studio.keyframe.added": "Fotograma clau de càmera afegit a %s ms. El recorregut es desa quan tanques la reproducció.",
|
||||
"horizon.studio.keyframe.cleared": "Recorregut de càmera esborrat. El canvi es desa quan tanques la reproducció.",
|
||||
"horizon.studio.keyframe.removed": "Fotograma clau de càmera %s eliminat. El canvi es desa quan tanques la reproducció.",
|
||||
"horizon.studio.keyframe.save_error": "No s'ha pogut desar el recorregut de càmera: %s",
|
||||
"horizon.studio.keyframe.remove_usage": "Ús: /studio keyframe remove <INDEX>",
|
||||
"horizon.studio.keyframe.index_invalid": "L'índex del fotograma clau de càmera està fora de rang",
|
||||
"horizon.studio.keyframe.usage": "Ús: /studio keyframe <add|remove INDEX|clear>",
|
||||
"horizon.studio.path.locked": "Reproducció del recorregut de càmera de l'Studio activada",
|
||||
"horizon.studio.path.unlocked": "Reproducció del recorregut de càmera de l'Studio aturada",
|
||||
"horizon.studio.path.usage": "Ús: /studio path <lock|unlock> (afegeix primer almenys un fotograma clau)",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Paquets Shader",
|
||||
"horizon.pages.shader_options": "Opcions d'ombra",
|
||||
"horizon.pages.controller": "Configuració del controlador",
|
||||
"horizon.pages.controller_binds": "El controlador enllaça",
|
||||
"horizon.pages.extras": "Extres d'Horizon",
|
||||
"horizon.pages.settings": "Configuració d'Horizon",
|
||||
"horizon.pages.studio": "Replay Studio",
|
||||
"horizon.pages.agent": "Agent d'IA",
|
||||
"horizon.split.leave": "marxar",
|
||||
"horizon.pages.split_settings": "Configuració de pantalla dividida",
|
||||
@@ -30,6 +139,7 @@
|
||||
"horizon.pages.split_setup": "Configuració de pantalla dividida",
|
||||
"horizon.pages.music": "Música",
|
||||
"horizon.pages.music.menu": "Menú",
|
||||
"horizon.pages.music.overworld": "Sobremón",
|
||||
"horizon.pages.music.creative": "Creatiu",
|
||||
"horizon.pages.music.underwater": "Sota l'aigua",
|
||||
"horizon.pages.music.end": "El final",
|
||||
@@ -42,6 +152,7 @@
|
||||
"horizon.pages.music.empty": "Sense pistes de música: els sons del joc no es carreguen.",
|
||||
"horizon.pages.music.tooltip": "Les pistes integrades, per situació: juga-ne una ara o desactiva-ne qualsevol perquè el joc deixi de triar-ne.",
|
||||
"horizon.pages.settings.tooltip": "Configuració pròpia d'Horizon: gràfics, controladors, extres i modificacions.",
|
||||
"horizon.pages.studio.tooltip": "Navega pels enregistraments desats i les seves metadades.",
|
||||
"horizon.pages.graphics.tooltip": "Gràfics, senzillament: cada paràmetre diu el que costa.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Tots els paràmetres gràfics, inclòs Horizon, amb el que costa cadascun.",
|
||||
"horizon.pages.controller.tooltip": "Controladors: els seus botons, pals, vibració i navegació per menús.",
|
||||
@@ -57,10 +168,19 @@
|
||||
"horizon.pages.open.controller": "Configuració del controlador...",
|
||||
"horizon.pages.open.split_setup": "Pantalla dividida...",
|
||||
"horizon.pages.open.extras": "Extres...",
|
||||
"horizon.pages.open.mods": "Mods...",
|
||||
"horizon.pages.open.agent": "Agent d'IA...",
|
||||
"horizon.pages.open.horizon_graphics": "Gràfics d'Horizon...",
|
||||
"horizon.pages.open.controller_binds": "El controlador s'enllaça...",
|
||||
"horizon.pages.open.horizon_settings": "Configuració d'Horizon...",
|
||||
"horizon.pages.open.studio": "Replay Studio...",
|
||||
"horizon.studio.saved_recordings": "Enregistraments desats",
|
||||
"horizon.studio.empty": "No s'ha trobat cap enregistrament a la carpeta d'enregistraments.",
|
||||
"horizon.studio.empty_hint": "Fes servir /studio record start <name> en un món, o deixa fitxers .mcpr allà.",
|
||||
"horizon.studio.open_folder": "Obre els enregistraments",
|
||||
"horizon.studio.open_replay": "Reprodueix",
|
||||
"horizon.studio.refresh": "Actualitza",
|
||||
"horizon.studio.unknown_server": "Desconegut",
|
||||
"horizon.pages.open.music": "Música...",
|
||||
"horizon.mods.empty": "Sense modificacions: poseu fitxers .hmod a la carpeta horizon-mods",
|
||||
"horizon.mods.stopped": " (aturat)",
|
||||
@@ -69,6 +189,28 @@
|
||||
"horizon.mods.settings": "Configuració",
|
||||
"horizon.mods.in_world": " (deixar el món per canviar)",
|
||||
"horizon.mods.open_folder": "Obriu la carpeta de modificacions",
|
||||
"horizon.mods.browse": "Navega pels mods...",
|
||||
"horizon.mods.browse.title": "Navega pels mods",
|
||||
"horizon.mods.browse.search": "Cerca mods...",
|
||||
"horizon.mods.browse.project": "Mod",
|
||||
"horizon.mods.browse.loading": "Llegint el registre de mods d'Horizon...",
|
||||
"horizon.mods.browse.failed": "No s'ha pogut llegir el registre de mods: %s",
|
||||
"horizon.mods.browse.none": "No s'ha trobat cap mod",
|
||||
"horizon.mods.browse.has_update": " (actualització)",
|
||||
"horizon.mods.browse.is_installed": " (instal·lat)",
|
||||
"horizon.mods.browse.by": "per %s",
|
||||
"horizon.mods.browse.installing": "S'està instal·lant...",
|
||||
"horizon.mods.browse.install": "Instal·la",
|
||||
"horizon.mods.browse.update": "Actualitza",
|
||||
"horizon.mods.browse.installed": "Instal·lat",
|
||||
"horizon.mods.browse.open_page": "Obre la pàgina",
|
||||
"horizon.mods.browse.enabled": "Mod",
|
||||
"horizon.mods.browse.changelog": "Canvis",
|
||||
"horizon.mods.browse.done": "%s %s està instal·lat i actiu.",
|
||||
"horizon.mods.browse.install_failed": "No s'ha pogut instal·lar: %s",
|
||||
"horizon.mods.switch.worldgen": "canvia la generació del món",
|
||||
"horizon.mods.switch.registries": "afegeix blocs, objectes o altres entrades del registre",
|
||||
"horizon.mods.switch.restart": "Reinicia el joc per aplicar-hi %s: afegeixen blocs, objectes o generació del món.",
|
||||
"horizon.shader_options.profile": "Perfil",
|
||||
"horizon.agent.copy_details": "Copia els detalls de la connexió",
|
||||
"horizon.multiplayer.join": "Uneix-te",
|
||||
@@ -80,6 +222,7 @@
|
||||
"LOD Distance": "Distància LOD",
|
||||
"Far Detail": "Detall llunyà",
|
||||
"Cave Culling": "Culling de coves",
|
||||
"GPU Culling": "Culling de GPU",
|
||||
"Entity Culling": "Culling d'entitats",
|
||||
"Chunk Updates": "Actualitzacions de blocs",
|
||||
"Upload Budget": "Carregar pressupost",
|
||||
@@ -93,10 +236,15 @@
|
||||
"Resolution Scale": "Escala de resolució",
|
||||
"Anti-aliasing": "Antialiasing",
|
||||
"Upscaling": "Ampliació d'escala",
|
||||
"Upscaling Sharpness": "Nitidesa de l'ampliació",
|
||||
"Paper White": "Paper blanc",
|
||||
"Peak Brightness": "Lluminositat màxima",
|
||||
"Black Level": "Nivell negre",
|
||||
"UI Brightness": "Brillantor de la IU",
|
||||
"HDR Lighting": "Il·luminació HDR",
|
||||
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Les fonts de llum, el sol i la llum solar brillen més enllà del blanc. Apagat: igual que SDR.",
|
||||
"Smooth Gradients": "Gradients suaus",
|
||||
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "Fotograma de 16 bits: els cels i les ombres foscos no fan bandes. Una mica més de VRAM. Proper inici.",
|
||||
"Battery Mode": "Mode de bateria",
|
||||
"Graphics API": "API de gràfics",
|
||||
"Translucent Batching": "Lots translúcids",
|
||||
@@ -110,6 +258,7 @@
|
||||
"Performance": "Rendiment",
|
||||
"Balanced": "Equilibrat",
|
||||
"Quality": "Qualitat",
|
||||
"Ultra": "Ultra",
|
||||
"Ultra Performance": "Ultra rendiment",
|
||||
"Native": "Nadiu",
|
||||
"Full": "Complet",
|
||||
@@ -117,22 +266,36 @@
|
||||
"Efficient": "Eficient",
|
||||
"Precise": "Precís",
|
||||
"Auto (VSync)": "Automàtic (VSync)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "Bústia de correu",
|
||||
"Immediate": "Immediatament",
|
||||
"Vulkan": "Vulkan",
|
||||
"DirectX 12": "DirectX 12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS i gràfic",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "Sense cost",
|
||||
"Low cost": "Baix cost",
|
||||
"Medium cost": "Cost mitjà",
|
||||
"High cost": "Alt cost",
|
||||
"Saves time": "Estalvia temps",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "Gràfics",
|
||||
"Done": "Fet",
|
||||
"This feature is not yet available in Horizon.": "Aquesta funció encara no està disponible a Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "La vostra pantalla o sistema no ofereixen sortida HDR.",
|
||||
"Turn HDR on to use it.": "Activa HDR per utilitzar-lo.",
|
||||
"Your GPU or graphics API doesn't support it.": "La vostra GPU o API de gràfics no ho admet.",
|
||||
"Not with software rendering, which runs it on the CPU.": "No amb el renderitzat per programari, que ho executa a la CPU.",
|
||||
"Out of video memory: off until the next start.": "Sense memòria de vídeo: desactivat fins al proper inici.",
|
||||
"The graphics device was lost: off until the next start.": "S'ha perdut el dispositiu gràfic: desactivat fins al proper inici.",
|
||||
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Encara no amb DirectX 12: prova l'API de gràfics Vulkan.",
|
||||
"%s chunks": "%s trossos",
|
||||
"%s per frame": "%s per fotograma",
|
||||
"%s MiB/frame": "%s MiB/fotograma",
|
||||
"%s KiB/frame": "%s KiB/fotograma",
|
||||
"Auto (%s)": "Automàtic (%s)",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Gamma de terreny amb tots els detalls. La distància LOD pot estendre els mons locals més lluny.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terreny llunyà gruixut en mons locals. Les GPU integrades s'aturen a 128 fragments.",
|
||||
@@ -144,13 +307,15 @@
|
||||
"Size of menus and the HUD.": "Mida dels menús i l'HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Estableix les opcions següents juntes. Canviar-ne un el fa personalitzat.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "La lava, el foc i la llum del sol brillen més que el blanc. Necessita una pantalla HDR.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "La pantalla és el negre més fosc. 0 per a OLED; Els LCD brillen una mica.",
|
||||
"Lifts shadows onto your display's darkest black. 0: true black, no lift.": "Puja les ombres fins al negre més fosc de la teva pantalla. 0: negre pur, sense pujar.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Com es fan els reflexos brillants. Automàtic: el pic que informa el vostre sistema.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Quin aspecte brillant del paper blanc i dels menús. Automàtic: el teu escriptori és blanc.",
|
||||
"How bright white looks in the world. Auto: your desktop's white.": "Com de brillant es veu el blanc al món. Automàtic: el blanc de l'escriptori.",
|
||||
"How bright the HUD and menus are, against paper white.": "Què brillants són l'HUD i els menús, contra el paper blanc.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Fins on segueixen treballant les turbes, els conreus i les màquines. Temps de CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "A quina distància es dibuixen les turbes i els articles, com a part de la normalitat.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Salta el terreny amagat darrere de seccions tancades. Off costa molt.",
|
||||
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Elimina el terreny proper a la GPU: menys CPU amb molta distància de renderitzat, més GPU.",
|
||||
"Auto: tries it where your CPU limits the frame, keeps it if frames get faster.": "Automàtic: ho prova on la CPU limita el fotograma i ho manté si els fotogrames van més ràpid.",
|
||||
"Hides entities behind opaque blocks.": "Amaga les entitats darrere de blocs opacs.",
|
||||
"No entity occlusion culling.": "Cap eliminació d'oclusió d'entitats.",
|
||||
"CPU occlusion test runs this frame.": "La prova d'oclusió de la CPU executa aquest marc.",
|
||||
@@ -174,6 +339,7 @@
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Píxels mundials per píxel de pantalla. Per sota del 100% és més ràpid a la GPU.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Allisa les vores dentades. FXAA: més suau. TAA: més suau, les coses en moviment poden seguir.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Redueix el món i el reconstrueix amb TAA. Més ràpid, més suau.",
|
||||
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.": "Fa més nítida la imatge ampliada. Més alt: vores més nítides, més tremolor.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Sortida HDR en una pantalla HDR: la lava, el foc i la llum del sol brillen més.",
|
||||
"How frames reach the screen while VSync is off.": "Com arriben els fotogrames a la pantalla mentre VSync està desactivat.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Dors eficients fins al següent fotograma; Girades precises, com la vainilla.",
|
||||
@@ -193,9 +359,11 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "Velocitat de fotogrames i temps de fotogrames al llarg del joc, amb un gràfic.",
|
||||
"horizon.multiplayer.finding_friend": "Trobar el món del teu amic...",
|
||||
"horizon.multiplayer.not_signed_in": "Aquest servidor està en mode en línia. Inicieu la sessió amb el vostre compte de Microsoft al llançador d'Horizon per unir-vos-hi.",
|
||||
"horizon.realms.not_signed_in": "Inicia la sessió amb el teu compte de Microsoft al llançador d'Horizon per jugar a Realms.",
|
||||
"horizon.multiplayer.invalid_address": "Adreça del servidor no vàlida: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Connectat directament amb el teu amic...",
|
||||
"horizon.multiplayer.connected_friend_relay": "Connectat amb el teu amic a través del relé...",
|
||||
"General": "General",
|
||||
"Advanced": "Avançat",
|
||||
"Undo": "Desfer",
|
||||
"Search options": "Opcions de cerca",
|
||||
@@ -205,8 +373,10 @@
|
||||
"Interface": "Interfície",
|
||||
"Sky and weather": "Cel i temps",
|
||||
"Lighting and entities": "Il·luminació i entitats",
|
||||
"Textures": "Textures",
|
||||
"Image": "Imatge",
|
||||
"Chunk loading": "Càrrega de trossos",
|
||||
"Culling": "Culling",
|
||||
"Power": "Poder",
|
||||
"Frames": "Marcs",
|
||||
"Overlay": "Superposició",
|
||||
@@ -219,6 +389,7 @@
|
||||
"Brighter-than-white output on an HDR display.": "Sortida més brillant que el blanc en una pantalla HDR.",
|
||||
"Results for \"%s\"": "Resultats per a \"%s\"",
|
||||
"No settings match.": "No coincideix cap configuració.",
|
||||
"horizon.plain.official": "Horizon oficial amb llançador, mods i actualitzacions: playhorizon.dev",
|
||||
"horizon.title.not_affiliated": "No està afiliat a Mojang o Microsoft",
|
||||
"horizon.extras.header.lighting": "Il·luminació",
|
||||
"horizon.extras.header.third_person": "Tercera persona",
|
||||
@@ -242,9 +413,12 @@
|
||||
"horizon.extras.no_ai": "Sense IA",
|
||||
"horizon.extras.agent_control": "Control d'agents",
|
||||
"horizon.extras.discord_presence": "Presència enriquida de Discord",
|
||||
"horizon.extras.zoom": "Zoom",
|
||||
"horizon.extras.zoom_mod_install": "Zoom: instal·la'l des de Mods",
|
||||
"horizon.extras.splitscreen": "Pantalla dividida",
|
||||
"horizon.extras.chat_animation": "Xat suau",
|
||||
"horizon.extras.chat_indicator": "Indicador de missatge",
|
||||
"horizon.extras.wheel_cycles_list_settings.tooltip": "La roda de desplaçament sobre un botó d'una llista de configuració en canvia el valor. Apagada, com a la vainilla, la roda desplaça la llista.",
|
||||
"horizon.extras.wheel_cycles_settings": "La roda de desplaçament canvia la configuració",
|
||||
"horizon.extras.pack_browser": "Navegador de paquets de recursos",
|
||||
"horizon.extras.fullbright.tooltip": "Mostra blocs i entitats a plena llum, fins i tot en zones fosques.",
|
||||
@@ -305,6 +479,7 @@
|
||||
"horizon.friends.stopped": "S'ha deixat de compartir el codi d'invitació.",
|
||||
"horizon.friends.short_code_changed": "Algú va seguir provant un codi curt equivocat, així que va canviar.",
|
||||
"horizon.friends.wrong_code": "Algú ha provat un codi incorrecte.",
|
||||
"horizon.friends.relay_stopped": "Massa codis curts incorrectes, així que el relé està aturat fins que tornis a compartir. Els amics encara s'hi poden unir amb el codi complet.",
|
||||
"horizon.friends.code_ready": "El teu codi d'invitació està preparat: Menú de joc, Juga amb els amics. Copia'l i envia'l als teus amics.",
|
||||
"horizon.friends.share_failed": "No s'ha pogut compartir el món: %s",
|
||||
"horizon.friends.finding_route": "Trobar com els amics et poden arribar...",
|
||||
@@ -318,11 +493,13 @@
|
||||
"horizon.extras.chat_indicator.tooltip": "La barra de colors a l'esquerra d'una línia de xat.",
|
||||
"horizon.extras.pack_browser.tooltip": "Select Resource Packs obté un botó Afegeix que cerca Modrinth i instal·la un paquet a la carpeta de paquets. Desactivat, el joc mai contacta amb Modrinth.",
|
||||
"horizon.extras.splitscreen.tooltip": "En un món, un altre controlador pot unir-se com a jugador nou: premeu A quan ho demani, després trieu un perfil i on jugar.",
|
||||
"horizon.splitscreen.setup.monitor": "Monitor %s",
|
||||
"horizon.splitscreen.setup.monitor_unknown": "un monitor",
|
||||
"horizon.splitscreen.setup.window": "Finestra %s",
|
||||
"horizon.splitscreen.setup.new_window": "Finestra nova",
|
||||
"horizon.splitscreen.setup.fill_monitor": "Ompliu %s",
|
||||
"horizon.splitscreen.setup.done": "Fet",
|
||||
"horizon.splitscreen.setup.switch": "%s: %s",
|
||||
"horizon.splitscreen.setup.on": "Encès",
|
||||
"horizon.splitscreen.setup.off": "Apagat",
|
||||
"horizon.splitscreen.setup.player": "Jugador %s",
|
||||
@@ -373,12 +550,16 @@
|
||||
"horizon.splitscreen.prompt.join.many": "%s els controladors volen unir-se",
|
||||
"horizon.splitscreen.prompt.join.yes": "Uneix-te",
|
||||
"horizon.splitscreen.prompt.join.as_p1": "Juga com a P1",
|
||||
"horizon.splitscreen.prompt.join.no": "No",
|
||||
"horizon.splitscreen.prompt.lost": "%s: controlador desconnectat",
|
||||
"horizon.splitscreen.prompt.lost.use_another": "Fes servir un altre",
|
||||
"horizon.splitscreen.prompt.lost.leave": "marxar",
|
||||
"horizon.splitscreen.setup.help.give_up_pad": "El vostre controlador deixa de conduir-vos i se li ofereix unir-se (A).",
|
||||
"horizon.splitscreen.setup.note.held_at": "%s a %s: A la deixa caure aquí, la B la torna a posar.",
|
||||
"horizon.splitscreen.setup.note.held": "%s agafat: esquerra/dreta escull on, A cau, B el torna a posar.",
|
||||
"horizon.splitscreen.online_only": "Aquest servidor està en mode en línia: els perfils fora de línia no s'hi poden unir, cada jugador necessita el seu propi compte de Minecraft.",
|
||||
"horizon.splitscreen.own_account": "Aquest servidor necessita el compte de Minecraft propi de cada jugador",
|
||||
"horizon.splitscreen.local_only": "Els altres jugadors només s'hi poden unir amb el seu propi compte de Minecraft: els perfils fora de línia juguen al teu món, a la LAN o en un servidor de la teva xarxa local.",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Amaga models darrere de blocs opacs. Async comprova fora de fil; les entitats marquen.",
|
||||
"Block Entities": "Bloquejar entitats",
|
||||
"Shulker Boxes": "Caixes Shulker",
|
||||
@@ -397,12 +578,26 @@
|
||||
"Reuses statue geometry until its block state changes.": "Reutilitza la geometria de l'estàtua fins que el seu estat de bloc canvia.",
|
||||
"Leaf Culling": "Talla de fulles",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Cada cara de fulla. Fulles elegants de vainilla.",
|
||||
"Opaque Ice": "Gel opac",
|
||||
"Draws ice in the solid pass, without its see-through texture.": "Dibuixa el gel a la passada sòlida, sense la seva textura transparent.",
|
||||
"Distant Plant Dither": "Dither de planta llunyana",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Evita que les plantes fines en paquets d'alta resolució s'esvaeixin lluny.",
|
||||
"Screenshot Quality": "Qualitat de la captura de pantalla",
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Captures de pantalla F2 amb moltes vegades la resolució del joc. Més alt triga més.",
|
||||
"Frame Generation": "Generació de fotogrames",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Mostra un marc inventat entre cadascun dels dos reals: més suau, una mica més de retard.",
|
||||
"Shadow Cache": "Cau d'ombres",
|
||||
"Shaders": "Shaders",
|
||||
"Shader packs, Horizon's and shaders/ folder ones, and their options.": "Paquets de shaders, els d'Horizon i els de la carpeta shaders/, i les seves opcions.",
|
||||
"Shader packs": "Paquets de shaders",
|
||||
"Vanilla's own preset: sets only vanilla's options, as Minecraft does.": "El preestablert de la vainilla: només estableix les opcions de la vainilla, com fa el Minecraft.",
|
||||
"Fullscreen takes the display over, at the mode below.": "La pantalla completa ocupa la pantalla, amb el mode de sota.",
|
||||
"The display mode exclusive fullscreen uses. Applies when you leave.": "El mode de pantalla que fa servir la pantalla completa exclusiva. S'aplica quan surts.",
|
||||
"Blurs the world behind menus.": "Desenfoca el món darrere dels menús.",
|
||||
"How textures are sampled: sharper at a slant with Anisotropic.": "Com es mostregen les textures: més nítides en angle amb Anisotròpic.",
|
||||
"How far Anisotropic filtering goes. Higher is sharper at a slant.": "Fins on arriba el filtratge anisotròpic. Més alt és més nítid en angle.",
|
||||
"Only with Anisotropic texture filtering.": "Només amb filtratge anisotròpic de textures.",
|
||||
"Picks a shader pack, Horizon's or a shaders/ folder one, and sets its options.": "Tria un paquet de shaders, d'Horizon o de la carpeta shaders/, i n'estableix les opcions.",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Reutilitza el mapa d'ombres del paquet quan el sol, la vista i el món coincideixen. Desactivat per defecte.",
|
||||
"Find BlueMap": "Busca BlueMap",
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.": "Als servidors, troba el seu mapa web BlueMap per a terrenys llunyans. A continuació uneix-te.",
|
||||
@@ -410,6 +605,8 @@
|
||||
"How far around you BlueMap tiles are downloaded. Next join.": "A quina distància al teu voltant es descarreguen les fitxes de BlueMap. A continuació uneix-te.",
|
||||
"Synchronous": "Sincrònic",
|
||||
"Asynchronous": "Asíncron",
|
||||
"Hidden Faces": "Cares ocultes",
|
||||
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Descarta les cares de fulla darrere de dues capes de fulles: més ràpid, les mateixes copes.",
|
||||
"Solid": "Sòlid",
|
||||
"Solid Aggressive": "Sòlid Agressiu",
|
||||
"Hollow": "Buit",
|
||||
@@ -427,8 +624,14 @@
|
||||
"horizon.agent.session_opened": "Obert per a aquesta sessió pel programa que va iniciar el joc.",
|
||||
"horizon.agent.socket": "Socket: %s (copieu els detalls del camí complet)",
|
||||
"horizon.agent.local_only": "Només local: agents en aquest ordinador, mai la xarxa.",
|
||||
"%s nits": "%s nits",
|
||||
"Auto (%s nits)": "Automàtic (%s nits)",
|
||||
"%.2f nits": "%.2f nits",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Amaga models darrere de blocs opacs. Actualització d'entitats. Apaga amb paquets personalitzats.",
|
||||
"Entity Culling Precheck": "Comprovació prèvia del culling d'entitats",
|
||||
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Executa la comprovació prèvia d'AABB dels mobs després d'aquest nombre de mobs carregats. Zero la desactiva.",
|
||||
"%s mobs": "%s mobs",
|
||||
"Only applies to asynchronous entity culling.": "Només s'aplica al culling asíncron d'entitats.",
|
||||
"horizon.extras.repro_keys": "Dreceres de gravació",
|
||||
"horizon.extras.repro_keys.tooltip": "F3+R inicia o atura un enregistrament de reproducció i F3+E escriu el seu paquet. Desactivat per defecte: una gravació copia el món.",
|
||||
"horizon.agent.record_session": "Inicieu la sessió de gravació",
|
||||
@@ -440,6 +643,17 @@
|
||||
"horizon.repro.error": "Reproducció: %s",
|
||||
"horizon.repro.no_recording": "Encara no hi ha cap gravació per exportar.",
|
||||
"horizon.repro.replay_started": "La reproducció ha començat: %s",
|
||||
"horizon.splitscreen.profiles.offline": " (Fora de línia)",
|
||||
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
|
||||
"horizon.splitscreen.profiles.sign_in": "Torna a iniciar la sessió al llançador",
|
||||
"horizon.splitscreen.profiles.online": "Microsoft en línia",
|
||||
"horizon.splitscreen.account_unavailable": "%s està fora de línia. Torna a iniciar la sessió al llançador i reinicia el joc.",
|
||||
"horizon.splitscreen.account_expired": "La sessió de Microsoft de %s ha caducat. Torna a iniciar la sessió al llançador i reinicia el joc.",
|
||||
"Particle Culling": "Culling de partícules",
|
||||
"Draws every particle.": "Dibuixa totes les partícules.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "Salta les partícules fora de pantalla, subpíxel i amagades pel terreny.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Salta les partícules fora de pantalla, subpíxel i amagades pel terreny amb menys feina del fil de renderitzat.",
|
||||
"horizon.pack.add": "+",
|
||||
"horizon.pack.addInfo": "Afegiu un paquet de recursos de Modrinth",
|
||||
"horizon.pack.addWorking": "Preguntant a Modrinth...",
|
||||
"horizon.pack.addNone": "No s'han trobat paquets de recursos",
|
||||
@@ -453,10 +667,28 @@
|
||||
"horizon.pack.filter.realistic": "Realista",
|
||||
"horizon.pack.filter.simplistic": "Simplista",
|
||||
"horizon.pack.filter.vanillaLike": "semblant a la vainilla",
|
||||
"horizon.pack.filter.x16": "16x",
|
||||
"horizon.pack.filter.x32": "32x",
|
||||
"horizon.pack.filter.x64": "64x",
|
||||
"horizon.pack.addFailed": "No s'ha pogut afegir el paquet: %s",
|
||||
"horizon.pack.options": "Opcions del paquet",
|
||||
"horizon.pack.optionsInfo": "Opcions d'aquest paquet",
|
||||
"horizon.pack.options.title": "Opcions del paquet",
|
||||
"horizon.pack.options.general": "General",
|
||||
"horizon.pack.options.presets": "Preestablerts",
|
||||
"horizon.pack.options.presets.custom": "Personalitzat",
|
||||
"horizon.pack.options.impact": "Impacte en el rendiment: %s",
|
||||
"horizon.pack.options.impact.low": "baix",
|
||||
"horizon.pack.options.impact.medium": "mitjà",
|
||||
"horizon.pack.options.impact.high": "alt",
|
||||
"horizon.pack.options.impact.varies": "variable",
|
||||
"horizon.extras.header.cosmetics": "Cosmètics",
|
||||
"horizon.extras.cosmetics_mine": "Els meus cosmètics",
|
||||
"horizon.extras.cosmetics_mine.tooltip": "Ocult: tots els jugadors d'Horizon et veuen sense els teus cosmètics, tu inclòs. El mateix interruptor que l'armari del llançador.",
|
||||
"key.emote": "Roda d'emotes",
|
||||
"horizon.emote_wheel.title": "Emotes",
|
||||
"horizon.emote_wheel.empty": "Encara no hi ha emotes",
|
||||
"horizon.emote_wheel.help": "Deixa anar o fes clic per reproduir · 1–8 · Esc tanca",
|
||||
"horizon.extras.cosmetics_others": "Cosmètica d'altres jugadors",
|
||||
"horizon.extras.cosmetics_others.tooltip": "Què porten altres jugadors d'Horizon: tot, només els seus estils d'etiquetes de nom o res. Amagat, el joc mai pregunta què porten.",
|
||||
"horizon.extras.cosmetics_hide_fx": "Amaga les esteles i les aures dels altres jugadors",
|
||||
@@ -481,5 +713,349 @@
|
||||
"Pack settings": "Configuració del paquet",
|
||||
"Built-in settings": "Configuració integrada",
|
||||
"%s settings": "Configuració de %s",
|
||||
"Settings made by the pack's author: they change only this pack.": "Configuració feta per l'autor del paquet: només canvia aquest paquet."
|
||||
"Settings made by the pack's author: they change only this pack.": "Configuració feta per l'autor del paquet: només canvia aquest paquet.",
|
||||
"horizon.moderation.duration.minute": "%s minut",
|
||||
"horizon.moderation.duration.minutes": "%s minuts",
|
||||
"horizon.moderation.duration.hour": "%s hora",
|
||||
"horizon.moderation.duration.hours": "%s hores",
|
||||
"horizon.moderation.duration.day": "%s dia",
|
||||
"horizon.moderation.duration.days": "%s dies",
|
||||
"horizon.moderation.reason": "%1$s: %2$s",
|
||||
"horizon.moderation.mutelist.empty": "No hi ha cap silenciament",
|
||||
"horizon.moderation.mutelist.count": "Hi ha %s silenciament(s):",
|
||||
"horizon.moderation.chat_filter": "el filtre de xat",
|
||||
"horizon.moderation.until_unmuted": "fins que es torni a permetre el xat",
|
||||
"horizon.moderation.remaining": "durant %s més",
|
||||
"horizon.moderation.mutelist.entry": "%1$s va ser silenciat per %2$s %3$s",
|
||||
"horizon.moderation.invalid_duration": "%s no és una durada: 10m, 2h, 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <player> [duration] [reason]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <player> <duration> [reason]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <player>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
"horizon.moderation.for_duration": "durant %s",
|
||||
"horizon.moderation.muted": "Has silenciat %1$s %2$s",
|
||||
"horizon.moderation.unmuted": "Has tornat a permetre el xat de %s",
|
||||
"horizon.moderation.not_muted": "%s no està silenciat",
|
||||
"horizon.tempban.usage": "/tempban <player> <duration> [reason]",
|
||||
"horizon.tempban.invalid_duration": "%s no és una durada: 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Els bans temporals necessiten un servidor dedicat",
|
||||
"horizon.tempban.success": "%1$s durant %2$s, fins a %3$s",
|
||||
"horizon.tempban.list.empty": "No hi ha cap ban temporal",
|
||||
"horizon.tempban.list.count": "Hi ha %s ban(s) temporal(s):",
|
||||
"horizon.tempban.list.entry": "%1$s va ser bandejat per %2$s %3$s (fins a %4$s)",
|
||||
"horizon.options.pointer_speed": "Velocitat del punter als menús",
|
||||
"horizon.options.scroll_speed": "Velocitat de desplaçament als menús",
|
||||
"horizon.options.repeat_delay": "Retard de repetició de navegació",
|
||||
"horizon.options.repeat_interval": "Interval de repetició de navegació",
|
||||
"horizon.options.milliseconds": "%s ms",
|
||||
"horizon.extras.cosmetics_solo": "A un jugador",
|
||||
"horizon.extras.cosmetics_solo.tooltip": "Mostra els teus cosmètics i la insígnia de la teva etiqueta de nom també als teus mons d'un jugador (la teva etiqueta de nom amb Etiqueta de nom pròpia). Apagat: només als servidors.",
|
||||
"horizon.extras.horizon_marks": "Logos d'Horizon",
|
||||
"horizon.extras.horizon_marks.tooltip": "Mostra el logo d'Horizon al costat dels jugadors d'Horizon: un logo petit abans dels noms dels altres jugadors que fan servir Horizon, a sobre dels caps i a la llista de jugadors. Només el veuen els jugadors d'Horizon: els servidors no reben res.",
|
||||
"horizon.extras.horizon_me": "Mostra'm com a Horizon",
|
||||
"horizon.extras.horizon_me.tooltip": "Mostra'm com a jugador d'Horizon: els altres jugadors d'Horizon veuen el logo al costat del teu nom. Activat, el servei respon a qui pregunti per tu que has jugat a Horizon en les últimes 3 hores; apagat, no t'informa mai.",
|
||||
"horizon.pages.pvp": "PvP i HUD",
|
||||
"horizon.pages.pvp.tooltip": "Retícula, HUD i ajustos de PvP, tots desactivats per defecte. Res d'aquí no mostra el que la vainilla no mostra.",
|
||||
"horizon.pages.open.pvp": "PvP i HUD...",
|
||||
"horizon.pvp.header.crosshair": "Retícula",
|
||||
"horizon.pvp.crosshair": "Retícula personalitzada",
|
||||
"horizon.pvp.crosshair_shape": "Forma",
|
||||
"horizon.pvp.crosshair_size": "Mida",
|
||||
"horizon.pvp.crosshair_gap": "Separació",
|
||||
"horizon.pvp.crosshair_thickness": "Gruix",
|
||||
"horizon.pvp.crosshair_colour": "Color",
|
||||
"horizon.pvp.crosshair_outline": "Contorn",
|
||||
"horizon.pvp.crosshair_dot": "Punt central",
|
||||
"horizon.pvp.crosshair_hover": "Color sobre entitats",
|
||||
"horizon.pvp.crosshair.tooltip": "Dibuixa la teva pròpia retícula en lloc de la de la vainilla. Els seus primers ajustos s'assemblen als de la vainilla.",
|
||||
"horizon.pvp.crosshair_shape.tooltip": "Una creu, una T, una X, un cercle, un quadrat o només un punt.",
|
||||
"horizon.pvp.crosshair_size.tooltip": "La llargada de cada braç, en píxels de la GUI.",
|
||||
"horizon.pvp.crosshair_gap.tooltip": "L'espai buit del mig.",
|
||||
"horizon.pvp.crosshair_thickness.tooltip": "Com de gruixuda és cada línia.",
|
||||
"horizon.pvp.crosshair_colour.tooltip": "Invertit mostra el color de sota invertit, com fa la retícula de la vainilla.",
|
||||
"horizon.pvp.crosshair_outline.tooltip": "Una vora negra fina, perquè es vegi sobre qualsevol fons.",
|
||||
"horizon.pvp.crosshair_dot.tooltip": "Un punt al mig de la retícula.",
|
||||
"horizon.pvp.crosshair_hover.tooltip": "Un altre color mentre la retícula és sobre un mob o un jugador que podries colpejar, tal com el coneix l'indicador d'atac de la vainilla.",
|
||||
"horizon.pvp.colour.inverted": "Invertit",
|
||||
"horizon.pvp.colour.white": "Blanc",
|
||||
"horizon.pvp.colour.red": "Vermell",
|
||||
"horizon.pvp.colour.orange": "Taronja",
|
||||
"horizon.pvp.colour.yellow": "Groc",
|
||||
"horizon.pvp.colour.green": "Verd",
|
||||
"horizon.pvp.colour.cyan": "Cian",
|
||||
"horizon.pvp.colour.blue": "Blau",
|
||||
"horizon.pvp.colour.magenta": "Magenta",
|
||||
"horizon.pvp.colour.black": "Negre",
|
||||
"horizon.pvp.shape.cross": "Creu",
|
||||
"horizon.pvp.shape.t": "T",
|
||||
"horizon.pvp.shape.x": "X",
|
||||
"horizon.pvp.shape.circle": "Cercle",
|
||||
"horizon.pvp.shape.square": "Quadrat",
|
||||
"horizon.pvp.shape.dot": "Punt",
|
||||
"horizon.pvp.outline": "Contorn de bloc personalitzat",
|
||||
"horizon.pvp.outline.tooltip": "Canvia el contorn del bloc que mires.",
|
||||
"horizon.pvp.outline_colour": "Color",
|
||||
"horizon.pvp.outline_colour.tooltip": "El color del contorn. Negre al 40% és el de la vainilla.",
|
||||
"horizon.pvp.outline_opacity": "Opacitat",
|
||||
"horizon.pvp.outline_opacity.tooltip": "Com de sòlid és el contorn.",
|
||||
"horizon.pvp.outline_thickness": "Gruix",
|
||||
"horizon.pvp.outline_thickness.tooltip": "L'amplada del contorn, en vegades el de la vainilla.",
|
||||
"horizon.pvp.outline_fill": "Ompliment",
|
||||
"horizon.pvp.outline_fill.tooltip": "Teny suaument les cares del bloc que mires.",
|
||||
"horizon.pvp.hit": "Color de cop personalitzat",
|
||||
"horizon.pvp.hit.tooltip": "Canvia el color amb què parpellegen els mobs i els jugadors quan reben mal. Vermell al 30% és el de la vainilla.",
|
||||
"horizon.pvp.hit_colour": "Color",
|
||||
"horizon.pvp.hit_colour.tooltip": "El color amb què parpellegen els mobs i els jugadors ferits.",
|
||||
"horizon.pvp.hit_opacity": "Opacitat",
|
||||
"horizon.pvp.hit_opacity.tooltip": "Quant del color els cobreix.",
|
||||
"horizon.pvp.header.outline": "Contorn de bloc",
|
||||
"horizon.pvp.header.hit": "Color de cop",
|
||||
"horizon.pvp.hud_armour": "Armadura",
|
||||
"horizon.pvp.hud_armour.tooltip": "L'armadura que portes, començant pel cap, amb els usos que li queden a cada peça.",
|
||||
"horizon.pvp.hud_armour_corner": "Cantonada de l'armadura",
|
||||
"horizon.pvp.hud_armour_corner.tooltip": "On es mostra l'armadura.",
|
||||
"horizon.pvp.hud_armour_percent": "Durabilitat en %",
|
||||
"horizon.pvp.hud_armour_percent.tooltip": "Usos restants com a percentatge del màxim de la peça en lloc d'un número.",
|
||||
"horizon.pvp.hud_effects": "Efectes",
|
||||
"horizon.pvp.hud_effects.tooltip": "Els teus efectes amb els noms, nivells i temps restant, en lloc de les icones de dalt a la dreta.",
|
||||
"horizon.pvp.hud_effects_corner": "Cantonada dels efectes",
|
||||
"horizon.pvp.hud_effects_corner.tooltip": "On es mostren els efectes.",
|
||||
"horizon.pvp.hud_held": "Comptador de l'objecte a la mà",
|
||||
"horizon.pvp.hud_held.tooltip": "Quants de l'objecte que tens a la mà conté el teu inventari; fletxes amb un arc o ballesta.",
|
||||
"horizon.pvp.hud_held_corner": "Cantonada del comptador",
|
||||
"horizon.pvp.hud_held_corner.tooltip": "On es mostra el comptador.",
|
||||
"horizon.pvp.hud_background": "Fons dels ginys",
|
||||
"horizon.pvp.hud_background.tooltip": "Una caixa fosca darrere de cada giny.",
|
||||
"horizon.pvp.hud_opacity": "Opacitat del fons",
|
||||
"horizon.pvp.hud_opacity.tooltip": "Com de fosques són les caixes darrere dels ginys.",
|
||||
"horizon.pvp.hud_text_colour": "Color del text",
|
||||
"horizon.pvp.hud_text_colour.tooltip": "El color del text dels ginys. La durabilitat manté el seu verd a vermell.",
|
||||
"horizon.pvp.hud_shadow": "Ombra del text",
|
||||
"horizon.pvp.hud_shadow.tooltip": "Una ombra sota el text dels ginys, com la té el text de l'HUD de la vainilla.",
|
||||
"horizon.pvp.header.hud": "Ginys de l'HUD",
|
||||
"horizon.pvp.corner.top_left": "A dalt a l'esquerra",
|
||||
"horizon.pvp.corner.top_right": "A dalt a la dreta",
|
||||
"horizon.pvp.corner.bottom_left": "A baix a l'esquerra",
|
||||
"horizon.pvp.corner.bottom_right": "A baix a la dreta",
|
||||
"horizon.pvp.hud_ping": "Ping",
|
||||
"horizon.pvp.hud_ping.tooltip": "La latència que el servidor t'informa, en mil·lisegons. Només multijugador.",
|
||||
"horizon.pvp.hud_ping_corner": "Cantonada del ping",
|
||||
"horizon.pvp.hud_ping_corner.tooltip": "On es mostra el ping.",
|
||||
"horizon.pvp.hud_coords": "Coordenades",
|
||||
"horizon.pvp.hud_coords.tooltip": "La teva posició en blocs i la direcció en què mires, com les mostra F3. Amagades quan el món o el servidor redueix F3.",
|
||||
"horizon.pvp.hud_coords_corner": "Cantonada de les coordenades",
|
||||
"horizon.pvp.hud_coords_corner.tooltip": "On es mostren les coordenades.",
|
||||
"horizon.pvp.hud_server": "Adreça del servidor",
|
||||
"horizon.pvp.hud_server.tooltip": "L'adreça del servidor on jugues. Només multijugador.",
|
||||
"horizon.pvp.hud_server_corner": "Cantonada del servidor",
|
||||
"horizon.pvp.hud_server_corner.tooltip": "On es mostra l'adreça.",
|
||||
"horizon.pvp.hud_combo": "Comptador de combo",
|
||||
"horizon.pvp.hud_combo.tooltip": "Cops seguits que el servidor ha confirmat, fins que reps mal o passen 3 segons sense cap.",
|
||||
"horizon.pvp.hud_combo_corner": "Cantonada del combo",
|
||||
"horizon.pvp.hud_combo_corner.tooltip": "On es mostra el combo.",
|
||||
"horizon.pvp.hud.ping": "Ping: %s ms",
|
||||
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
|
||||
"horizon.pvp.hud.facing": "Mirant: %s",
|
||||
"horizon.pvp.hud.combo": "Combo: %s",
|
||||
"horizon.pvp.hud.direction.north": "Nord",
|
||||
"horizon.pvp.hud.direction.south": "Sud",
|
||||
"horizon.pvp.hud.direction.east": "Est",
|
||||
"horizon.pvp.hud.direction.west": "Oest",
|
||||
"horizon.pvp.hud_fps": "FPS",
|
||||
"horizon.pvp.hud_fps.tooltip": "Fotogrames dibuixats en l'últim segon.",
|
||||
"horizon.pvp.hud_fps_corner": "Cantonada dels FPS",
|
||||
"horizon.pvp.hud_fps_corner.tooltip": "On es mostren els FPS.",
|
||||
"horizon.pvp.hud_cps": "Clics per segon",
|
||||
"horizon.pvp.hud_cps.tooltip": "Els teus clics esquerre i dret en l'últim segon.",
|
||||
"horizon.pvp.hud_cps_corner": "Cantonada dels CPS",
|
||||
"horizon.pvp.hud_cps_corner.tooltip": "On es mostren els clics per segon.",
|
||||
"horizon.pvp.hud_keys": "Tecles premudes",
|
||||
"horizon.pvp.hud_keys.tooltip": "Les teves tecles de moviment, botons del ratolí i salt, il·luminats mentre els mantens, amb els clics per segon.",
|
||||
"horizon.pvp.hud_keys_corner": "Cantonada de les tecles",
|
||||
"horizon.pvp.hud_keys_corner.tooltip": "On es mostren les tecles premudes.",
|
||||
"horizon.pvp.hud_clock": "Rellotge",
|
||||
"horizon.pvp.hud_clock.tooltip": "L'hora del teu ordinador.",
|
||||
"horizon.pvp.hud_clock_corner": "Cantonada del rellotge",
|
||||
"horizon.pvp.hud_clock_corner.tooltip": "On es mostra el rellotge.",
|
||||
"horizon.pvp.hud_reach": "Abast",
|
||||
"horizon.pvp.hud_reach.tooltip": "A quina distància dels teus ulls ha arribat el teu últim cop, durant 3 segons. Només mesura: el teu abast segueix sent el de la vainilla.",
|
||||
"horizon.pvp.hud_reach_corner": "Cantonada de l'abast",
|
||||
"horizon.pvp.hud_reach_corner.tooltip": "On es mostra l'abast.",
|
||||
"horizon.pvp.hud_speed": "Velocitat",
|
||||
"horizon.pvp.hud_speed.tooltip": "Com de ràpid et mous pel terra, en blocs per segon.",
|
||||
"horizon.pvp.hud_speed_corner": "Cantonada de la velocitat",
|
||||
"horizon.pvp.hud_speed_corner.tooltip": "On es mostra la velocitat.",
|
||||
"horizon.pvp.hud.fps": "%s FPS",
|
||||
"horizon.pvp.hud.cps": "CPS: %s | %s",
|
||||
"horizon.pvp.hud.reach": "Abast: %s",
|
||||
"horizon.pvp.hud.speed": "Velocitat: %s b/s",
|
||||
"horizon.pvp.hud.keys.attack": "LMB",
|
||||
"horizon.pvp.hud.keys.use": "RMB",
|
||||
"horizon.pvp.hud.keys.cps": "%s CPS",
|
||||
"horizon.pvp.numeric_enchants": "Nivells d'encantament com a números",
|
||||
"horizon.pvp.numeric_enchants.tooltip": "Els consells i la taula d'encantaments mostren Afilat 5 en lloc d'Afilat V.",
|
||||
"horizon.pvp.header.items": "Objectes",
|
||||
"horizon.pvp.fire_lower": "Foc més baix",
|
||||
"horizon.pvp.fire_lower.tooltip": "Mou les flames que veus mentre cremes més avall, perquè cobreixin menys pantalla.",
|
||||
"horizon.pvp.shield_size": "Mida de l'escut",
|
||||
"horizon.pvp.shield_size.tooltip": "La mida de l'escut que tens a la mà, en primera persona.",
|
||||
"horizon.pvp.totem_size": "Mida de l'animació del tòtem",
|
||||
"horizon.pvp.totem_size.tooltip": "La mida del tòtem que omple la pantalla quan et salva.",
|
||||
"horizon.pvp.glint_colour": "Color de la lluentor dels encantaments",
|
||||
"horizon.pvp.glint_colour.tooltip": "El color de la lluentor dels objectes i armadures encantats. Apagat és el porpra de la vainilla.",
|
||||
"horizon.pvp.clear_water": "Aigua clara",
|
||||
"horizon.pvp.clear_water.tooltip": "Sota l'aigua, veu tan lluny com la vainilla et deixa després de 30 segons allà, des del principi.",
|
||||
"horizon.pvp.clear_lava": "Lava clara",
|
||||
"horizon.pvp.clear_lava.tooltip": "A la lava, veu tan lluny com et deixa la Resistència al foc de la vainilla, sense l'efecte.",
|
||||
"horizon.pvp.header.visual": "Retocs visuals",
|
||||
"horizon.pvp.view_time": "Hora del dia",
|
||||
"horizon.pvp.view_time.tooltip": "L'hora del dia que veus: només el cel i la seva llum. L'hora del món segueix i no s'envia res al servidor.",
|
||||
"horizon.pvp.view_weather": "Temps",
|
||||
"horizon.pvp.view_weather.tooltip": "El temps que veus: només la pluja, la seva llum i la boira. El temps del món segueix i no s'envia res al servidor.",
|
||||
"horizon.pvp.header.view": "La teva vista",
|
||||
"horizon.pvp.view.world": "El del món",
|
||||
"horizon.pvp.time.morning": "Matí",
|
||||
"horizon.pvp.time.noon": "Migdia",
|
||||
"horizon.pvp.time.sunset": "Posta de sol",
|
||||
"horizon.pvp.time.midnight": "Mitjanit",
|
||||
"horizon.pvp.weather.clear": "Clar",
|
||||
"horizon.pvp.weather.rain": "Pluja",
|
||||
"horizon.pvp.weather.thunder": "Tempesta",
|
||||
"horizon.chat.timestamp": "[%s] ",
|
||||
"horizon.chat.repeats": " (x%s)",
|
||||
"horizon.pvp.header.chat": "Xat",
|
||||
"horizon.chat.copied": "Missatge copiat",
|
||||
"horizon.chat.search": "Cerca al xat:",
|
||||
"horizon.pvp.chat_timestamps": "Marca de temps",
|
||||
"horizon.pvp.chat_timestamps.tooltip": "L'hora en què va arribar cada missatge, abans.",
|
||||
"horizon.pvp.chat_compact": "Xat compacte",
|
||||
"horizon.pvp.chat_compact.tooltip": "Un missatge repetit seguit es mostra un cop, amb quantes vegades ha arribat: (x3).",
|
||||
"horizon.pvp.chat_copy": "Copia en fer clic",
|
||||
"horizon.pvp.chat_copy.tooltip": "Fer clic en un missatge del xat obert en copia el text, tret que el clic faci una altra cosa.",
|
||||
"horizon.pvp.chat_search": "Cerca al xat",
|
||||
"horizon.pvp.chat_search.tooltip": "Ctrl+F al xat obert: el que escrius només mostra els missatges que ho contenen. Retorn o Ctrl+F un altre cop ho atura.",
|
||||
"horizon.pvp.preset": "Preestablert de PvP",
|
||||
"horizon.pvp.preset.tooltip": "Activa la retícula personalitzada, els ginys d'armadura i efectes, l'esprint i ajupir-se alternats, el foc més baix i els mods de comptador de FPS i tecles premudes si estan instal·lats. Els teus ajustos de cadascun es mantenen.",
|
||||
"horizon.pvp.hud_editor_key": "Tecla de l'editor de l'HUD",
|
||||
"horizon.pvp.hud_editor_key.tooltip": "Obre l'editor de l'HUD des del joc. Fes clic i prem una tecla; Escape la desvincula.",
|
||||
"horizon.pvp.editor": "Edita l'HUD",
|
||||
"horizon.pvp.editor.tooltip": "Mou els ginys al joc: arrossega'ls on vulguis, canvia'n la mida amb la roda i fes clic dret sobre un per tornar-lo a la seva cantonada.",
|
||||
"horizon.pvp.editor.hint": "Arrossega els ginys · Roda: mida · Clic dret: torna a la seva cantonada",
|
||||
"horizon.pvp.combat_feel": "Sensació de combat",
|
||||
"horizon.pvp.combat_feel.tooltip": "El mode Clàssic dibuixa les lluites com ho feia la 1.8.9: l'objecte a la mà, el seu cop, menjar, l'arc i una espasa aixecada mentre mantens ús, sense la baixada després de cada cop ni l'indicador d'atac. Només l'aspecte: el que envies a un servidor no canvia.",
|
||||
"horizon.pvp.combat_indicator": "Indicador d'atac al mode Clàssic",
|
||||
"horizon.pvp.combat_indicator.tooltip": "Manté l'indicador d'atac al mode Clàssic, que la 1.8.9 no tenia.",
|
||||
"horizon.pvp.combat_feel.modern": "Modern",
|
||||
"horizon.pvp.combat_feel.legacy": "Clàssic (1.8.9)",
|
||||
"horizon.pvp.header.combat": "Sensació de combat",
|
||||
"horizon.pvp.combat_sounds": "Sons de cop de la 1.8",
|
||||
"horizon.pvp.combat_sounds.tooltip": "Al mode Clàssic, omet els sons d'atac que va afegir la 1.9 (fort, feble, escombrat, crític...). Els sons de ferit es mantenen.",
|
||||
"horizon.pvp.combat_rules": "Combat de la 1.8 als mons que hostatges",
|
||||
"horizon.pvp.combat_rules.tooltip": "El mode d'un jugador i els mons que obres a la LAN lluiten amb les regles de la 1.8.9: sense temps de reutilització d'atac ni escombrat, mal d'arma de la 1.8, Força, Afilat i cops crítics. Els servidors on t'uneixes mantenen les seves pròpies regles.",
|
||||
"Bindings": "Enllaços",
|
||||
"Play": "Juga",
|
||||
"Device": "Dispositiu",
|
||||
"Look": "Mirada",
|
||||
"Movement": "Moviment",
|
||||
"Menus": "Menús",
|
||||
"Actions": "Accions",
|
||||
"Reset": "Restableix",
|
||||
"Detected": "Detectat",
|
||||
"Glyphs": "Glifos",
|
||||
"Feedback": "Retroalimentació",
|
||||
"Controller": "Controlador",
|
||||
"How the sticks turn and move you, and how menus repeat and scroll.": "Com els pals et giren i et mouen, i com els menús es repeteixen i es desplacen.",
|
||||
"Which button does what. Each player has their own.": "Quin botó fa què. Cada jugador té el seu.",
|
||||
"The controller the game sees, its button glyphs and its feedback.": "El controlador que veu el joc, els glifos dels seus botons i la seva retroalimentació.",
|
||||
"Press this row, then the button that should do it.": "Prem aquesta fila i, després, el botó que ho ha de fer.",
|
||||
"Off, the game ignores every controller for this player.": "Apagat, el joc ignora tots els controladors d'aquest jugador.",
|
||||
"How fast the right stick turns the view.": "Com de ràpid el pal dret gira la vista.",
|
||||
"Turn the view the other way, left and right.": "Gira la vista a l'altre sentit, esquerra i dreta.",
|
||||
"Turn the view the other way, up and down.": "Gira la vista a l'altre sentit, amunt i avall.",
|
||||
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Quant s'ha de moure el pal dret abans que la vista giri. Apuja-ho si la vista es desplaça sola.",
|
||||
"Hold the button to sneak, or press it once to start and again to stop.": "Mantén el botó per ajupir-te, o prem-lo un cop per començar i un altre per aturar.",
|
||||
"How far the left stick moves before you walk. Raise it if you drift.": "Quant s'ha de moure el pal esquerre abans que caminis. Apuja-ho si et desplaces sol.",
|
||||
"How far a trigger is pulled before it counts as pressed.": "Quant s'ha de prémer un gallet abans que compti com a premut.",
|
||||
"Shows what your buttons do right now, in play and in inventories.": "Mostra què fan els teus botons ara mateix, al joc i als inventaris.",
|
||||
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "Mostra només què pots fer amb el que mires, i els botons principals de l'inventari.",
|
||||
"Doesn't show what your buttons do.": "No mostra què fan els teus botons.",
|
||||
"Shows the buttons of the controller you're using.": "Mostra els botons del controlador que estàs fent servir.",
|
||||
"Always shows Xbox buttons.": "Mostra sempre els botons de Xbox.",
|
||||
"Always shows PlayStation buttons.": "Mostra sempre els botons de PlayStation.",
|
||||
"Always shows Nintendo buttons.": "Mostra sempre els botons de Nintendo.",
|
||||
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A, a la dreta, confirma i salta, i B torna enrere, com a la Switch.",
|
||||
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B, a baix, confirma i salta, i A torna enrere, com als altres controladors.",
|
||||
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Obre el teclat de Steam en una Steam Deck amb Steam, i el d'Horizon altrament.",
|
||||
"Always opens Horizon's keyboard.": "Obre sempre el teclat d'Horizon.",
|
||||
"Always opens Steam's keyboard.": "Obre sempre el teclat de Steam.",
|
||||
"A DualSense's light shows your health, from green through amber to red.": "La llum del DualSense mostra la teva salut, del verd passant per l'ambre fins al vermell.",
|
||||
"A DualSense's light changes with the dimension you're in.": "La llum del DualSense canvia segons la dimensió on ets.",
|
||||
"A DualSense keeps its own light.": "El DualSense manté la seva pròpia llum.",
|
||||
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "Permet fabricar amb LT + A en servidors que no són els teus mons. Alguns servidors ho compten com a macro.",
|
||||
"Show the buttons for what you can do, while you play with a controller.": "Mostra els botons del que pots fer mentre jugues amb un controlador.",
|
||||
"How fast the stick moves the crosshair in inventories.": "Com de ràpid el pal mou la retícula als inventaris.",
|
||||
"How fast the right stick scrolls a list.": "Com de ràpid el pal dret desplaça una llista.",
|
||||
"How long a held direction waits before it repeats.": "Quant espera una direcció mantinguda abans de repetir-se.",
|
||||
"How long a held direction takes between repeats.": "Quant triga una direcció mantinguda entre repeticions.",
|
||||
"Which on-screen keyboard opens when a text field is pressed.": "Quin teclat en pantalla s'obre quan es prem un camp de text.",
|
||||
"Every action back on its default button.": "Torna cada acció al seu botó per defecte.",
|
||||
"These settings belong to this player's profile.": "Aquesta configuració pertany al perfil d'aquest jugador.",
|
||||
"The controller driving this player, or else the first one connected.": "El controlador que condueix aquest jugador o, si no, el primer connectat.",
|
||||
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "El proveïdor i el producte USB que informa el sistema. Útil per informar d'un controlador que va malament.",
|
||||
"The glyph family the game picked from the controller's maker.": "La família de glifos que el joc ha triat segons el fabricant del controlador.",
|
||||
"Follow the controller, or always show one family's buttons.": "Segueix el controlador, o mostra sempre els botons d'una família.",
|
||||
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "Si l'A i la B d'un controlador de Nintendo funcionen per la seva posició o per les seves lletres.",
|
||||
"The buttons menus show right now.": "Els botons que mostren els menús ara mateix.",
|
||||
"Rumble when something happens to you.": "Vibra quan et passa alguna cosa.",
|
||||
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "El controlador segueix jugant mentre aquesta finestra no té el focus, i perdre'l no posa en pausa.",
|
||||
"A DualSense's triggers push back on bows and tools.": "Els gallets del DualSense fan força en els arcs i les eines.",
|
||||
"What a DualSense's light shows, over USB or Bluetooth.": "Què mostra la llum del DualSense, per USB o Bluetooth.",
|
||||
"Player profile": "Perfil de jugador",
|
||||
"Vendor and product": "Proveïdor i producte",
|
||||
"Detected glyphs": "Glifos detectats",
|
||||
"Glyphs in use": "Glifos en ús",
|
||||
"Unknown": "Desconegut",
|
||||
"No controller": "Cap controlador",
|
||||
"Editing: %s": "Editant: %s",
|
||||
"Press the button for this. %s cancels.": "Prem el botó per a això. %s cancel·la.",
|
||||
"This button also does: %s": "Aquest botó també fa: %s",
|
||||
"Not bound: press this row, then the button that should do it.": "No vinculat: prem aquesta fila i, després, el botó que ho ha de fer.",
|
||||
"horizon.pad.notice.connected.press": "Prem per fer-lo servir",
|
||||
"horizon.recipe_transfer.missing": "Falten objectes",
|
||||
"horizon.recipe_transfer.no_room": "Inventari ple",
|
||||
"horizon.recipe_transfer.unavailable": "La recepta no cap en aquest menú",
|
||||
"horizon.recipe_transfer.unsupported": "El servidor requereix una recepta coneguda",
|
||||
"horizon.studio.timeline.freecam_on": "Càmera lliure: ACTIVADA",
|
||||
"horizon.studio.timeline.freecam_off": "Càmera lliure: DESACTIVADA",
|
||||
"horizon.studio.timeline.freecam_short": "Càm",
|
||||
"horizon.studio.timeline.freecam_active_short": "Càm ✓",
|
||||
"horizon.studio.timeline.add_keyframe_short": "+ Clau",
|
||||
"horizon.studio.timeline.delete_keyframe_short": "− Elim.",
|
||||
"horizon.studio.timeline.split": "Divideix",
|
||||
"horizon.studio.timeline.marker": "Marcador %s",
|
||||
"horizon.studio.timeline.seconds": "%s s",
|
||||
"horizon.studio.timeline.scene_count": "%s escenes",
|
||||
"horizon.studio.timeline.scene_title": "Escena %s",
|
||||
"horizon.studio.timeline.sequence": "Seqüència",
|
||||
"horizon.studio.topbar.title": "✦ Studio",
|
||||
"horizon.studio.topbar.file": "Fitxer",
|
||||
"horizon.studio.topbar.camera": "Càmera",
|
||||
"horizon.studio.topbar.view": "Vista",
|
||||
"horizon.studio.topbar.file_short": "Fitxer",
|
||||
"horizon.studio.topbar.camera_short": "Càm",
|
||||
"horizon.studio.topbar.view_short": "Vista",
|
||||
"horizon.studio.topbar.game_pause_menu": "⏸ Menú de pausa",
|
||||
"horizon.studio.topbar.pause_short": "Pausa",
|
||||
"horizon.studio.topbar.open_recordings": "📁 Obre la carpeta d'enregistraments",
|
||||
"horizon.studio.topbar.close_replay": "✕ Tanca la reproducció",
|
||||
"horizon.studio.topbar.exit": "Surt",
|
||||
"horizon.studio.topbar.toggle_freecam": "Activa o desactiva la càmera lliure",
|
||||
"horizon.studio.topbar.reset_to_player": "👤 Torna al jugador",
|
||||
"horizon.studio.topbar.unlock_freecam": "🔓 Desbloqueja la càmera lliure",
|
||||
"horizon.studio.topbar.lock_to_spline": "🔒 Bloqueja a la spline",
|
||||
"horizon.studio.topbar.add_keyframe": "➕ Afegeix un fotograma clau",
|
||||
"horizon.studio.topbar.add_marker": "Afegeix un marcador",
|
||||
"horizon.studio.topbar.clear_keyframes": "🗑 Esborra els fotogrames clau",
|
||||
"horizon.studio.topbar.timeline_hud": "⏱ Línia de temps",
|
||||
"horizon.studio.topbar.split_scene": "Divideix l'escena"
|
||||
}
|
||||
Loaded 100 of 606 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user