mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
479
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
155ded0638 | ||
|
|
3d450f961e | ||
|
|
83b24baab0 | ||
|
|
29bb9c4cb9 | ||
|
|
d9355a5336 | ||
|
|
3596448656 | ||
|
|
8705ffae37 | ||
|
|
7ccd75a2fb | ||
|
|
8428ff0f59 | ||
|
|
b9449b2348 | ||
|
|
5f80ddffce | ||
|
|
af3aa00377 | ||
|
|
c2c364f195 | ||
|
|
c6af857433 | ||
|
|
8f91683323 | ||
|
|
471d815aeb | ||
|
|
5c2bc0af4a | ||
|
|
11f22e26fa | ||
|
|
e6bb2f74c7 | ||
|
|
08c67f3d2f | ||
|
|
db8c6d6331 | ||
|
|
791d8b8950 | ||
|
|
fe3333f3f0 | ||
|
|
b56048d996 | ||
|
|
ab0f591673 | ||
|
|
142388e1c1 | ||
|
|
93b7ddd09e | ||
|
|
6a221890bd | ||
|
|
c9a103852c | ||
|
|
a15faef940 | ||
|
|
58cd73ee0b | ||
|
|
e2a3e92424 | ||
|
|
6f8c7a0e5d | ||
|
|
04cbda1b14 | ||
|
|
2de8bbd84b | ||
|
|
a7177cfbd7 | ||
|
|
b558a845a0 | ||
|
|
9f1a998403 | ||
|
|
527cd7225c | ||
|
|
f64fdeaa75 | ||
|
|
a0c79c8b26 | ||
|
|
0284d3bb16 | ||
|
|
72b4d8ecbb | ||
|
|
1ed31e54eb | ||
|
|
b3aaa04129 | ||
|
|
bb38695695 | ||
|
|
6bda112225 | ||
|
|
77fa9fb89f | ||
|
|
153d18a102 | ||
|
|
95ecc9d771 | ||
|
|
e470f3a0c9 | ||
|
|
f93c28a230 | ||
|
|
b9721d816d | ||
|
|
2034db1df6 | ||
|
|
6edc4e950d | ||
|
|
d4120a9ef0 | ||
|
|
795a0db7e4 | ||
|
|
75b8a176d4 | ||
|
|
21a0656530 | ||
|
|
071580e716 | ||
|
|
ae01d872fb | ||
|
|
d2f5a0d678 | ||
|
|
d6ee462614 | ||
|
|
430e5e4c92 | ||
|
|
f3d8535fcd | ||
|
|
b276d65b5f | ||
|
|
559ecb02f6 | ||
|
|
11b8757bc7 | ||
|
|
b44da42ca4 | ||
|
|
9669600c38 | ||
|
|
528c322d16 | ||
|
|
a9f8e64331 | ||
|
|
01d0f45c2d | ||
|
|
228af35f2a | ||
|
|
abd1755f95 | ||
|
|
81f339814b | ||
|
|
0590a85d16 | ||
|
|
be6beb6ec7 | ||
|
|
9cdfc5bd44 | ||
|
|
26af55000a | ||
|
|
3782473f52 | ||
|
|
9cbc96ee7b | ||
|
|
af0fef9196 | ||
|
|
5a2d71d2ea | ||
|
|
bbec03c14f | ||
|
|
3c9bb38e88 | ||
|
|
dc943f95e8 | ||
|
|
312218a47f | ||
|
|
83b5c5ed53 | ||
|
|
578cd262a5 | ||
|
|
868256f1a8 | ||
|
|
1149c92cb8 | ||
|
|
360272fd79 | ||
|
|
1d6d4cf0a0 | ||
|
|
a1bdb0d22d | ||
|
|
930252e2e4 | ||
|
|
7dba3b4d73 | ||
|
|
e0bf5f6599 | ||
|
|
c7504433c0 | ||
|
|
41266ae70b | ||
|
|
a3acb5f0d2 | ||
|
|
49b77529dd | ||
|
|
dafa794cb3 | ||
|
|
b7df7bd9e0 | ||
|
|
40e2fe0608 | ||
|
|
a1e56a4d1b | ||
|
|
ab32a64d08 | ||
|
|
f843f28a27 | ||
|
|
abcc2e7c80 | ||
|
|
ac78481794 | ||
|
|
650601848f | ||
|
|
f9ea299952 | ||
|
|
52dc27fa40 | ||
|
|
b6eb028bb6 | ||
|
|
9dffa8a2a9 | ||
|
|
cd89bc6dc1 | ||
|
|
8a863ad378 | ||
|
|
a27561da88 | ||
|
|
ed7213df2e | ||
|
|
b1b27240d4 | ||
|
|
8a2a31e6a3 | ||
|
|
0264b39aa6 | ||
|
|
83935015cf | ||
|
|
506a9c8f01 | ||
|
|
475b22ff19 | ||
|
|
0d1f56e015 | ||
|
|
958c02593c | ||
|
|
a266eee6d2 | ||
|
|
62898b4cf6 | ||
|
|
d08e05283a | ||
|
|
e7a07f88b3 | ||
|
|
4e59dab892 | ||
|
|
a1f4841526 | ||
|
|
4e804cd813 | ||
|
|
7dd4b772c9 | ||
|
|
03dd78d67b | ||
|
|
6267cd3e15 | ||
|
|
265640ac18 | ||
|
|
f5a3088f0d | ||
|
|
3ccbba76df | ||
|
|
c519ff6c56 | ||
|
|
400d15ab3f | ||
|
|
d6971605d4 | ||
|
|
7dc818eb5b | ||
|
|
2772738d47 | ||
|
|
131625c414 | ||
|
|
478ae35b53 | ||
|
|
5f868011ee | ||
|
|
67712eeeef | ||
|
|
5b7d19f02b | ||
|
|
e2f3d1a85a | ||
|
|
74c2d13d77 | ||
|
|
9ff0d98010 | ||
|
|
f66f66c3b4 | ||
|
|
42be3018dd | ||
|
|
7cbb8c4506 | ||
|
|
6a05885ee3 | ||
|
|
c979ce46e9 | ||
|
|
0c5c38ac9e | ||
|
|
ae3b9e273f | ||
|
|
d0b6e48921 | ||
|
|
07e4a86658 | ||
|
|
3c98062662 | ||
|
|
e1b2b0b2fd | ||
|
|
9755416a02 | ||
|
|
0281365907 | ||
|
|
a5fcb49900 | ||
|
|
6f6229c2d4 | ||
|
|
0ca3a5be98 | ||
|
|
8993772de0 | ||
|
|
6a261a00a5 | ||
|
|
f8b83eea51 | ||
|
|
88398e8bda | ||
|
|
6d3489d30b | ||
|
|
7a84c93843 | ||
|
|
cba52f4e5d | ||
|
|
4d477067c3 | ||
|
|
60773a0e92 | ||
|
|
9f1fa0a0a2 | ||
|
|
0291a5864f | ||
|
|
fe84cc07a1 | ||
|
|
d4e42cf726 | ||
|
|
998c94bee3 | ||
|
|
466512c90e | ||
|
|
4db071e281 | ||
|
|
2649c5e506 | ||
|
|
505de825db | ||
|
|
1f6511d0f3 | ||
|
|
1523c118d8 | ||
|
|
9239089b76 | ||
|
|
281ab9759c | ||
|
|
febad05c78 | ||
|
|
519edfac19 | ||
|
|
6561df77f6 | ||
|
|
cd7f177104 | ||
|
|
07ab7ccfc1 | ||
|
|
727f6c932a | ||
|
|
cd1f58ef72 | ||
|
|
291aae00e8 | ||
|
|
bec8cce82d | ||
|
|
0ee2425d40 | ||
|
|
7f3bd901bc | ||
|
|
d308df6167 | ||
|
|
5ded675a7d | ||
|
|
d0a29a0f66 | ||
|
|
564162a0b0 | ||
|
|
8b5a27ed3f | ||
|
|
c0505424bd | ||
|
|
137045e321 | ||
|
|
4a18a4bde4 | ||
|
|
f6b467eba9 | ||
|
|
23d2146066 | ||
|
|
5f8afb17fc | ||
|
|
f7bb1ceb79 | ||
|
|
b9494af976 | ||
|
|
d1ead6a5fa | ||
|
|
3511d556ef | ||
|
|
4ff7118db7 | ||
|
|
da9950c75b | ||
|
|
626068da3a | ||
|
|
cd2fea1f02 | ||
|
|
4bf633e8a9 | ||
|
|
f4d6f9c0ed | ||
|
|
93e61ef936 | ||
|
|
28e3286e40 | ||
|
|
aef4186de4 | ||
|
|
e8cb6ae83b | ||
|
|
b6c936f82b | ||
|
|
99008baad9 | ||
|
|
e1bc2cbea3 | ||
|
|
6960b65c62 | ||
|
|
c66554126d | ||
|
|
4a1c5fc54c | ||
|
|
5b13af2f75 | ||
|
|
7b93b310a0 | ||
|
|
a1b32304a3 | ||
|
|
7934a0b180 | ||
|
|
992c63eefb | ||
|
|
a967ca4340 | ||
|
|
0112a6feac | ||
|
|
c0b560527f | ||
|
|
557a827383 | ||
|
|
e06134f71a | ||
|
|
330314327a | ||
|
|
9d32bebad3 | ||
|
|
012a3cf908 | ||
|
|
0e03cac368 | ||
|
|
e24394ca3a | ||
|
|
a5ee1e588c | ||
|
|
a48fcf0d16 | ||
|
|
2eb3de2748 | ||
|
|
67511d9749 | ||
|
|
df90f62d35 | ||
|
|
56f968e11c | ||
|
|
ae4cea18ef | ||
|
|
296802a618 | ||
|
|
a5d3ad165a | ||
|
|
2120ffedb4 | ||
|
|
04e6e21813 | ||
|
|
acee253e1c | ||
|
|
555dce8bb3 | ||
|
|
067163ded8 | ||
|
|
704cf97780 | ||
|
|
007d38fe49 | ||
|
|
43e2ad34c7 | ||
|
|
a6dbc6651f | ||
|
|
4bb3de29e7 | ||
|
|
786f67f77b | ||
|
|
797852c62b | ||
|
|
1f8f1055d5 | ||
|
|
0121466ed2 | ||
|
|
55b0545c11 | ||
|
|
5e14e309bc | ||
|
|
9e236c7914 | ||
|
|
2d7fc7f000 | ||
|
|
644a399389 | ||
|
|
9e31ce2f3d | ||
|
|
66d03b4d1c | ||
|
|
84d5574887 | ||
|
|
4118e026c3 | ||
|
|
51b374121b | ||
|
|
9042746704 | ||
|
|
752fc2170a | ||
|
|
356b69aea7 | ||
|
|
9fe3ddfec3 | ||
|
|
efa8df83b3 | ||
|
|
58422bcc19 | ||
|
|
fdefcfeac0 | ||
|
|
5c14d18141 | ||
|
|
7a62c6f4a7 | ||
|
|
0f8e3a9b09 | ||
|
|
4ce1eb6b0a | ||
|
|
47d249e7dd | ||
|
|
ea6ebae264 | ||
|
|
d878f3bed2 | ||
|
|
ec5fc9d5e4 | ||
|
|
99948fdf3f | ||
|
|
73ad5f1dbb | ||
|
|
6e26990ad3 | ||
|
|
ffb6594600 | ||
|
|
e0bb0881e8 | ||
|
|
4b4950d7c4 | ||
|
|
1d693cb3c3 | ||
|
|
69bb386a6d | ||
|
|
23e3cf465c | ||
|
|
0d3ca74bd0 | ||
|
|
f69088012b | ||
|
|
154aaa2051 | ||
|
|
1ab273641e | ||
|
|
785d579f47 | ||
|
|
3fe8aa76b2 | ||
|
|
dc75e77eb1 | ||
|
|
94e8c85fd0 | ||
|
|
28c30923f4 | ||
|
|
2b74cfae4a | ||
|
|
60a4edaef9 | ||
|
|
5096b41100 | ||
|
|
e9eaa66542 | ||
|
|
2c2aa41619 | ||
|
|
f164427cba | ||
|
|
9fcfdaa1ea | ||
|
|
b77301b6eb | ||
|
|
3be828d012 | ||
|
|
e08adcd31c | ||
|
|
830a9d3d84 | ||
|
|
6f0d56512d | ||
|
|
482444afc4 | ||
|
|
46c84a8b24 | ||
|
|
ae82c1b693 | ||
|
|
501e543105 | ||
|
|
0d7f842b09 | ||
|
|
a1dfcbf2b3 | ||
|
|
5703fdfdc4 | ||
|
|
57494e6871 | ||
|
|
ad8ef0b7e8 | ||
|
|
f9fca5688d | ||
|
|
4156a6ac93 | ||
|
|
6c3defa821 | ||
|
|
8057d27511 | ||
|
|
466fef4486 | ||
|
|
4e814a152a | ||
|
|
3f378dda96 | ||
|
|
70068076f2 | ||
|
|
59b67af6a8 | ||
|
|
e34f01715c | ||
|
|
1b469a542f | ||
|
|
567e92e2a2 | ||
|
|
5388064d88 | ||
|
|
20ffd8e538 | ||
|
|
d321b656f6 | ||
|
|
c3e19973cf | ||
|
|
7aaa4b433b | ||
|
|
3cf601f760 | ||
|
|
39327fca33 | ||
|
|
6489cdf829 | ||
|
|
1727f2acf1 | ||
|
|
079963f27e | ||
|
|
3c72bf2d79 | ||
|
|
16cda1637b | ||
|
|
3dc958611c | ||
|
|
513aed64c5 | ||
|
|
33c15a6d79 | ||
|
|
1db835fa0c | ||
|
|
ad61c30f46 | ||
|
|
f35ee16412 | ||
|
|
5216403562 | ||
|
|
7ab2b20bd5 | ||
|
|
2aea6138b7 | ||
|
|
dddcc99726 | ||
|
|
f6209003de | ||
|
|
736842d40f | ||
|
|
ba37fa75d6 | ||
|
|
e9fbcfef78 | ||
|
|
206b7acee4 | ||
|
|
1deb3bd36c | ||
|
|
5b41d9ebf3 | ||
|
|
2ad7b68710 | ||
|
|
52f3dcc5ef | ||
|
|
203fb2db89 | ||
|
|
7d99d23aef | ||
|
|
2f7edd1524 | ||
|
|
b8f0cde900 | ||
|
|
c4348be8d2 | ||
|
|
8fdcb37ddd | ||
|
|
7f6e8b634e | ||
|
|
db27568929 | ||
|
|
9c9ce0b435 | ||
|
|
af41d0325c | ||
|
|
e21957efd7 | ||
|
|
6790913e0b | ||
|
|
c309d331ad | ||
|
|
7acfc655e8 | ||
|
|
d485c688d6 | ||
|
|
deea1a5b83 | ||
|
|
47ca0312df | ||
|
|
78c028321c | ||
|
|
127434c46c | ||
|
|
32a0a11fed | ||
|
|
7b40465f85 | ||
|
|
f6807fe7cd | ||
|
|
a34a41e1b5 | ||
|
|
22cab3eeb3 | ||
|
|
9b6faeb8cf | ||
|
|
e36cd17dd1 | ||
|
|
e6c1a91a65 | ||
|
|
8cfc13fe2c | ||
|
|
a7e3bcf79f | ||
|
|
62bb83857c | ||
|
|
de286f236f | ||
|
|
e7f5201c1f | ||
|
|
91d401aa8a | ||
|
|
a7d0586d95 | ||
|
|
8e36842891 | ||
|
|
7b841af095 | ||
|
|
033c89a1b2 | ||
|
|
0580626784 | ||
|
|
4f13db42b0 | ||
|
|
f507a067cd | ||
|
|
28f906c37d | ||
|
|
f8f2f904bc | ||
|
|
c3fb1656e6 | ||
|
|
b98633b677 | ||
|
|
3c7942d2bd | ||
|
|
65bca4ce98 | ||
|
|
574f3587ed | ||
|
|
de89e758cc | ||
|
|
ee6b5bf585 | ||
|
|
5f5a43fd54 | ||
|
|
c0bf77f3c3 | ||
|
|
2812cf201d | ||
|
|
ce848af067 | ||
|
|
d3be8dbf05 | ||
|
|
9bbc54bd4c | ||
|
|
52c667f423 | ||
|
|
86474679db | ||
|
|
dcb7476d12 | ||
|
|
152334eee3 | ||
|
|
4fdc93257f | ||
|
|
67e0d54062 | ||
|
|
53cb3a19d1 | ||
|
|
84390f6dc1 | ||
|
|
9d191c28f2 | ||
|
|
5d42108a98 | ||
|
|
1972ef3bef | ||
|
|
e8145b07fa | ||
|
|
5e7295a631 | ||
|
|
761a770174 | ||
|
|
7f0a61954b | ||
|
|
a8903cba07 | ||
|
|
045cabb7f2 | ||
|
|
560aa61e91 | ||
|
|
49ce3e6d5a | ||
|
|
00df6c646f | ||
|
|
1f040d06eb | ||
|
|
d13b478ca2 | ||
|
|
3e0f4cf653 | ||
|
|
b38fe43b09 | ||
|
|
91de744250 | ||
|
|
1654cb51db | ||
|
|
35581f52e2 | ||
|
|
f105583803 | ||
|
|
c216561daa | ||
|
|
618d20bd08 | ||
|
|
985ece811b | ||
|
|
c734c72fc9 | ||
|
|
fa940d5b6b | ||
|
|
bcdbc252fe | ||
|
|
186198afe6 | ||
|
|
a77639022c | ||
|
|
fcc7a818b4 | ||
|
|
a3e581b56f | ||
|
|
4c4fcedf40 | ||
|
|
de8dd66ac2 | ||
|
|
6fc2006447 | ||
|
|
7e68f1ed32 | ||
|
|
9ebccd6a59 | ||
|
|
0f565f4e4c | ||
|
|
44850bdd55 | ||
|
|
33e81f464e |
No files matched your search
+10
-1
@@ -1,2 +1,11 @@
|
||||
#!/bin/sh
|
||||
exec python3 tools/check_public_tree.py
|
||||
# Git for Windows puts a `python3` on PATH that is only the Microsoft Store
|
||||
# stub: it prints "Python" and exits non-zero, so the hook fails with no other
|
||||
# output. Use the first name that actually runs Python 3.
|
||||
for py in python3 python py; do
|
||||
if "$py" -c "import sys; sys.exit(sys.version_info < (3,))" >/dev/null 2>&1; then
|
||||
exec "$py" tools/check_public_tree.py
|
||||
fi
|
||||
done
|
||||
echo "check_public_tree: no working Python 3 found" >&2
|
||||
exit 1
|
||||
+10
-1
@@ -1,2 +1,11 @@
|
||||
#!/bin/sh
|
||||
exec python3 tools/check_public_tree.py --pre-push
|
||||
# Git for Windows puts a `python3` on PATH that is only the Microsoft Store
|
||||
# stub: it prints "Python" and exits non-zero, so the hook fails with no other
|
||||
# output. Use the first name that actually runs Python 3.
|
||||
for py in python3 python py; do
|
||||
if "$py" -c "import sys; sys.exit(sys.version_info < (3,))" >/dev/null 2>&1; then
|
||||
exec "$py" tools/check_public_tree.py --pre-push
|
||||
fi
|
||||
done
|
||||
echo "check_public_tree: no working Python 3 found" >&2
|
||||
exit 1
|
||||
@@ -6,3 +6,4 @@
|
||||
kacpherek # added by Ludovic, 2026-10-27
|
||||
rdbtCVS # added by Ludovic, 2026-10-27
|
||||
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
|
||||
levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
|
||||
@@ -192,9 +192,11 @@ jobs:
|
||||
retry "$launcher" install latest
|
||||
"$launcher" versions --installed
|
||||
"$launcher" links --json
|
||||
clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*)
|
||||
# `install latest` is the Nightly since #3342 (versions/nightly-<date>-<commit>), a vX.Y.Z before.
|
||||
clients=("$HORIZON_LAUNCHER_HOME"/versions/*/horizon-client*)
|
||||
client="${clients[0]}"
|
||||
echo "installed client: $client"
|
||||
[[ -f "$client" ]] || { echo "::error::no game installed under versions/"; exit 1; }
|
||||
# The game's Linux build is the game's to keep portable (release.yml checks it): a
|
||||
# release linked to a Nix store is a warning here, not a broken launcher.
|
||||
soft() { if [[ "$RUNNER_OS" == "Linux" ]]; then "$@" || echo "::warning::$* failed"; else "$@"; fi; }
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
name: nightly build
|
||||
run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.request) || '' }}
|
||||
# The opt-in Nightly channel (docs/release-flow.md "Nightly"; Ludovic, 2026-10-27). Once a night, and when a trusted
|
||||
# The Nightly, the only build Horizon ships since 2026-10-28 (docs/release-flow.md "Nightly"; Ludovic). When the batch
|
||||
# train decides (release.yml, with the commit as `base`), once a night, and when a trusted
|
||||
# reviewer asks (`/nightly 2640 2642`, nightly-command.yml), it builds main plus the pull requests the reviewers
|
||||
# picked, merged in order (tools/ci/nightly_set.py). With none picked, it builds the newest union branch on origin
|
||||
# (union/<agent>-<name>: main plus pull requests the contribution gate proved together), or main when no union is
|
||||
# newer. It publishes the result as the `nightly` pre-release. Launchers install it only for players who turn Nightly
|
||||
# on. The previous nightly stays a week as nightly-<date>.
|
||||
# picked, merged in order (tools/ci/nightly_set.py), or main alone with none picked. Main is its newest commit where
|
||||
# checks and parity-main passed (or the `base` input), as for a release, and no Nightly is published while a
|
||||
# release-blocker is open. It publishes the result as the `nightly` pre-release, which every launcher and
|
||||
# `horizon-server update` install. The previous nightly stays a week as nightly-<date>.
|
||||
#
|
||||
# Why the signing key never meets the pull requests' code:
|
||||
# - `pick` merges the picked heads onto main with `git merge-tree --write-tree`: git objects only, no working tree,
|
||||
@@ -13,11 +14,11 @@ run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.req
|
||||
# request that changes a path CODEOWNERS gives to Ludovic (this folder, tools/ci/, the signing and update code),
|
||||
# pushes the merge as nightly/build-<date>-<n> and saves the set on the nightly-state branch.
|
||||
# - This file, game-build.yml and every script below run from main's copy: a schedule runs the default branch, and
|
||||
# `uses: ./…` takes the caller's commit. The union's own .github/ is never read.
|
||||
# `uses: ./…` takes the caller's commit. A picked pull request's own .github/ is never read.
|
||||
# - `build` (game-build.yml, `trusted: false`) never reaches a release's tools: its macOS jobs run on the runner
|
||||
# that already runs pull requests' parity jobs, never the release runner; its Rust cache has its own key and is
|
||||
# never saved; and releases don't read the machine's shared sccache.
|
||||
# - `build` (game-build.yml) checks out and compiles the merge (or the union) with no secrets: its token reads contents only and
|
||||
# - `build` (game-build.yml) checks out and compiles the merge with no secrets: its token reads contents only and
|
||||
# isn't kept in the checkout; the macOS programs are signed ad hoc (Developer ID needs the runner's keychain,
|
||||
# which stays locked for nightlies).
|
||||
# - `sign` is the only job with RELEASE_SIGNING_KEY. It checks out nothing but main's tools/ci/sign_release.sh, which
|
||||
@@ -25,6 +26,9 @@ run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.req
|
||||
# names the channel ("horizon release nightly"), so a nightly's signature can't pass for a release's.
|
||||
# - `publish` verifies the signature with main's horizon-release-sign, checks the smoke test, and only then moves
|
||||
# the tag. Like sign, it never executes what build produced.
|
||||
# - The source ships with the build: `pick` writes horizon-source.tar.gz with `git archive` (git objects again, nothing
|
||||
# run), `sign` signs it with the builds, and `publish` attaches it and tags the commit nightly-source/<date>-<short>
|
||||
# for good (the dated release and the build branch go after 7 days; native mods pin the commit: docs/native-mods.md).
|
||||
# gh workflow run "nightly build" # now (skips when the pick is the same commit as last night's)
|
||||
# gh workflow run "nightly build" -f force=true # build and publish even if unchanged
|
||||
# gh workflow run "nightly build" -f dry_run=true # build and sign with a throwaway key, publish nothing
|
||||
@@ -53,6 +57,14 @@ on:
|
||||
description: A dry run's own set, `2640@<head> 2642@<head>` (the saved set stays)
|
||||
type: string
|
||||
default: ''
|
||||
base:
|
||||
description: The commit of main to build on (default main's newest with green checks and parity-main)
|
||||
type: string
|
||||
default: ''
|
||||
urgent:
|
||||
description: Past the day's cap of 12 builds (the train, for a release-now pull request or a forced run)
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
@@ -68,6 +80,7 @@ jobs:
|
||||
contents: write # nightly/build-<date>-<n> and the nightly-state branch (main is protected)
|
||||
issues: write # says in the thread when the picked pull requests conflict
|
||||
pull-requests: write
|
||||
actions: read # main's newest green checks and parity-main runs (the base)
|
||||
outputs:
|
||||
build: ${{ steps.pick.outputs.build }}
|
||||
sha: ${{ steps.pick.outputs.sha }}
|
||||
@@ -76,6 +89,7 @@ jobs:
|
||||
date: ${{ steps.pick.outputs.date }}
|
||||
version: ${{ steps.pick.outputs.version }}
|
||||
nightly: ${{ steps.pick.outputs.nightly }}
|
||||
source_tag: ${{ steps.pick.outputs.source_tag }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -92,6 +106,8 @@ jobs:
|
||||
DRY_RUN: ${{ steps.mode.outputs.dry_run }}
|
||||
NIGHTLY_ISSUE: ${{ inputs.issue }}
|
||||
NIGHTLY_PRS: ${{ inputs.prs }}
|
||||
NIGHTLY_BASE: ${{ inputs.base }}
|
||||
NIGHTLY_URGENT: ${{ inputs.urgent }}
|
||||
run: |
|
||||
python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
{ echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -103,6 +119,18 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
{ echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
- name: The source of the build (horizon-source.tar.gz)
|
||||
if: steps.pick.outputs.build == 'true'
|
||||
env:
|
||||
SHA: ${{ steps.pick.outputs.sha }}
|
||||
run: git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: steps.pick.outputs.build == 'true'
|
||||
with:
|
||||
name: horizon-source
|
||||
path: horizon-source.tar.gz
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nightly-notes
|
||||
@@ -144,7 +172,7 @@ jobs:
|
||||
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
|
||||
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
@@ -164,6 +192,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
issues: read # the open release-blocker issues (gate)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -178,27 +207,39 @@ jobs:
|
||||
run: |
|
||||
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
|
||||
cargo run --locked --quiet -p horizon-release-sign -- verify --tag nightly --publishing "${key[@]}" dist
|
||||
- name: The smoke test drew a world
|
||||
# No release-blocker check: a nightly is how blockers get tested.
|
||||
run: python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json
|
||||
- name: Ready to publish (the smoke test drew a world, no release-blocker is open)
|
||||
# Right before publishing, as for a release: an issue labelled while the builds ran still holds it. A dry run
|
||||
# only reports what a real run would do (that is how a blocker's fix is tried).
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DRY_RUN: ${{ needs.pick.outputs.dry_run }}
|
||||
run: |
|
||||
if ! python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json --blockers; then
|
||||
[[ "$DRY_RUN" == true ]] || { echo "::error::Nothing published"; exit 1; }
|
||||
echo "::warning::A real run would publish nothing"
|
||||
fi
|
||||
- name: Publish as the `nightly` pre-release
|
||||
if: needs.pick.outputs.dry_run != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SHA: ${{ needs.pick.outputs.sha }}
|
||||
DATE: ${{ needs.pick.outputs.date }}
|
||||
SOURCE_TAG: ${{ needs.pick.outputs.source_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 tools/ci/nightly.py rotate
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
# A pre-release, never GitHub's latest: launchers on the release channel never see it.
|
||||
# A pre-release, never GitHub's latest: the latest stays the last vX.Y.Z, which launchers and servers from
|
||||
# before the Nightly-only switch keep (they don't read Nightlies).
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" \
|
||||
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
python3 tools/ci/nightly.py keep-source "$SHA" "$DATE"
|
||||
python3 tools/ci/nightly.py prune --days 7
|
||||
- name: Dry run, the nightly that would be published
|
||||
if: needs.pick.outputs.dry_run == 'true'
|
||||
@@ -230,7 +271,7 @@ jobs:
|
||||
elif [[ "$PUBLISHED" == success && "$DRY_RUN" == true ]]; then
|
||||
text="the dry run built and signed $NAME; nothing was published. $RUN"
|
||||
elif [[ "$PUBLISHED" == success ]]; then
|
||||
text="published $NAME: https://github.com/$GITHUB_REPOSITORY/releases/tag/nightly. Launchers with Nightly on install it at their next start."
|
||||
text="published $NAME: https://github.com/$GITHUB_REPOSITORY/releases/tag/nightly. Launchers install it at their next check."
|
||||
else
|
||||
text="the build failed, nothing was published: $RUN"
|
||||
fi
|
||||
|
||||
+27
-139
@@ -1,22 +1,15 @@
|
||||
name: release
|
||||
# Automatic game releases (docs/release-flow.md). After every green `checks` run on main, every 10 minutes (so a
|
||||
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides,
|
||||
# for a commit whose checks and parity-main (the vanilla harnesses on main) both passed:
|
||||
# a release when 5 pull requests merged since the last one changed what players run and the newest merged 5 minutes
|
||||
# ago, or when the oldest has waited 6 hours, or at once for a pull request labelled release-now.
|
||||
# RELEASES_PAUSED=1 (repository variable) stops them; so does an open issue labelled release-blocker, forced runs too.
|
||||
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
|
||||
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then
|
||||
# publishes vX.Y.Z on the release line in .github/release-line.txt (docs/versioning.md; without it, the dated
|
||||
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as the release and GitHub's latest: one
|
||||
# channel, which launchers, `horizon-server update` and install.sh follow at once. So it is published only after the
|
||||
# checks that used to gate a promotion pass in this run: the Mac mini's smoke test drew a world, and no release-blocker
|
||||
# is open (tools/release_train.py gate); otherwise nothing is published. macOS: with the repository variable
|
||||
# MACOS_SIGN_IDENTITY set, the programs are signed with
|
||||
# Developer ID and the hardened runtime and the zip is notarized, from the runner's signing keychain
|
||||
# (packaging/macos/README.md); unset (forks), ad hoc.
|
||||
# gh workflow run release --ref <branch> # dry run: builds and notes, publishes nothing
|
||||
# gh workflow run release -f dry_run=false -f force=true # on main: release now, whatever the rules say
|
||||
# Automatic game releases (docs/release-flow.md). Horizon ships only the Nightly since 2026-10-28 (Ludovic): this
|
||||
# workflow is the batch train that decides when a new Nightly goes out, and `nightly build` (nightly-build.yml) builds,
|
||||
# signs and publishes it. After every green `checks` run on main, every 10 minutes (so a batch waiting for merges to
|
||||
# settle goes out without another merge) and by hand, tools/release_train.py decides, for a commit whose checks and
|
||||
# parity-main (the vanilla harnesses on main) both passed: a Nightly when 5 pull requests merged since the published
|
||||
# Nightly changed what players run and the newest merged 5 minutes ago, or when the oldest has waited 6 hours, or at
|
||||
# once for a pull request labelled release-now. RELEASES_PAUSED=1 (repository variable) stops it; so does an open issue
|
||||
# labelled release-blocker, forced runs too (nightly-build.yml checks again before publishing). One Nightly build at a
|
||||
# time, at most 12 a day (release-now and force=true pass the cap). No `vX.Y.Z` game release is made any more.
|
||||
# gh workflow run release --ref <branch> # dry run: decides, starts nothing
|
||||
# gh workflow run release -f dry_run=false -f force=true # on main: a Nightly now, whatever the rules say
|
||||
on:
|
||||
workflow_run:
|
||||
# A game release ships a commit both passed on (parity-main runs on batches: the newest commit it passed on).
|
||||
@@ -28,17 +21,19 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Build everything and write the notes, but publish nothing
|
||||
description: Decide only; start no Nightly build
|
||||
type: boolean
|
||||
default: true
|
||||
force:
|
||||
description: Release even without player-facing changes, before merges settle or while paused
|
||||
description: A Nightly even without player-facing changes, before merges settle, while paused or past the day's cap
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
# The open release-blocker issues (decide).
|
||||
issues: read
|
||||
# The checks, parity-main and nightly build runs (decide).
|
||||
actions: read
|
||||
concurrency:
|
||||
# One release at a time. A hand-started dry run publishes nothing, so it can't race a real release: it gets a
|
||||
# group of its own. In the shared group, a release queued behind it replaced it as the pending run and GitHub
|
||||
@@ -54,9 +49,8 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
release: ${{ steps.decide.outputs.release }}
|
||||
tag: ${{ steps.decide.outputs.tag }}
|
||||
version: ${{ steps.decide.outputs.version }}
|
||||
head: ${{ steps.decide.outputs.head }}
|
||||
urgent: ${{ steps.decide.outputs.urgent }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -76,127 +70,21 @@ jobs:
|
||||
run: |
|
||||
python3 tools/release_train.py decide --kind game ${HEAD_SHA:+--head "$HEAD_SHA"} ${FORCE:+--force} | tee decision.json
|
||||
echo "### $(jq -r '.reason' decision.json)" >> "$GITHUB_STEP_SUMMARY"
|
||||
# The version the programs are built with (docs/versioning.md): the tag without its v.
|
||||
tag=$(jq -r '.tag' decision.json)
|
||||
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
|
||||
# A dry run builds whatever the decision, to test the builds.
|
||||
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
|
||||
|
||||
build:
|
||||
nightly:
|
||||
# The Nightly is the release: nightly-build.yml builds the decided commit plus the reviewers' picks, signs it for
|
||||
# the Nightly channel and publishes it once its smoke test passed and no release-blocker is open.
|
||||
needs: decide
|
||||
if: needs.decide.outputs.release == 'true'
|
||||
# Every platform's archive, and macOS's smoke test (game-build.yml).
|
||||
uses: ./.github/workflows/game-build.yml
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head }}
|
||||
version: ${{ needs.decide.outputs.version }}
|
||||
trusted: true # main: the release runner, the release cache, no shared sccache
|
||||
|
||||
sign:
|
||||
needs: [decide, build]
|
||||
if: needs.decide.outputs.release == 'true' && needs.decide.outputs.dry_run != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# The only job that holds the release signing key, a secret of the `release` environment, which only main's real
|
||||
# releases enter (dry runs sign with a throwaway key). It runs no code from the repository but
|
||||
# tools/ci/sign_release.sh, which calls only the runner's sha256sum and OpenSSL (docs/release-flow.md).
|
||||
environment: ${{ needs.decide.outputs.dry_run != 'true' && 'release' || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: tools/ci/sign_release.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
pattern: horizon-*
|
||||
merge-multiple: true
|
||||
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig)
|
||||
env:
|
||||
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
|
||||
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
|
||||
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
bash tools/ci/sign_release.sh "$TAG" dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
path: dist/SHA256SUMS*
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
with:
|
||||
name: ephemeral.pub
|
||||
path: dist/ephemeral.pub
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
needs: [decide, build, sign]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: write
|
||||
issues: read
|
||||
actions: write # starts `nightly build`
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Notes
|
||||
env:
|
||||
- env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HEAD_SHA: ${{ needs.decide.outputs.head }}
|
||||
URGENT: ${{ needs.decide.outputs.urgent }}
|
||||
run: |
|
||||
python3 tools/release_train.py notes --kind game --tag "${{ needs.decide.outputs.tag }}" \
|
||||
--head "${{ needs.decide.outputs.head }}" --out notes > /dev/null
|
||||
{ echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: The signature, checked as launchers check it
|
||||
env:
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
|
||||
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
|
||||
- name: Ready to publish (the smoke test passed, no release-blocker is open)
|
||||
# Right before publishing: an issue labelled while the builds ran still holds this release. A dry run only
|
||||
# reports what a real run would do.
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DRY_RUN: ${{ needs.decide.outputs.dry_run }}
|
||||
run: |
|
||||
if ! python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json --blockers; then
|
||||
[[ "$DRY_RUN" == true ]] || { echo "::error::Nothing published"; exit 1; }
|
||||
echo "::warning::A real run would publish nothing"
|
||||
fi
|
||||
- name: Publish ${{ needs.decide.outputs.tag }}
|
||||
if: needs.decide.outputs.dry_run != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
# The release and the latest at once: there is one channel (docs/release-flow.md). Each archive gets a
|
||||
# label (file#label), which GitHub's page shows instead of the file name, so nobody takes the game's
|
||||
# files for the download (#1647). Launchers, servers and install.sh match the name, which stays.
|
||||
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
|
||||
--notes-file notes/body.md --latest \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs: Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Game files the launcher installs: Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs: macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
- name: Dry run, the release that would be published
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
run: |
|
||||
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }} as the release and latest: $(cat notes/title.txt)"
|
||||
ls -l dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-notes
|
||||
path: notes/
|
||||
retention-days: 3
|
||||
gh workflow run nightly-build.yml --repo "$GITHUB_REPOSITORY" --ref main -f base="$HEAD_SHA" -f urgent="$URGENT"
|
||||
echo "### Started \`nightly build\` on ${HEAD_SHA:0:9}" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -86,6 +86,9 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
|
||||
release notes), `skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
|
||||
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
|
||||
|
||||
Who reviews and merges: the Horizon Team, on CI for ordinary pull requests; risky ones (saves, network, world
|
||||
generation, the launcher's updates) wait for a local proof first ([merging](docs/release-flow.md#merging)).
|
||||
|
||||
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).
|
||||
|
||||
|
||||
Generated
+42
-5
@@ -876,6 +876,12 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.19.0"
|
||||
@@ -1398,6 +1404,7 @@ dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"naga",
|
||||
"open",
|
||||
"png",
|
||||
"pollster",
|
||||
"rustix 1.1.5",
|
||||
@@ -1877,6 +1884,25 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -2691,6 +2717,17 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"is-wsl",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orbclient"
|
||||
version = "0.3.55"
|
||||
@@ -3206,9 +3243,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
version = "0.103.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -4386,14 +4423,14 @@ version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
"webpki-roots 1.0.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
version = "1.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
+4
-2
@@ -61,8 +61,10 @@ large_enum_variant = "allow"
|
||||
# wgpu-hal 30.0.1 with gfx-rs/wgpu#10124: wgpu 30 creates a Vulkan descriptor
|
||||
# pool with no pool sizes while it creates the device, which Vulkan allows only
|
||||
# since 1.3.215, and Intel's Windows driver for 6th-10th gen iGPUs (HD 520/530,
|
||||
# UHD 620/630) crashes on it with an access violation (#746). Drop this and the
|
||||
# folder once a wgpu release has the fix.
|
||||
# UHD 620/630) crashes on it with an access violation (#746). It also fixes the
|
||||
# Metal backend's lookup of the CoreGraphics HDR colour space names, which handed
|
||||
# CoreGraphics a symbol's address as the string and crashed HDR output on macOS.
|
||||
# Drop this and the folder once a wgpu release has both fixes.
|
||||
[patch.crates-io]
|
||||
wgpu-hal = { path = "third_party/wgpu-hal" }
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ludovic
|
||||
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -29,9 +29,9 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
|
||||
|
||||
## Download
|
||||
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
|
||||
|
||||
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)); Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)) and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -93,7 +93,7 @@ Commands block until the action is done and reply with what happened; errors are
|
||||
|
||||
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA and udev development headers (`libasound2-dev libudev-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
|
||||
|
||||
Horizon contains no game files and never downloads them. It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
|
||||
Horizon contains no game files, and `horizon-prepare` never downloads them (the launcher downloads them from Mojang only for a signed-in account that owns the game). It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
|
||||
|
||||
1. **Client:** install Java Edition with the official launcher and play 26.3 once. Horizon reads `versions/26.3/`, `libraries/` and `assets/` from the launcher's game directory (`~/.minecraft` on Linux). If yours lives elsewhere, pass `--minecraft-dir` or set `HORIZON_MINECRAFT_DIR`.
|
||||
2. **Server jar:** the 26.3 server jar, from the `downloads.server.url` in your launcher's `versions/26.3/26.3.json` (or [minecraft.net/download/server](https://www.minecraft.net/en-us/download/server) while 26.3 is the current release). Pass it with `--server-jar` or set `HORIZON_SERVER_JAR`. It is only needed the first time.
|
||||
|
||||
@@ -97,6 +97,9 @@ impl Report {
|
||||
for r in &f.requires {
|
||||
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
|
||||
}
|
||||
if self.kind == Kind::NativeMod {
|
||||
out += " native source: unsandboxed; not executed by the registry check. Compile and test against the exact pinned Horizon and Mixins revisions before review.\n";
|
||||
}
|
||||
if self.kind == Kind::Mod && !self.code {
|
||||
out += " no code: content only, nothing to run\n";
|
||||
} else if self.kind == Kind::Mod {
|
||||
@@ -118,6 +121,7 @@ impl Report {
|
||||
pub fn kind_of(path: &Path) -> Option<Kind> {
|
||||
match path.extension()?.to_str()? {
|
||||
"hmod" => Some(Kind::Mod),
|
||||
"hnmod" => Some(Kind::NativeMod),
|
||||
"hpack" => Some(Kind::Modpack),
|
||||
"zip" => Some(Kind::ResourcePack),
|
||||
"hshader" => Some(Kind::Shader),
|
||||
|
||||
@@ -195,6 +195,11 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
Some("copy_to_clipboard") => {
|
||||
text("value").map(crate::text::ClickEvent::CopyToClipboard)
|
||||
}
|
||||
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
|
||||
// the dialog itself; the server may send it (isAllowedFromServer).
|
||||
Some("show_dialog") => e.get("dialog").map(|dialog| {
|
||||
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ pub enum ClickEvent {
|
||||
/// `ClickEvent.RunCommand`: the command, with or without its `/`.
|
||||
RunCommand(String),
|
||||
CopyToClipboard(String),
|
||||
/// `ClickEvent.ShowDialog`: a `minecraft:dialog` registry id, or the
|
||||
/// dialog itself as the component carried it.
|
||||
ShowDialog(horizon_world::nbt::Tag),
|
||||
/// A local file to open with the system handler.
|
||||
/// Only allow this in locally constructed messages, never server/LAN text.
|
||||
OpenFile(String),
|
||||
@@ -557,6 +560,11 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
ClickEvent::RunCommand(command) => {
|
||||
json!({ "action": "run_command", "command": command })
|
||||
}
|
||||
// ClickEvent.ShowDialog.CODEC: the dialog the server sent it with.
|
||||
ClickEvent::ShowDialog(dialog) => json!({
|
||||
"action": "show_dialog",
|
||||
"dialog": crate::scoreboard::tag_to_json(dialog),
|
||||
}),
|
||||
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
|
||||
};
|
||||
out.push(("click_event".to_owned(), event));
|
||||
|
||||
@@ -145,6 +145,22 @@ const KEYS: &[(&str, KeyCode)] = &[
|
||||
("key.keyboard.period", KeyCode::Period),
|
||||
("key.keyboard.slash", KeyCode::Slash),
|
||||
("key.keyboard.caps.lock", KeyCode::CapsLock),
|
||||
("key.keyboard.world.1", KeyCode::IntlBackslash),
|
||||
("key.keyboard.international1", KeyCode::IntlRo),
|
||||
("key.keyboard.international3", KeyCode::IntlYen),
|
||||
("key.keyboard.lang1", KeyCode::Lang1),
|
||||
("key.keyboard.lang2", KeyCode::Lang2),
|
||||
("key.keyboard.lang3", KeyCode::Lang3),
|
||||
("key.keyboard.lang4", KeyCode::Lang4),
|
||||
("key.keyboard.lang5", KeyCode::Lang5),
|
||||
("key.keyboard.help", KeyCode::Help),
|
||||
("key.keyboard.select", KeyCode::Select),
|
||||
("key.keyboard.again", KeyCode::Again),
|
||||
("key.keyboard.undo", KeyCode::Undo),
|
||||
("key.keyboard.cut", KeyCode::Cut),
|
||||
("key.keyboard.copy", KeyCode::Copy),
|
||||
("key.keyboard.paste", KeyCode::Paste),
|
||||
("key.keyboard.find", KeyCode::Find),
|
||||
("key.keyboard.f1", KeyCode::F1),
|
||||
("key.keyboard.f2", KeyCode::F2),
|
||||
("key.keyboard.f3", KeyCode::F3),
|
||||
@@ -157,6 +173,18 @@ const KEYS: &[(&str, KeyCode)] = &[
|
||||
("key.keyboard.f10", KeyCode::F10),
|
||||
("key.keyboard.f11", KeyCode::F11),
|
||||
("key.keyboard.f12", KeyCode::F12),
|
||||
("key.keyboard.f13", KeyCode::F13),
|
||||
("key.keyboard.f14", KeyCode::F14),
|
||||
("key.keyboard.f15", KeyCode::F15),
|
||||
("key.keyboard.f16", KeyCode::F16),
|
||||
("key.keyboard.f17", KeyCode::F17),
|
||||
("key.keyboard.f18", KeyCode::F18),
|
||||
("key.keyboard.f19", KeyCode::F19),
|
||||
("key.keyboard.f20", KeyCode::F20),
|
||||
("key.keyboard.f21", KeyCode::F21),
|
||||
("key.keyboard.f22", KeyCode::F22),
|
||||
("key.keyboard.f23", KeyCode::F23),
|
||||
("key.keyboard.f24", KeyCode::F24),
|
||||
("key.keyboard.print.screen", KeyCode::PrintScreen),
|
||||
("key.keyboard.scroll.lock", KeyCode::ScrollLock),
|
||||
("key.keyboard.pause", KeyCode::Pause),
|
||||
@@ -188,6 +216,36 @@ const KEYS: &[(&str, KeyCode)] = &[
|
||||
("key.keyboard.keypad.0", KeyCode::Numpad0),
|
||||
("key.keyboard.keypad.period", KeyCode::NumpadDecimal),
|
||||
("key.keyboard.keypad.equal", KeyCode::NumpadEqual),
|
||||
("key.keyboard.keypad.comma", KeyCode::NumpadComma),
|
||||
("key.keyboard.keypad.backspace", KeyCode::NumpadBackspace),
|
||||
(
|
||||
"key.keyboard.keypad.left.parenthesis",
|
||||
KeyCode::NumpadParenLeft,
|
||||
),
|
||||
(
|
||||
"key.keyboard.keypad.right.parenthesis",
|
||||
KeyCode::NumpadParenRight,
|
||||
),
|
||||
("key.keyboard.keypad.hash", KeyCode::NumpadHash),
|
||||
(
|
||||
"key.keyboard.keypad.memory.store",
|
||||
KeyCode::NumpadMemoryStore,
|
||||
),
|
||||
(
|
||||
"key.keyboard.keypad.memory.recall",
|
||||
KeyCode::NumpadMemoryRecall,
|
||||
),
|
||||
(
|
||||
"key.keyboard.keypad.memory.clear",
|
||||
KeyCode::NumpadMemoryClear,
|
||||
),
|
||||
("key.keyboard.keypad.memory.add", KeyCode::NumpadMemoryAdd),
|
||||
(
|
||||
"key.keyboard.keypad.memory.subtract",
|
||||
KeyCode::NumpadMemorySubtract,
|
||||
),
|
||||
("key.keyboard.keypad.clear", KeyCode::NumpadClear),
|
||||
("key.keyboard.keypad.clear.entry", KeyCode::NumpadClearEntry),
|
||||
("key.keyboard.menu", KeyCode::ContextMenu),
|
||||
("key.keyboard.left.control", KeyCode::ControlLeft),
|
||||
("key.keyboard.left.shift", KeyCode::ShiftLeft),
|
||||
|
||||
@@ -324,6 +324,24 @@ pub fn ease_in_out(t: f64) -> f64 {
|
||||
0.5 - 0.5 * (std::f64::consts::PI * t).cos()
|
||||
}
|
||||
|
||||
/// `MouseHandler.grabMouse` / `releaseMouse`: the mouse position is the
|
||||
/// window's centre whenever the pointer's grab state changes, so a screen
|
||||
/// opened from gameplay starts with the cursor in the middle. A release of
|
||||
/// an already free pointer (or a grab of an already grabbed one) leaves the
|
||||
/// position where it is, as vanilla does.
|
||||
pub fn mouse_position_after_grab_change(
|
||||
grab: bool,
|
||||
was_grabbed: bool,
|
||||
size: [u32; 2],
|
||||
scale: f32,
|
||||
) -> Option<(f32, f32)> {
|
||||
if grab == was_grabbed {
|
||||
None
|
||||
} else {
|
||||
Some((size[0] as f32 / 2.0 / scale, size[1] as f32 / 2.0 / scale))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -485,4 +503,27 @@ mod tests {
|
||||
assert_eq!(look.on_scroll(0.0, 0.4, false, 1.0), None);
|
||||
assert_eq!(look.on_scroll(0.0, 0.7, false, 1.0), Some((1, (0, 1))));
|
||||
}
|
||||
#[test]
|
||||
fn the_mouse_position_is_the_centre_when_the_grab_changes() {
|
||||
// A grab of a free pointer and a release of a grabbed one both put
|
||||
// the position at the window's centre (`MouseHandler.grabMouse`,
|
||||
// `MouseHandler.releaseMouse`).
|
||||
assert_eq!(
|
||||
mouse_position_after_grab_change(true, false, [1920, 1080], 1.0),
|
||||
Some((960.0, 540.0))
|
||||
);
|
||||
assert_eq!(
|
||||
mouse_position_after_grab_change(false, true, [1920, 1080], 2.0),
|
||||
Some((480.0, 270.0))
|
||||
);
|
||||
// Without a change of the grab state the position stays where it is.
|
||||
assert_eq!(
|
||||
mouse_position_after_grab_change(false, false, [1920, 1080], 1.0),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
mouse_position_after_grab_change(true, true, [1920, 1080], 1.0),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,8 @@ unicode-bidi = "0.3.18"
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Use the registered URI handler without routing links through Explorer/cmd.
|
||||
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
|
||||
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
|
||||
# on the PATH.
|
||||
|
||||
@@ -161,6 +161,7 @@ pub fn main() -> Result<()> {
|
||||
"sound {} {:08x} {:08x}",
|
||||
match sound {
|
||||
ArrowSound::HitGround => "hit",
|
||||
ArrowSound::CrossbowHit => "crossbow_hit",
|
||||
ArrowSound::Splash => "splash",
|
||||
ArrowSound::Extinguish => "extinguish",
|
||||
ArrowSound::TridentHitGround => "trident_hit_ground",
|
||||
|
||||
@@ -369,6 +369,7 @@ pub fn main() -> Result<()> {
|
||||
banner: &|_| layers.clone(),
|
||||
skull: &|_| None,
|
||||
glyph: &|c| atlas.glyph_cell(c),
|
||||
glyphs: atlas.font.epoch(),
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
renderer.render_block(&scene, pos, state, Mat4::IDENTITY, packed, &mut verts);
|
||||
|
||||
@@ -87,7 +87,11 @@ pub fn main() {
|
||||
}
|
||||
"tick" => {
|
||||
out.push_str("tick");
|
||||
for (p, _) in entities.tickers(|p| states.get(&p).copied().unwrap_or(0)) {
|
||||
for (p, _) in entities
|
||||
.tickers(|p| states.get(&p).copied().unwrap_or(0))
|
||||
.iter()
|
||||
.copied()
|
||||
{
|
||||
out.push_str(&format!(" {},{},{}", p.0, p.1, p.2));
|
||||
}
|
||||
out.push('\n');
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use horizon_client::{
|
||||
containers::{Furnace, FurnaceKind},
|
||||
crafting::Recipes,
|
||||
furnace::FurnaceCooking,
|
||||
item_data::{canon_stack, parse_stack},
|
||||
items::Items,
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! usage: mob_ai_probe RESOURCES SCENARIOS OUTPUT
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
beehive::HiveHost,
|
||||
beehive::{BeehiveExt, HiveHost},
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, HEIGHT, MIN_Y},
|
||||
@@ -1121,6 +1121,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
mob_drops: true,
|
||||
max_entity_cramming: 24,
|
||||
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
|
||||
end: false,
|
||||
world_seed: 0,
|
||||
|
||||
@@ -36,11 +36,14 @@ use horizon_client::{
|
||||
SpawnPlayer, SpawnRequest, SpawnState, Spawned, StructureAt, StructureSpawns,
|
||||
spawn_box,
|
||||
},
|
||||
spawner::{Spawner, SpawnerAdded, SpawnerEvent, SpawnerLevel, SpawnerSpawn},
|
||||
spawner::{
|
||||
SpawnDataExt, Spawner, SpawnerAdded, SpawnerEvent, SpawnerExt, SpawnerLevel,
|
||||
SpawnerSpawn,
|
||||
},
|
||||
species::{self, Baby},
|
||||
trial_spawner::{
|
||||
Omen, TRIAL_OMEN_TICKS_PER_LEVEL, TrialEvent, TrialPlayer, TrialSpawner,
|
||||
TrialSpawnerLevel, TrialState,
|
||||
TrialSpawnerExt, TrialSpawnerLevel, TrialState,
|
||||
},
|
||||
},
|
||||
vault::{Vault, VaultEvent, VaultLevel, VaultPlayer, VaultState},
|
||||
|
||||
@@ -444,6 +444,8 @@ pub struct EntityView {
|
||||
pub gamemode: Option<String>,
|
||||
/// Lightning: entities it struck.
|
||||
pub struck: Vec<EntityView>,
|
||||
/// Lightning: blocks it set on fire (`LightningBolt.blocksSetOnFire`).
|
||||
pub blocks_set_on_fire: i32,
|
||||
pub fishing_open_water: Option<bool>,
|
||||
pub variant: Option<String>,
|
||||
/// The data components the entity reports (`Entity.get`): id and value.
|
||||
@@ -538,6 +540,11 @@ pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(d) = comps["minecraft:damage"].as_u64()
|
||||
&& u64::from(it.damage) != d
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let preds = &v["predicates"];
|
||||
if let Some(list) = preds["minecraft:enchantments"].as_array() {
|
||||
for e in list {
|
||||
@@ -734,7 +741,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
.is_some_and(|t| entity(&p["looking_at"], t, w, origin)))
|
||||
}
|
||||
"minecraft:type_specific/lightning" => {
|
||||
range_i(&p["blocks_set_on_fire"], 0)
|
||||
range_i(&p["blocks_set_on_fire"], i64::from(e.blocks_set_on_fire))
|
||||
&& (p["entity_struck"].is_null()
|
||||
|| e.struck
|
||||
.iter()
|
||||
@@ -818,7 +825,13 @@ pub fn damage(v: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let ty = &v["type"];
|
||||
damage_source(&v["type"], d, w, origin)
|
||||
}
|
||||
/// `DamageSourcePredicate`: the damage type's tags and the entities behind it.
|
||||
pub fn damage_source(ty: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> bool {
|
||||
if ty.is_null() {
|
||||
return true;
|
||||
}
|
||||
let tags_ok = ty["tags"].as_array().into_iter().flatten().all(|t| {
|
||||
let tag = t["id"].as_str().unwrap_or("");
|
||||
let tag = tag.strip_prefix('#').unwrap_or(tag);
|
||||
@@ -864,6 +877,22 @@ pub fn item_entity(
|
||||
&& item(&v["item"], stack, w)
|
||||
}
|
||||
|
||||
/// `RecipeCraftedTrigger.TriggerInstance.matches`: each ingredient predicate
|
||||
/// takes a different one of the stacks used, in order.
|
||||
pub fn recipe_ingredients(v: &Value, used: &[ItemView], w: &dyn World) -> bool {
|
||||
let Some(preds) = v.as_array() else {
|
||||
return true;
|
||||
};
|
||||
let mut remaining: Vec<&ItemView> = used.iter().collect();
|
||||
preds.iter().all(|p| {
|
||||
let Some(i) = remaining.iter().position(|it| item(p, it, w)) else {
|
||||
return false;
|
||||
};
|
||||
remaining.remove(i);
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
/// A `ContextAwarePredicate`: a list of loot conditions (or one condition).
|
||||
pub fn conditions(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
|
||||
match v {
|
||||
@@ -1326,8 +1355,47 @@ mod tests {
|
||||
fn in_tag(&self, _: &str, tag: &str, id: &str) -> bool {
|
||||
(tag == "minecraft:stone_tool_materials" && id == "minecraft:cobblestone")
|
||||
|| (tag == "minecraft:sulfur_cube_archetype/explosive" && id == "minecraft:tnt")
|
||||
|| (tag == "minecraft:is_projectile"
|
||||
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
|
||||
}
|
||||
}
|
||||
/// Return to Sender's `killing_blow`: only the ghast's own fireball, as a
|
||||
/// projectile, kills it for the advancement; a sword or an arrow does not.
|
||||
#[test]
|
||||
fn killing_blow_needs_the_fireball() {
|
||||
let blow = serde_json::json!({
|
||||
"direct_entity": {"minecraft:entity_type": "minecraft:fireball"},
|
||||
"tags": [{"expected": true, "id": "#minecraft:is_projectile"}]
|
||||
});
|
||||
let entity = |kind: &str| EntityView {
|
||||
kind: kind.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let player = entity("minecraft:player");
|
||||
let fireball = DamageView {
|
||||
kind: "minecraft:fireball".into(),
|
||||
direct: Some(entity("minecraft:fireball")),
|
||||
source: Some(player.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(damage_source(&blow, &fireball, &Flat, [0.0; 3]));
|
||||
let sword = DamageView {
|
||||
kind: "minecraft:player_attack".into(),
|
||||
direct: Some(player.clone()),
|
||||
source: Some(player.clone()),
|
||||
is_direct: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!damage_source(&blow, &sword, &Flat, [0.0; 3]));
|
||||
let arrow = DamageView {
|
||||
kind: "minecraft:arrow".into(),
|
||||
direct: Some(entity("minecraft:arrow")),
|
||||
source: Some(player),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!damage_source(&blow, &arrow, &Flat, [0.0; 3]));
|
||||
assert!(damage_source(&Value::Null, &sword, &Flat, [0.0; 3]));
|
||||
}
|
||||
#[test]
|
||||
fn uh_oh_accepts_either_tnt_event_only_for_adult_sulfur_cubes() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(
|
||||
|
||||
@@ -130,6 +130,8 @@ pub enum ArrowSound {
|
||||
Splash,
|
||||
/// The arrow's `soundEvent` (`entity.arrow.hit`), for blocks and entities.
|
||||
HitGround,
|
||||
/// An arrow shot from a crossbow: `item.crossbow.hit`.
|
||||
CrossbowHit,
|
||||
/// `entity.generic.extinguish_fire`.
|
||||
Extinguish,
|
||||
/// A thrown trident's `getDefaultHitGroundSoundEvent` (`item.trident.hit_ground`).
|
||||
@@ -782,8 +784,16 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
/// `playSound(soundEvent, 1, 1.2 / (random * 0.2 + 0.9))`.
|
||||
fn hit_sound(&mut self) {
|
||||
let pitch = 1.2 / (self.st.random.next_float() * 0.2 + 0.9);
|
||||
// CrossbowItem.createProjectile: its arrows hit with the crossbow's sound.
|
||||
let crossbow = self
|
||||
.st
|
||||
.fired_from_weapon
|
||||
.as_ref()
|
||||
.is_some_and(|s| &*s.item == "minecraft:crossbow");
|
||||
let sound = if self.trident() {
|
||||
ArrowSound::TridentHitGround
|
||||
} else if crossbow {
|
||||
ArrowSound::CrossbowHit
|
||||
} else {
|
||||
ArrowSound::HitGround
|
||||
};
|
||||
|
||||
@@ -219,6 +219,8 @@ impl RenderAssets {
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
sprites.finish();
|
||||
// `finish` grows the buffer by doubling: keep only the bytes in use.
|
||||
sprites.pixels.shrink_to_fit();
|
||||
Ok(Self {
|
||||
sprite_size: sprites.size,
|
||||
sprite_minimum_size: sprites.minimum_size,
|
||||
|
||||
@@ -83,10 +83,19 @@ fn pos(tag: Option<&Tag>) -> Option<[i32; 3]> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Occupant {
|
||||
/// The bee occupant's codec and constructors, over the data in `chunk::block_entity`.
|
||||
pub trait OccupantExt: Sized {
|
||||
/// `Occupant.of`: the bee's saved tag without the ignored tags, staying
|
||||
/// 2400 ticks with nectar, else 600.
|
||||
pub fn of(mut tag: Tag) -> Self {
|
||||
fn of(tag: Tag) -> Self;
|
||||
/// `Occupant.create`: a worldgen nest's bee, a bare bee tag.
|
||||
fn create(ticks_in_hive: i32) -> Self;
|
||||
fn has_nectar(&self) -> bool;
|
||||
fn from_nbt(tag: &Tag) -> Option<Self>;
|
||||
fn to_nbt(&self) -> Tag;
|
||||
}
|
||||
impl OccupantExt for Occupant {
|
||||
fn of(mut tag: Tag) -> Self {
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
strip_ignored(c);
|
||||
}
|
||||
@@ -97,8 +106,7 @@ impl Occupant {
|
||||
min_ticks_in_hive: if nectar { 2400 } else { 600 },
|
||||
}
|
||||
}
|
||||
/// `Occupant.create`: a worldgen nest's bee, a bare bee tag.
|
||||
pub fn create(ticks_in_hive: i32) -> Self {
|
||||
fn create(ticks_in_hive: i32) -> Self {
|
||||
Self {
|
||||
entity_data: Tag::compound([("id", Tag::String("minecraft:bee".into()))]),
|
||||
ticks_in_hive,
|
||||
@@ -128,13 +136,50 @@ impl Occupant {
|
||||
}
|
||||
}
|
||||
|
||||
impl Beehive {
|
||||
pub fn full(&self) -> bool {
|
||||
self.bees.len() == MAX_OCCUPANTS
|
||||
}
|
||||
/// `BeehiveBlockEntity`'s behaviour, over the data in `chunk::block_entity`.
|
||||
pub trait BeehiveExt: Sized {
|
||||
fn full(&self) -> bool;
|
||||
/// `loadAdditional`: `bees` (all or none, as the list codec reads them)
|
||||
/// and `flower_pos`.
|
||||
pub fn from_nbt(tag: &Tag) -> Self {
|
||||
fn from_nbt(tag: &Tag) -> Self;
|
||||
/// `saveAdditional`.
|
||||
fn to_nbt(&self) -> Vec<(&'static str, Tag)>;
|
||||
/// `addOccupant` (the bee's own tag, saved by the host): the hive keeps
|
||||
/// the bee's flower, or now and then swaps its own for it. The host then
|
||||
/// discards the bee.
|
||||
fn add_occupant(
|
||||
&mut self,
|
||||
at: [i32; 3],
|
||||
tag: Tag,
|
||||
bee_flower: Option<[i32; 3]>,
|
||||
host: &mut impl HiveHost,
|
||||
) -> bool;
|
||||
/// `serverTick`: the bees whose time is up come out, then the hive
|
||||
/// hums now and then while it holds any. `honey` is the hive state's
|
||||
/// honey level as the tick starts (`None` without the property);
|
||||
/// `facing` its front.
|
||||
fn server_tick(
|
||||
&mut self,
|
||||
at: [i32; 3],
|
||||
facing: [i32; 2],
|
||||
honey: Option<i32>,
|
||||
host: &mut impl HiveHost,
|
||||
);
|
||||
/// `releaseAllOccupants`: the ids of the bees let out.
|
||||
fn release_all(
|
||||
&mut self,
|
||||
at: [i32; 3],
|
||||
facing: [i32; 2],
|
||||
honey: Option<i32>,
|
||||
status: Release,
|
||||
host: &mut impl HiveHost,
|
||||
) -> Vec<u32>;
|
||||
}
|
||||
impl BeehiveExt for Beehive {
|
||||
fn full(&self) -> bool {
|
||||
self.bees.len() == MAX_OCCUPANTS
|
||||
}
|
||||
fn from_nbt(tag: &Tag) -> Self {
|
||||
let bees = match tag.get("bees") {
|
||||
Some(Tag::List(list)) => list
|
||||
.iter()
|
||||
@@ -148,8 +193,7 @@ impl Beehive {
|
||||
flower: pos(tag.get("flower_pos")),
|
||||
}
|
||||
}
|
||||
/// `saveAdditional`.
|
||||
pub fn to_nbt(&self) -> Vec<(&'static str, Tag)> {
|
||||
fn to_nbt(&self) -> Vec<(&'static str, Tag)> {
|
||||
let mut out = vec![(
|
||||
"bees",
|
||||
Tag::List(self.bees.iter().map(Occupant::to_nbt).collect()),
|
||||
@@ -159,10 +203,7 @@ impl Beehive {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `addOccupant` (the bee's own tag, saved by the host): the hive keeps
|
||||
/// the bee's flower, or now and then swaps its own for it. The host then
|
||||
/// discards the bee.
|
||||
pub fn add_occupant(
|
||||
fn add_occupant(
|
||||
&mut self,
|
||||
at: [i32; 3],
|
||||
tag: Tag,
|
||||
@@ -182,11 +223,7 @@ impl Beehive {
|
||||
host.sound("block.beehive.enter", corner);
|
||||
true
|
||||
}
|
||||
/// `serverTick`: the bees whose time is up come out, then the hive
|
||||
/// hums now and then while it holds any. `honey` is the hive state's
|
||||
/// honey level as the tick starts (`None` without the property);
|
||||
/// `facing` its front.
|
||||
pub fn server_tick(
|
||||
fn server_tick(
|
||||
&mut self,
|
||||
at: [i32; 3],
|
||||
facing: [i32; 2],
|
||||
@@ -223,8 +260,7 @@ impl Beehive {
|
||||
host.sound("block.beehive.work", hum);
|
||||
}
|
||||
}
|
||||
/// `releaseAllOccupants`: the ids of the bees let out.
|
||||
pub fn release_all(
|
||||
fn release_all(
|
||||
&mut self,
|
||||
at: [i32; 3],
|
||||
facing: [i32; 2],
|
||||
|
||||
@@ -20,12 +20,68 @@ pub struct SpawnEntry {
|
||||
/// A `ConstantInt` count (sampling draws nothing).
|
||||
pub constant: bool,
|
||||
}
|
||||
/// A `natural_mob_spawns` / `spawn_overrides` entry list.
|
||||
pub fn parse_entries(list: &Value) -> Vec<SpawnEntry> {
|
||||
list.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let count = &e["count"];
|
||||
let (min, max, constant) = match count {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64().unwrap_or(1) as u32;
|
||||
(n, n, true)
|
||||
}
|
||||
c if c["type"].as_str() == Some("minecraft:constant") => {
|
||||
let n = c["value"].as_u64().unwrap_or(1) as u32;
|
||||
(n, n, true)
|
||||
}
|
||||
c => (
|
||||
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
false,
|
||||
),
|
||||
};
|
||||
Some(SpawnEntry {
|
||||
entity: e["type"].as_str()?.to_owned(),
|
||||
weight: e["weight"].as_u64().unwrap_or(1) as u32,
|
||||
min,
|
||||
max,
|
||||
constant,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `MobSpawnSettings.MobSpawnCost`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SpawnCost {
|
||||
pub energy_budget: f64,
|
||||
pub charge: f64,
|
||||
}
|
||||
/// The `spawn_costs` of a `natural_mob_spawns` value.
|
||||
pub fn parse_costs(costs: &Value) -> HashMap<String, SpawnCost> {
|
||||
costs
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(k, c)| {
|
||||
(
|
||||
k.clone(),
|
||||
SpawnCost {
|
||||
energy_budget: c["energy_budget"].as_f64().unwrap_or(0.0),
|
||||
charge: c["charge"].as_f64().unwrap_or(0.0),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BiomeInfo {
|
||||
/// `natural_mob_spawns` by category (creature, monster, ...).
|
||||
pub spawns: HashMap<String, Vec<SpawnEntry>>,
|
||||
/// `natural_mob_spawns` spawn costs by entity type.
|
||||
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
|
||||
pub spawn_costs: HashMap<String, SpawnCost>,
|
||||
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
|
||||
pub creature_probability: f32,
|
||||
/// The `gameplay/increased_fire_burnout` attribute (jungles, swamps, peaks,
|
||||
@@ -150,12 +206,12 @@ impl Biomes {
|
||||
};
|
||||
let categories = &natural["spawns_by_category"];
|
||||
for (category, entries) in categories.as_object().into_iter().flatten() {
|
||||
let list = crate::mobs::spawn_selection::parse_entries(entries);
|
||||
let list = parse_entries(entries);
|
||||
spawns.insert(category.clone(), list);
|
||||
}
|
||||
list.push(BiomeInfo {
|
||||
spawns,
|
||||
spawn_costs: crate::mobs::spawn_selection::parse_costs(&natural["spawn_costs"]),
|
||||
spawn_costs: parse_costs(&natural["spawn_costs"]),
|
||||
creature_probability:
|
||||
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
|
||||
.as_f64()
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
use crate::chunk::block_entity::BlockEntity;
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub type Pos = (i32, i32, i32);
|
||||
|
||||
@@ -46,7 +47,7 @@ pub enum Ticking {
|
||||
pub struct BlockEntities {
|
||||
map: HashMap<Pos, BlockEntity>,
|
||||
/// `Level.blockEntityTickers`: positions and the slot each was given.
|
||||
tickers: Vec<(Pos, u32)>,
|
||||
tickers: Arc<Vec<(Pos, u32)>>,
|
||||
/// `LevelChunk.tickersInLevel`: the live slot of each ticking position.
|
||||
live: FxHashMap<Pos, u32>,
|
||||
/// Positions that may need a slot, in the order vanilla would have made
|
||||
@@ -233,7 +234,7 @@ impl BlockEntities {
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.map.clear();
|
||||
self.tickers.clear();
|
||||
self.tickers = Arc::default();
|
||||
self.live.clear();
|
||||
self.added.clear();
|
||||
self.pending.clear();
|
||||
@@ -274,7 +275,7 @@ impl BlockEntities {
|
||||
/// and freed ones leave it. `state` reads the block state at a position.
|
||||
/// Returns the slots to tick, in order; check each with `is_live` before
|
||||
/// ticking it, as the ticks before it can free it.
|
||||
pub fn tickers(&mut self, state: impl Fn(Pos) -> u32) -> Vec<(Pos, u32)> {
|
||||
pub fn tickers(&mut self, state: impl Fn(Pos) -> u32) -> Arc<Vec<(Pos, u32)>> {
|
||||
for (pos, token) in std::mem::take(&mut self.added) {
|
||||
if self.pending.get(&pos) != Some(&token) {
|
||||
continue;
|
||||
@@ -288,21 +289,21 @@ impl BlockEntities {
|
||||
}
|
||||
let slot = self.next();
|
||||
self.live.insert(pos, slot);
|
||||
self.tickers.push((pos, slot));
|
||||
Arc::make_mut(&mut self.tickers).push((pos, slot));
|
||||
}
|
||||
if !std::mem::take(&mut self.freed) && self.out.is_empty() {
|
||||
return self.tickers.clone();
|
||||
return Arc::clone(&self.tickers);
|
||||
}
|
||||
self.out.clear();
|
||||
let (live, map) = (&mut self.live, &self.map);
|
||||
self.tickers.retain(|(p, slot)| {
|
||||
Arc::make_mut(&mut self.tickers).retain(|(p, slot)| {
|
||||
let mine = live.get(p) == Some(slot);
|
||||
if mine && !map.contains_key(p) {
|
||||
live.remove(p);
|
||||
}
|
||||
mine && map.contains_key(p)
|
||||
});
|
||||
self.tickers.clone()
|
||||
Arc::clone(&self.tickers)
|
||||
}
|
||||
/// Whether a slot from `tickers` still ticks.
|
||||
pub fn is_live(&self, (pos, slot): (Pos, u32)) -> bool {
|
||||
@@ -329,8 +330,8 @@ mod tests {
|
||||
}
|
||||
fn order(b: &mut BlockEntities, states: &HashMap<Pos, u32>) -> Vec<Pos> {
|
||||
b.tickers(|p| states.get(&p).copied().unwrap_or(0))
|
||||
.into_iter()
|
||||
.map(|(p, _)| p)
|
||||
.iter()
|
||||
.map(|(p, _)| *p)
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -435,7 +435,8 @@ pub struct BellTick {
|
||||
pub ended: Option<(Vec<([f64; 3], i32)>, Vec<u64>)>,
|
||||
}
|
||||
|
||||
fn closer_to_center_than(pos: [i32; 3], p: [f64; 3], distance: f64) -> bool {
|
||||
/// `Vec3i.closerToCenterThan`: `p` is within `distance` of the block's centre.
|
||||
pub fn closer_to_center_than(pos: [i32; 3], p: [f64; 3], distance: f64) -> bool {
|
||||
let dx = f64::from(pos[0]) + 0.5 - p[0];
|
||||
let dy = f64::from(pos[1]) + 0.5 - p[1];
|
||||
let dz = f64::from(pos[2]) + 0.5 - p[2];
|
||||
|
||||
@@ -35,6 +35,25 @@ pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
data(stack).is_some_and(|(id, _)| OP_ONLY.contains(&id.as_str()))
|
||||
}
|
||||
|
||||
/// `SpawnEggItem.shouldPrintOpWarning` for a player with game master
|
||||
/// permission: a spawn egg carries data of an op-only entity type
|
||||
/// (`EntityTypes.OP_ONLY_CUSTOM_DATA`).
|
||||
pub fn spawn_egg_op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
stack.item.ends_with("_spawn_egg")
|
||||
&& matches!(
|
||||
stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:entity_data"))
|
||||
.and_then(|data| data.get("id")),
|
||||
Some(Tag::String(id)) if matches!(
|
||||
id.to_string_lossy().as_str(),
|
||||
"minecraft:falling_block"
|
||||
| "minecraft:command_block_minecart"
|
||||
| "minecraft:spawner_minecart"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// `Spawner.getSpawnEntityDisplayName` (`SpawnData.entity.id` of the item's
|
||||
/// data, which `ItemStack.addDetailsToTooltip` reads for spawners and trial
|
||||
/// spawners alike): the entity type a spawner item spawns.
|
||||
|
||||
@@ -97,6 +97,16 @@ pub fn diffuse(normal: Vec3, lights: (Vec3, Vec3)) -> f32 {
|
||||
pub fn vertex_light(packed: u8) -> u32 {
|
||||
(u32::from(packed >> 4) * 16) | ((u32::from(packed & 15) * 16) << 8)
|
||||
}
|
||||
/// `ChestRenderer.xmasTextures`: `SpecialDates.isExtendedChristmas` (24 to 26
|
||||
/// December in the local time zone), read once when the renderer is created.
|
||||
fn extended_christmas() -> bool {
|
||||
use chrono::Datelike;
|
||||
static XMAS: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*XMAS.get_or_init(|| {
|
||||
let now = chrono::Local::now();
|
||||
now.month() == 12 && (24..=26).contains(&now.day())
|
||||
})
|
||||
}
|
||||
fn pack_color(rgb: u32, alpha: u32, brightness: f32) -> u32 {
|
||||
let c = |shift: u32| (((rgb >> shift) & 255) as f32 * brightness) as u32;
|
||||
alpha << 24 | c(0) << 16 | c(8) << 8 | c(16)
|
||||
@@ -587,6 +597,18 @@ fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> Stri
|
||||
line.to_owned()
|
||||
}
|
||||
|
||||
/// `AbstractSignRenderer.isOutlineVisible`: the camera within 16 blocks of
|
||||
/// the sign's centre.
|
||||
fn sign_text_near(pos: [i32; 3], camera: DVec3) -> bool {
|
||||
DVec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
)
|
||||
.distance_squared(camera)
|
||||
< 256.0
|
||||
}
|
||||
|
||||
/// `AbstractSignRenderer.submitSignText`: four centred lines, glowing text
|
||||
/// full-bright with an 8x outline in the dark colour.
|
||||
fn sign_text_calls(
|
||||
@@ -747,6 +769,9 @@ pub struct Scene<'a> {
|
||||
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
|
||||
/// Font glyph cell (none for blank glyphs such as the space) and advance.
|
||||
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
|
||||
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
|
||||
/// text laid out under another value is laid out again.
|
||||
pub glyphs: u64,
|
||||
}
|
||||
|
||||
pub struct BlockEntityRenderer {
|
||||
@@ -758,7 +783,30 @@ pub struct BlockEntityRenderer {
|
||||
pot_patterns: HashMap<String, String>,
|
||||
/// `Block.shouldRenderFace` for End gateway faces (its occlusion pair cache needs a lock).
|
||||
visibility: Option<Mutex<FaceVisibility>>,
|
||||
sign_texts: Mutex<SignTexts>,
|
||||
}
|
||||
/// Laid-out sign text by position. `AbstractSignRenderer` lays the text out
|
||||
/// again each frame, glyph by glyph; its vertices only change with what is
|
||||
/// in the key, so they are kept relative to the block and moved to the camera.
|
||||
#[derive(Default)]
|
||||
struct SignTexts {
|
||||
signs: crate::fast_hash::FxHashMap<[i32; 3], SignTextEntry>,
|
||||
/// Game time of the last sweep of signs out of view.
|
||||
swept: i64,
|
||||
}
|
||||
struct SignTextEntry {
|
||||
state: u32,
|
||||
data: crate::signs::SignData,
|
||||
packed: u8,
|
||||
/// Within the glowing text outline's distance (only kept for glowing text).
|
||||
near: bool,
|
||||
glyphs: u64,
|
||||
/// Game time it was last drawn.
|
||||
used: i64,
|
||||
vertices: Vec<Vertex>,
|
||||
}
|
||||
/// Game ticks a sign's text is kept after it was last drawn.
|
||||
const SIGN_TEXT_KEEP: i64 = 100;
|
||||
const MODELS: [(&str, &str); 27] = [
|
||||
("chest", "minecraft:chest#main"),
|
||||
("double_chest_left", "minecraft:double_chest_left#main"),
|
||||
@@ -888,6 +936,7 @@ impl BlockEntityRenderer {
|
||||
closed_chests,
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
}
|
||||
}
|
||||
fn kind(&self, state: u32) -> &Kind {
|
||||
@@ -922,11 +971,81 @@ impl BlockEntityRenderer {
|
||||
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
|
||||
- scene.camera)
|
||||
.as_vec3();
|
||||
let base = Mat4::from_translation(origin);
|
||||
let packed = scene.world.light(pos[0], pos[1], pos[2]);
|
||||
if matches!(kind, Kind::Sign { .. }) && scene.texts.is_none() {
|
||||
self.sign_text(scene, *pos, *state, origin, packed, out);
|
||||
continue;
|
||||
}
|
||||
let base = Mat4::from_translation(origin);
|
||||
self.render_one(scene, *pos, kind, base, packed, lights, false, true, out);
|
||||
}
|
||||
}
|
||||
/// A sign's text from the cache, laid out again when its data, light,
|
||||
/// state, outline or font changed.
|
||||
fn sign_text(
|
||||
&self,
|
||||
scene: &Scene,
|
||||
pos: [i32; 3],
|
||||
state: u32,
|
||||
origin: Vec3,
|
||||
packed: u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let Some(data) = (scene.sign)(pos) else {
|
||||
return;
|
||||
};
|
||||
let near = (data.front.glowing || data.back.glowing) && sign_text_near(pos, scene.camera);
|
||||
let Ok(mut cache) = self.sign_texts.lock() else {
|
||||
return;
|
||||
};
|
||||
let now = scene.game_time;
|
||||
if (now - cache.swept).abs() >= SIGN_TEXT_KEEP {
|
||||
cache.swept = now;
|
||||
cache
|
||||
.signs
|
||||
.retain(|_, e| (now - SIGN_TEXT_KEEP..=now).contains(&e.used));
|
||||
}
|
||||
let glyphs = scene.glyphs;
|
||||
let signs = &mut cache.signs;
|
||||
if !signs.get(&pos).is_some_and(|e| {
|
||||
(e.state, e.packed, e.near, e.glyphs) == (state, packed, near, glyphs)
|
||||
&& e.data == *data
|
||||
}) {
|
||||
let mut vertices = signs.remove(&pos).map_or_else(Vec::new, |e| e.vertices);
|
||||
vertices.clear();
|
||||
self.render_one(
|
||||
scene,
|
||||
pos,
|
||||
self.kind(state),
|
||||
Mat4::IDENTITY,
|
||||
packed,
|
||||
diffuse_lights(scene.nether),
|
||||
false,
|
||||
true,
|
||||
&mut vertices,
|
||||
);
|
||||
signs.insert(
|
||||
pos,
|
||||
SignTextEntry {
|
||||
state,
|
||||
data: data.clone(),
|
||||
packed,
|
||||
near,
|
||||
glyphs,
|
||||
used: now,
|
||||
vertices,
|
||||
},
|
||||
);
|
||||
}
|
||||
let Some(entry) = signs.get_mut(&pos) else {
|
||||
return;
|
||||
};
|
||||
entry.used = now;
|
||||
out.extend(entry.vertices.iter().map(|v| Vertex {
|
||||
pos: (Vec3::from_array(v.pos) + origin).to_array(),
|
||||
..*v
|
||||
}));
|
||||
}
|
||||
/// One block entity at `pos` drawn under `base` with the given packed light
|
||||
/// (the parity fixture's entry point).
|
||||
pub fn render_block(
|
||||
@@ -1158,13 +1277,7 @@ impl BlockEntityRenderer {
|
||||
m *= Mat4::from_translation(Vec3::new(0.0, 1.0 / 3.0, 0.046_666_667))
|
||||
* Mat4::from_scale(s * 0.010_416_667);
|
||||
}
|
||||
let near = DVec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
)
|
||||
.distance_squared(scene.camera)
|
||||
< 256.0;
|
||||
let near = sign_text_near(pos, scene.camera);
|
||||
let calls = sign_text_calls(scene, data, text, m, packed, near);
|
||||
match scene.texts {
|
||||
Some(sink) => sink.borrow_mut().extend(calls),
|
||||
@@ -1173,11 +1286,25 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
}
|
||||
Kind::Chest {
|
||||
block,
|
||||
texture,
|
||||
part,
|
||||
y_rot,
|
||||
..
|
||||
} => {
|
||||
// ChestRenderer.getChestMaterial: normal and trapped chests use the christmas
|
||||
// sprites on 24-26 December. Copper and ender chests keep theirs.
|
||||
let texture = if matches!(block.as_str(), "chest" | "trapped_chest")
|
||||
&& extended_christmas()
|
||||
{
|
||||
match part {
|
||||
ChestPart::Single => "entity/chest/christmas",
|
||||
ChestPart::Left => "entity/chest/christmas_left",
|
||||
ChestPart::Right => "entity/chest/christmas_right",
|
||||
}
|
||||
} else {
|
||||
*texture
|
||||
};
|
||||
let model = match part {
|
||||
ChestPart::Single => "chest",
|
||||
ChestPart::Left => "double_chest_left",
|
||||
@@ -1930,6 +2057,7 @@ mod tests {
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
// Compare the cached world path with the original model renderer,
|
||||
// including atlas replacement, current light and animated fallback.
|
||||
@@ -2130,6 +2258,128 @@ mod tests {
|
||||
assert_eq!(out[2].uv, [0.5, 0.5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_sign_text_matches_and_follows_changes() {
|
||||
let renderer = BlockEntityRenderer {
|
||||
models: HashMap::new(),
|
||||
kinds: vec![
|
||||
Kind::Sign {
|
||||
hanging: false,
|
||||
wall: false,
|
||||
angle: 180.0,
|
||||
},
|
||||
Kind::Sign {
|
||||
hanging: true,
|
||||
wall: true,
|
||||
angle: 90.0,
|
||||
},
|
||||
],
|
||||
closed_chests: Default::default(),
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
};
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
let sprites = HashMap::new();
|
||||
let text = |line: &str, glowing| crate::signs::SignText {
|
||||
lines: [line.into(), "two".into(), String::new(), "four".into()],
|
||||
color: 3,
|
||||
glowing,
|
||||
};
|
||||
let first_text = crate::signs::SignData {
|
||||
front: text("one", false),
|
||||
..Default::default()
|
||||
};
|
||||
let changed = crate::signs::SignData {
|
||||
front: text("changed", false),
|
||||
..Default::default()
|
||||
};
|
||||
let glowing = crate::signs::SignData {
|
||||
back: text("glow", true),
|
||||
..changed.clone()
|
||||
};
|
||||
let wide = std::cell::Cell::new(6.0);
|
||||
let glyph = |c: char| {
|
||||
let u = f32::from(c as u8 % 16) / 16.0;
|
||||
let rect = Rect {
|
||||
u0: u,
|
||||
v0: 0.0,
|
||||
u1: u + 0.0625,
|
||||
v1: 0.0625,
|
||||
w: 8,
|
||||
h: 8,
|
||||
};
|
||||
((c != ' ').then_some(rect), wide.get())
|
||||
};
|
||||
let (first_sign, changed_sign, glowing_sign) = (
|
||||
|_| Some(&first_text),
|
||||
|_| Some(&changed),
|
||||
|_| Some(&glowing),
|
||||
);
|
||||
let mut scene = Scene {
|
||||
camera: DVec3::new(3.25, 70.5, -4.75),
|
||||
orientation: Quat::IDENTITY,
|
||||
anim: &|_| BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: 0.0,
|
||||
game_time: 0,
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &first_sign,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
};
|
||||
let check = |scene: &Scene, pos: [i32; 3], state: u32| {
|
||||
let mut cached = Vec::new();
|
||||
renderer.render(scene, [(pos, state)].iter(), &mut cached);
|
||||
let corner = DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
|
||||
let mut original = Vec::new();
|
||||
renderer.render_one(
|
||||
scene,
|
||||
pos,
|
||||
renderer.kind(state),
|
||||
Mat4::from_translation((corner - scene.camera).as_vec3()),
|
||||
world.light(pos[0], pos[1], pos[2]),
|
||||
diffuse_lights(false),
|
||||
false,
|
||||
true,
|
||||
&mut original,
|
||||
);
|
||||
assert!(!cached.is_empty());
|
||||
assert_eq!(cached.len(), original.len());
|
||||
for (a, b) in cached.iter().zip(&original) {
|
||||
assert!((Vec3::from_array(a.pos) - Vec3::from_array(b.pos)).length() < 1e-5);
|
||||
assert_eq!((a.uv, a.color, a.light), (b.uv, b.color, b.light));
|
||||
}
|
||||
cached.len()
|
||||
};
|
||||
let (pos, other) = ([1, 70, 2], [-3, 64, 9]);
|
||||
let first = check(&scene, pos, 0);
|
||||
// The camera moves: the same text, moved.
|
||||
scene.camera.x += 0.5;
|
||||
check(&scene, pos, 0);
|
||||
// New text, glowing text near and far, another state, other glyphs.
|
||||
scene.sign = &changed_sign;
|
||||
assert_ne!(check(&scene, pos, 0), first);
|
||||
scene.sign = &glowing_sign;
|
||||
check(&scene, pos, 0);
|
||||
scene.camera.z -= 20.0;
|
||||
check(&scene, pos, 0);
|
||||
check(&scene, pos, 1);
|
||||
check(&scene, other, 1);
|
||||
wide.set(5.0);
|
||||
scene.glyphs = 1;
|
||||
check(&scene, pos, 1);
|
||||
// Signs out of view for a while are dropped.
|
||||
scene.game_time = 2 * SIGN_TEXT_KEEP;
|
||||
check(&scene, pos, 0);
|
||||
assert_eq!(renderer.sign_texts.lock().unwrap().signs.len(), 1);
|
||||
}
|
||||
#[test]
|
||||
fn first_line_wraps_on_bytes_without_splitting_a_char() {
|
||||
let advance = |c: char| if c == ' ' { 4.0 } else { 6.0 };
|
||||
@@ -2190,6 +2440,7 @@ mod tests {
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
renderer.render_block(&scene, [0; 3], state.0, Mat4::IDENTITY, 0xff, &mut verts);
|
||||
|
||||
@@ -772,6 +772,27 @@ pub fn bob_matrix(state: &BobState, damage_tilt_strength: f64, bob_view_option:
|
||||
pose
|
||||
}
|
||||
|
||||
/// The spinning effect `GameRenderer.renderLevel` multiplies into the level's
|
||||
/// projection after the bob: a skew about a tilted axis, from a portal's or
|
||||
/// Nausea's intensity (already scaled by Distortion Effects) and the angle in
|
||||
/// degrees. The first-person hand keeps its own projection without it.
|
||||
pub fn spinning_effect_matrix(intensity: f32, angle_degrees: f32) -> glam::Mat4 {
|
||||
if intensity <= 0.0 {
|
||||
return glam::Mat4::IDENTITY;
|
||||
}
|
||||
let mut skew = 5.0 / (intensity * intensity + 5.0) - intensity * 0.04;
|
||||
skew *= skew;
|
||||
let axis = glam::Vec3::new(
|
||||
0.0,
|
||||
std::f32::consts::SQRT_2 / 2.0,
|
||||
std::f32::consts::SQRT_2 / 2.0,
|
||||
);
|
||||
let angle = angle_degrees * DEG_TO_RAD;
|
||||
glam::Mat4::from_axis_angle(axis, angle)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(1.0 / skew, 1.0, 1.0))
|
||||
* glam::Mat4::from_axis_angle(axis, -angle)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
||||
@@ -301,6 +301,24 @@ pub struct Spawner {
|
||||
pub spin: f64,
|
||||
pub o_spin: f64,
|
||||
}
|
||||
impl Default for Spawner {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
spawn_delay: 20,
|
||||
potentials: Vec::new(),
|
||||
next: None,
|
||||
min_spawn_delay: 200,
|
||||
max_spawn_delay: 800,
|
||||
spawn_count: 4,
|
||||
max_nearby_entities: 6,
|
||||
required_player_range: 16,
|
||||
spawn_range: 4,
|
||||
client_delay: 20,
|
||||
spin: 0.0,
|
||||
o_spin: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `TrialSpawnerConfig`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TrialConfig {
|
||||
@@ -314,6 +332,26 @@ pub struct TrialConfig {
|
||||
pub loot_tables_to_eject: Vec<(String, i32)>,
|
||||
pub items_to_drop_when_ominous: String,
|
||||
}
|
||||
impl Default for TrialConfig {
|
||||
/// `TrialSpawnerConfig.Builder`'s defaults.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
spawn_range: 4,
|
||||
total_mobs: 6.0,
|
||||
simultaneous_mobs: 2.0,
|
||||
total_mobs_added_per_player: 2.0,
|
||||
simultaneous_mobs_added_per_player: 1.0,
|
||||
ticks_between_spawn: 40,
|
||||
spawn_potentials: Vec::new(),
|
||||
loot_tables_to_eject: vec![
|
||||
("minecraft:spawners/trial_chamber/consumables".into(), 1),
|
||||
("minecraft:spawners/trial_chamber/key".into(), 1),
|
||||
],
|
||||
items_to_drop_when_ominous:
|
||||
"minecraft:spawners/trial_chamber/items_to_drop_when_ominous".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A `Holder<TrialSpawnerConfig>` as saved: a registry id
|
||||
/// (`minecraft:trial_chamber/breeze/normal`) or an inline config.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -347,6 +385,28 @@ pub struct TrialSpawner {
|
||||
/// and their weights (the rolled counts).
|
||||
pub dispensing: Option<Vec<ItemStack>>,
|
||||
}
|
||||
impl Default for TrialSpawner {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
normal: ConfigSlot::default(),
|
||||
ominous: ConfigSlot::default(),
|
||||
target_cooldown_length: 36000,
|
||||
required_player_range: 14,
|
||||
detected_players: Vec::new(),
|
||||
current_mobs: Vec::new(),
|
||||
cooldown_ends_at: 0,
|
||||
next_mob_spawns_at: 0,
|
||||
total_mobs_spawned: 0,
|
||||
next_spawn_data: None,
|
||||
ejecting_loot_table: None,
|
||||
is_ominous: false,
|
||||
has_display: false,
|
||||
spin: 0.0,
|
||||
o_spin: 0.0,
|
||||
dispensing: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `VaultConfig`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct VaultConfig {
|
||||
|
||||
@@ -15,33 +15,11 @@ pub struct Confirm {
|
||||
pub buttons: [(f32, f32); 2],
|
||||
}
|
||||
impl Confirm {
|
||||
pub fn new(font: &Font, (w, h): (f32, f32), title: &Text, message: &Text) -> Self {
|
||||
let message: Vec<Text> = font
|
||||
.split_lines(message, w - 50.0)
|
||||
.into_iter()
|
||||
.map(|(line, _)| line)
|
||||
.take(15)
|
||||
.collect();
|
||||
let tw = font.width_text(title);
|
||||
let mw = message
|
||||
.iter()
|
||||
.map(|l| font.width_text(l))
|
||||
.fold(0.0, f32::max);
|
||||
let mh = 9.0 * message.len() as f32;
|
||||
let col = tw.max(mw).max(304.0);
|
||||
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
|
||||
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
|
||||
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
|
||||
let mid = cell(mw) + (mw / 2.0).trunc();
|
||||
let mut lines = vec![(title.clone(), cell(tw), ly)];
|
||||
for (i, line) in message.into_iter().enumerate() {
|
||||
let x = mid - (font.width_text(&line) / 2.0).trunc();
|
||||
lines.push((line, x, ly + 17.0 + 9.0 * i as f32));
|
||||
}
|
||||
let (bx, by) = (cell(304.0), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
|
||||
pub fn new(font: &Font, size: (f32, f32), title: &Text, message: &Text) -> Self {
|
||||
let Choice { lines, buttons, .. } = Choice::new(font, size, title, message, 2, 150.0);
|
||||
Self {
|
||||
lines,
|
||||
buttons: [(bx, by), (bx + 154.0, by)],
|
||||
buttons: [buttons[0], buttons[1]],
|
||||
}
|
||||
}
|
||||
/// The answer a click at `cursor` gives: true for yes, false for no.
|
||||
@@ -84,3 +62,119 @@ impl Confirm {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ConfirmScreen` with a row of `count` buttons `width` wide, 4 apart: `ConfirmLinkScreen`'s
|
||||
/// yes, copy and no are three of 100. `Confirm` is its two of 150.
|
||||
pub struct Choice {
|
||||
/// Each line and where it is drawn.
|
||||
pub lines: Vec<(Text, f32, f32)>,
|
||||
/// The buttons' corners, left to right.
|
||||
pub buttons: Vec<(f32, f32)>,
|
||||
pub width: f32,
|
||||
}
|
||||
impl Choice {
|
||||
pub fn new(
|
||||
font: &Font,
|
||||
(w, h): (f32, f32),
|
||||
title: &Text,
|
||||
message: &Text,
|
||||
count: usize,
|
||||
width: f32,
|
||||
) -> Self {
|
||||
let message: Vec<Text> = font
|
||||
.split_lines(message, w - 50.0)
|
||||
.into_iter()
|
||||
.map(|(line, _)| line)
|
||||
.take(15)
|
||||
.collect();
|
||||
let tw = font.width_text(title);
|
||||
let mw = message
|
||||
.iter()
|
||||
.map(|l| font.width_text(l))
|
||||
.fold(0.0, f32::max);
|
||||
let mh = 9.0 * message.len() as f32;
|
||||
let row = width * count as f32 + 4.0 * count.saturating_sub(1) as f32;
|
||||
let col = tw.max(mw).max(row);
|
||||
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
|
||||
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
|
||||
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
|
||||
let mid = cell(mw) + (mw / 2.0).trunc();
|
||||
let mut lines = vec![(title.clone(), cell(tw), ly)];
|
||||
for (i, line) in message.into_iter().enumerate() {
|
||||
let x = mid - (font.width_text(&line) / 2.0).trunc();
|
||||
lines.push((line, x, ly + 17.0 + 9.0 * i as f32));
|
||||
}
|
||||
let (bx, by) = (cell(row), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
|
||||
Self {
|
||||
lines,
|
||||
buttons: (0..count)
|
||||
.map(|i| (bx + (width + 4.0) * i as f32, by))
|
||||
.collect(),
|
||||
width,
|
||||
}
|
||||
}
|
||||
/// The button a click at `cursor` hits, counted from the left.
|
||||
pub fn pressed_at(&self, cursor: (f32, f32)) -> Option<usize> {
|
||||
self.buttons
|
||||
.iter()
|
||||
.position(|&(x, y)| ui::hovered(cursor, x, y, self.width, 20.0))
|
||||
}
|
||||
/// The lines and the buttons, over whatever is behind.
|
||||
pub fn draw(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
labels: &[Text],
|
||||
) {
|
||||
for (line, x, y) in &self.lines {
|
||||
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
|
||||
}
|
||||
for (&(bx, by), label) in self.buttons.iter().zip(labels) {
|
||||
let hover = ui::hovered(cursor, bx, by, self.width, 20.0);
|
||||
ui::button(batch, atlas, bx, by, self.width, 20.0, label, true, hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::hud::Rect;
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
fn font() -> Font {
|
||||
// The missing-glyph metrics let these tests run without game assets.
|
||||
let rect = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
};
|
||||
Font::load(
|
||||
Path::new("/nonexistent-horizon-confirm-test-resources"),
|
||||
&HashMap::new(),
|
||||
&[],
|
||||
(1, 1),
|
||||
(0, 0),
|
||||
rect,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_buttons_sit_four_apart_in_the_middle() {
|
||||
let (title, message) = (Text::literal("Open?"), Text::literal("https://x"));
|
||||
let c = Choice::new(&font(), (480.0, 270.0), &title, &message, 3, 100.0);
|
||||
let xs: Vec<f32> = c.buttons.iter().map(|b| b.0).collect();
|
||||
// 3 x 100 + 2 x 4 = 308 wide, centred on 480.
|
||||
assert_eq!(xs, vec![86.0, 190.0, 294.0]);
|
||||
assert_eq!(c.pressed_at((191.0, c.buttons[1].1 + 1.0)), Some(1));
|
||||
assert_eq!(c.pressed_at((188.0, c.buttons[1].1 + 1.0)), None);
|
||||
// Confirm keeps its two 150-wide buttons where they were.
|
||||
let two = Confirm::new(&font(), (480.0, 270.0), &title, &message);
|
||||
assert_eq!(two.buttons[0].0, 88.0);
|
||||
assert_eq!(two.buttons[1].0, 242.0);
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,8 @@ pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
|
||||
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
|
||||
};
|
||||
use crate::{
|
||||
crafting::Recipes,
|
||||
items::{ItemStack, Items},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
|
||||
impl FurnaceKind {
|
||||
pub fn recipe_type(self) -> &'static str {
|
||||
@@ -380,167 +377,6 @@ impl BlockEntity {
|
||||
}
|
||||
}
|
||||
pub use crate::providers::Providers;
|
||||
fn remainder(item: &str) -> Option<&'static str> {
|
||||
match item {
|
||||
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
|
||||
Some("minecraft:bucket")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
impl Furnace {
|
||||
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
|
||||
/// the same item with the same components restarts cooking, with the
|
||||
/// total time of the new input's recipe (200 ticks without one).
|
||||
pub fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
|
||||
let same = match (&self.items[0], &stack) {
|
||||
(Some(old), Some(new)) => old.same_item_same_components(new),
|
||||
_ => false,
|
||||
};
|
||||
self.items[0] = stack;
|
||||
if !same {
|
||||
self.cooking_total = self.items[0]
|
||||
.as_ref()
|
||||
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
|
||||
.map_or(200, |recipe| total_time(recipe.time, self.speed));
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
|
||||
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
|
||||
pub fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
|
||||
self.recipes_used
|
||||
.iter()
|
||||
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
|
||||
.collect()
|
||||
}
|
||||
/// A menu click wrote its slots into `items`, and `before` was the input
|
||||
/// slot until then: the menu called `setItem` on it only if it changed
|
||||
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
|
||||
pub fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
|
||||
if self.items[0] != before {
|
||||
let after = std::mem::replace(&mut self.items[0], before);
|
||||
self.set_input(recipes, after);
|
||||
}
|
||||
}
|
||||
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
recipes: &Recipes,
|
||||
items: &Items,
|
||||
providers: &Providers,
|
||||
) -> Option<bool> {
|
||||
let was_lit = self.lit_remaining > 0;
|
||||
let mut lit = false;
|
||||
if self.lit_remaining > 0 {
|
||||
self.lit_remaining -= 1;
|
||||
lit = self.lit_remaining > 0;
|
||||
}
|
||||
let fast = self.kind.fast();
|
||||
let has_ingredient = self.items[0].is_some();
|
||||
let has_fuel = self.items[1].is_some();
|
||||
if lit || has_fuel && has_ingredient {
|
||||
if let Some(ingredient) = self.items[0].clone() {
|
||||
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
|
||||
if self.cooking_timer == 0 {
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
}
|
||||
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
|
||||
let can_burn = match &self.items[2] {
|
||||
None => true,
|
||||
Some(out) => {
|
||||
out.item == recipe.result.item && out.count + recipe.result.count <= max
|
||||
}
|
||||
};
|
||||
if can_burn {
|
||||
if !lit {
|
||||
let fuel = self.items[1].clone();
|
||||
let time = fuel
|
||||
.as_ref()
|
||||
.map_or(0, |f| providers.burn_time(items, f, fast));
|
||||
let speed = fuel
|
||||
.as_ref()
|
||||
.map_or(1.0, |f| providers.speed(items, f, fast));
|
||||
self.lit_remaining = time;
|
||||
self.lit_total = time;
|
||||
self.speed = speed;
|
||||
if self.cooking_total > 0 && self.cooking_timer < self.cooking_total {
|
||||
let ratio = self.cooking_timer as f32 / self.cooking_total as f32;
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
self.cooking_timer =
|
||||
(ratio * self.cooking_total as f32).ceil() as i32;
|
||||
}
|
||||
if time > 0 {
|
||||
if let Some(f) = self.items[1].as_mut() {
|
||||
let item = f.item.clone();
|
||||
f.count -= 1;
|
||||
if f.count == 0 {
|
||||
self.items[1] = remainder(&item).map(|r| ItemStack {
|
||||
item: Arc::from(r),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
lit = true;
|
||||
}
|
||||
}
|
||||
if lit {
|
||||
self.cooking_timer += 1;
|
||||
if self.cooking_timer >= self.cooking_total {
|
||||
self.cooking_timer = 0;
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
match &mut self.items[2] {
|
||||
Some(out) => out.count += recipe.result.count,
|
||||
None => self.items[2] = Some(recipe.result.clone()),
|
||||
}
|
||||
*self
|
||||
.recipes_used
|
||||
.entry(recipe_used_key(&recipe.id))
|
||||
.or_insert(0) += 1;
|
||||
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
|
||||
// the bucket in the fuel slot.
|
||||
if self.items[0]
|
||||
.as_ref()
|
||||
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
|
||||
&& self.items[1]
|
||||
.as_ref()
|
||||
.is_some_and(|f| &*f.item == "minecraft:bucket")
|
||||
{
|
||||
self.items[1] =
|
||||
Some(ItemStack::new("minecraft:water_bucket", 1));
|
||||
}
|
||||
if let Some(i) = self.items[0].as_mut() {
|
||||
i.count -= 1;
|
||||
if i.count == 0 {
|
||||
self.items[0] = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else if self.cooking_timer > 0 {
|
||||
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
|
||||
}
|
||||
(was_lit != lit).then_some(lit)
|
||||
}
|
||||
}
|
||||
fn total_time(time: u32, speed: f32) -> i32 {
|
||||
if speed > 0.0 {
|
||||
(time as f32 / speed).ceil() as i32
|
||||
} else {
|
||||
time as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
|
||||
/// more with the chance of what is left over (a level random draw only when there is some).
|
||||
|
||||
@@ -326,16 +326,18 @@ fn panic_message(info: &std::panic::PanicHookInfo) -> String {
|
||||
/// frames go. Paths inside the workspace lose the build machine's prefix.
|
||||
pub fn stack_lines(backtrace: &str, from_panic: bool) -> String {
|
||||
// The workspace as it was on the machine that built this binary (it need not exist here).
|
||||
let manifest = env!("CARGO_MANIFEST_DIR");
|
||||
// A report names workspace files the same way on every host, whatever
|
||||
// separator the build machine used.
|
||||
let manifest = env!("CARGO_MANIFEST_DIR").replace('\\', "/");
|
||||
let root = manifest
|
||||
.strip_suffix("crates/horizon-client")
|
||||
.unwrap_or(manifest);
|
||||
.unwrap_or(&manifest);
|
||||
let mut frames: Vec<(String, Option<String>)> = Vec::new();
|
||||
for line in backtrace.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(at) = line.strip_prefix("at ") {
|
||||
if let Some(frame) = frames.last_mut() {
|
||||
frame.1 = Some(short_path(at, root).to_owned());
|
||||
frame.1 = Some(short_path(&at.replace('\\', "/"), root).to_owned());
|
||||
}
|
||||
} else if let Some((n, name)) = line.split_once(": ")
|
||||
&& n.chars().all(|c| c.is_ascii_digit())
|
||||
|
||||
@@ -355,6 +355,19 @@ impl Creative {
|
||||
pub fn search_items(&self) -> &[ItemStack] {
|
||||
&self.search_items
|
||||
}
|
||||
/// Opening the screen: vanilla rebuilds CreativeModeInventoryScreen, whose
|
||||
/// init() makes a fresh search box and a zeroed scrollbar; only the tab
|
||||
/// persists (its static selectedTab). selectTab on the reopened screen
|
||||
/// takes the oldTab != tab branch, so it empties and refocuses the box.
|
||||
pub fn reopen(&mut self) {
|
||||
self.search.set_value("");
|
||||
self.release();
|
||||
self.scroll = 0.0;
|
||||
self.search.focused = self.tab().search;
|
||||
if self.search.focused {
|
||||
self.search.reset_blink();
|
||||
}
|
||||
}
|
||||
/// Indices into `search_items` that CreativeModeInventoryScreen.refreshSearchResults
|
||||
/// shows for `term`: the whole tab when empty, the tag tree after `#`, else
|
||||
/// the name tree, both over vanilla's suffix arrays (substring matches,
|
||||
@@ -575,6 +588,8 @@ pub enum DisplayClick {
|
||||
Nothing,
|
||||
/// SWAP: the hotbar slot gets a full stack of the item.
|
||||
Hotbar(usize, ItemStack),
|
||||
/// SWAP with button 40 (Key F): the off-hand gets a full stack of the item.
|
||||
Offhand(ItemStack),
|
||||
/// THROW: a copy of the item is dropped.
|
||||
Drop(ItemStack),
|
||||
}
|
||||
@@ -596,8 +611,11 @@ pub fn click_display(
|
||||
match input {
|
||||
Input::Swap => {
|
||||
return match shown {
|
||||
Some(s) => DisplayClick::Hotbar(button as usize, copy(s, max(s))),
|
||||
None => DisplayClick::Nothing,
|
||||
Some(s) if button == 40 => DisplayClick::Offhand(copy(s, max(s))),
|
||||
Some(s) if (0..9).contains(&button) => {
|
||||
DisplayClick::Hotbar(button as usize, copy(s, max(s)))
|
||||
}
|
||||
Some(_) | None => DisplayClick::Nothing,
|
||||
};
|
||||
}
|
||||
Input::Clone => {
|
||||
@@ -720,11 +738,15 @@ mod tests {
|
||||
display(Some(stack("ender_pearl", 9)), 1, Pickup).0,
|
||||
Some(stack("ender_pearl", 8))
|
||||
);
|
||||
// Number keys fill a hotbar slot, Q drops copies, pick block clones.
|
||||
// Number keys fill a hotbar slot, F fills off-hand, Q drops copies, pick block clones.
|
||||
assert_eq!(
|
||||
display(None, 3, Swap).1,
|
||||
DisplayClick::Hotbar(3, stack("stone", 64))
|
||||
);
|
||||
assert_eq!(
|
||||
display(None, 40, Swap).1,
|
||||
DisplayClick::Offhand(stack("stone", 64))
|
||||
);
|
||||
assert_eq!(
|
||||
display(None, 0, Throw).1,
|
||||
DisplayClick::Drop(stack("stone", 1))
|
||||
|
||||
@@ -29,6 +29,10 @@ impl EffectInstance {
|
||||
fn infinite(&self) -> bool {
|
||||
self.duration == -1
|
||||
}
|
||||
/// `MobEffectInstance.endsWithin`.
|
||||
pub fn ends_within(&self, ticks: i32) -> bool {
|
||||
!self.infinite() && self.duration <= ticks
|
||||
}
|
||||
fn shorter_than(&self, other: &Self) -> bool {
|
||||
!self.infinite() && (self.duration < other.duration || other.infinite())
|
||||
}
|
||||
|
||||
@@ -263,6 +263,16 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
|
||||
// rendertype_outline.fsh: the vertex colour (the team colour; outline vertices carry no
|
||||
// normal, so no cardinal light) wherever the texture is not fully clear.
|
||||
// rendertype_entity_shadow.fsh: the shadow texture times the vertex colour,
|
||||
// then fog, with no alpha cutout, so a faint shadow in dim light still draws.
|
||||
@fragment fn fs_entity_shadow(in: Out) -> @location(0) vec4<f32> {
|
||||
var color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
|
||||
color *= in.light_color;
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
}
|
||||
|
||||
@fragment fn fs_outline(in: Out) -> @location(0) vec4<f32> {
|
||||
if textureSample(atlas, atlas_sampler, in.uv).a == 0.0 { discard; }
|
||||
return vec4(in.color.rgb, 1.0);
|
||||
|
||||
@@ -300,6 +300,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
// Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny).
|
||||
"minecraft:rabbit" => {
|
||||
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
|
||||
map.entry("MoreCarrotTicks".into()).or_insert(Tag::Int(0));
|
||||
@@ -907,7 +908,20 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
continue;
|
||||
};
|
||||
let v = f64::from_bits(*bits);
|
||||
// A mount's rolled health and speed stay attribute bases, which
|
||||
// the server syncs (its hearts over the rider's hotbar).
|
||||
let equine = crate::mobs::equine::is_equine(m.species());
|
||||
match id.as_str() {
|
||||
"minecraft:max_health" if equine => crate::mobs::finalize::set_base(
|
||||
&mut m,
|
||||
crate::mobs::finalize::Attr::MaxHealth,
|
||||
v,
|
||||
),
|
||||
"minecraft:movement_speed" if equine => crate::mobs::finalize::set_base(
|
||||
&mut m,
|
||||
crate::mobs::finalize::Attr::MovementSpeed,
|
||||
v,
|
||||
),
|
||||
"minecraft:max_health" => m.attrs.max_health = v as f32,
|
||||
"minecraft:movement_speed" => m.attrs.speed = v,
|
||||
"minecraft:attack_damage" => m.attrs.attack_damage = v as f32,
|
||||
@@ -1508,6 +1522,10 @@ pub fn arrow_to_nbt(
|
||||
// Projectile: LeftOwner only when set; Owner as the UUID.
|
||||
map.remove(&"LeftOwner".into());
|
||||
map.remove(&"inBlockState".into());
|
||||
map.remove(&"weapon".into());
|
||||
}
|
||||
if let Some(weapon) = &a.fired_from_weapon {
|
||||
put(&mut tag, "weapon", item_to_nbt(weapon));
|
||||
}
|
||||
if p.left_owner {
|
||||
put(&mut tag, "LeftOwner", Tag::Byte(1));
|
||||
@@ -1554,6 +1572,7 @@ pub fn arrow_from_nbt(
|
||||
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i16 as i32;
|
||||
a.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
|
||||
a.pickup_item = item;
|
||||
a.fired_from_weapon = tag.get("weapon").and_then(item_from_nbt);
|
||||
p.stuck = a.in_ground;
|
||||
Some((p, tag.get("inBlockState").cloned()))
|
||||
}
|
||||
|
||||
@@ -130,6 +130,8 @@ pub struct Font {
|
||||
atlas: (u32, u32),
|
||||
region: (u32, u32),
|
||||
cache: RefCell<Cache>,
|
||||
/// Set anew for each font and each `reset_cache` (see `epoch`).
|
||||
generation: std::cell::Cell<u64>,
|
||||
/// "Force Unicode Font": the `uniform` filter option.
|
||||
pub uniform: bool,
|
||||
/// "Japanese Glyph Variants": the `jp` filter option.
|
||||
@@ -559,6 +561,11 @@ impl Region {
|
||||
Some((ax, ay))
|
||||
}
|
||||
}
|
||||
/// A value no font has had yet (`Font::epoch`).
|
||||
fn next_generation() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
impl Font {
|
||||
/// Build from the atlas holding the bitmap font sheets (`sprites`, of
|
||||
/// `atlas` size) with `region` the reserved rows.
|
||||
@@ -603,6 +610,7 @@ impl Font {
|
||||
.map_or(0, |d| d.as_nanos() as i64),
|
||||
),
|
||||
}),
|
||||
generation: std::cell::Cell::new(next_generation()),
|
||||
uniform: false,
|
||||
japanese: false,
|
||||
};
|
||||
@@ -661,6 +669,13 @@ impl Font {
|
||||
}
|
||||
cache.region.cursor = (0, 0);
|
||||
cache.region.row = 0;
|
||||
self.generation.set(next_generation());
|
||||
}
|
||||
/// Changes whenever a glyph may come out different: another font, a
|
||||
/// cache reset (glyphs move in the atlas) or other font options. Text laid
|
||||
/// out under one epoch can be kept until it changes.
|
||||
pub fn epoch(&self) -> u64 {
|
||||
self.generation.get() << 2 | u64::from(self.uniform) << 1 | u64::from(self.japanese)
|
||||
}
|
||||
/// The glyph for a character in the default font (FontSet.getGlyph).
|
||||
pub fn glyph(&self, c: char) -> Glyph {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! `AbstractFurnaceBlockEntity`'s cooking over the furnace data the chunk
|
||||
//! holds (`containers::Furnace`): it needs the recipes, so it lives above them.
|
||||
use crate::{
|
||||
containers::{Furnace, recipe_used_key},
|
||||
crafting::Recipes,
|
||||
items::{ItemStack, Items},
|
||||
providers::Providers,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The furnace methods that read the recipes.
|
||||
pub trait FurnaceCooking {
|
||||
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
|
||||
/// the same item with the same components restarts cooking, with the
|
||||
/// total time of the new input's recipe (200 ticks without one).
|
||||
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>);
|
||||
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
|
||||
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
|
||||
fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)>;
|
||||
/// A menu click wrote its slots into `items`, and `before` was the input
|
||||
/// slot until then: the menu called `setItem` on it only if it changed
|
||||
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
|
||||
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>);
|
||||
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
|
||||
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool>;
|
||||
}
|
||||
fn remainder(item: &str) -> Option<&'static str> {
|
||||
match item {
|
||||
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
|
||||
Some("minecraft:bucket")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
impl FurnaceCooking for Furnace {
|
||||
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
|
||||
let same = match (&self.items[0], &stack) {
|
||||
(Some(old), Some(new)) => old.same_item_same_components(new),
|
||||
_ => false,
|
||||
};
|
||||
self.items[0] = stack;
|
||||
if !same {
|
||||
self.cooking_total = self.items[0]
|
||||
.as_ref()
|
||||
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
|
||||
.map_or(200, |recipe| total_time(recipe.time, self.speed));
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
|
||||
self.recipes_used
|
||||
.iter()
|
||||
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
|
||||
.collect()
|
||||
}
|
||||
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
|
||||
if self.items[0] != before {
|
||||
let after = std::mem::replace(&mut self.items[0], before);
|
||||
self.set_input(recipes, after);
|
||||
}
|
||||
}
|
||||
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool> {
|
||||
let was_lit = self.lit_remaining > 0;
|
||||
let mut lit = false;
|
||||
if self.lit_remaining > 0 {
|
||||
self.lit_remaining -= 1;
|
||||
lit = self.lit_remaining > 0;
|
||||
}
|
||||
let fast = self.kind.fast();
|
||||
let has_ingredient = self.items[0].is_some();
|
||||
let has_fuel = self.items[1].is_some();
|
||||
if lit || has_fuel && has_ingredient {
|
||||
if let Some(ingredient) = self.items[0].clone() {
|
||||
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
|
||||
if self.cooking_timer == 0 {
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
}
|
||||
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
|
||||
let can_burn = match &self.items[2] {
|
||||
None => true,
|
||||
Some(out) => {
|
||||
out.item == recipe.result.item && out.count + recipe.result.count <= max
|
||||
}
|
||||
};
|
||||
if can_burn {
|
||||
if !lit {
|
||||
let fuel = self.items[1].clone();
|
||||
let time = fuel
|
||||
.as_ref()
|
||||
.map_or(0, |f| providers.burn_time(items, f, fast));
|
||||
let speed = fuel
|
||||
.as_ref()
|
||||
.map_or(1.0, |f| providers.speed(items, f, fast));
|
||||
self.lit_remaining = time;
|
||||
self.lit_total = time;
|
||||
self.speed = speed;
|
||||
if self.cooking_total > 0 && self.cooking_timer < self.cooking_total {
|
||||
let ratio = self.cooking_timer as f32 / self.cooking_total as f32;
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
self.cooking_timer =
|
||||
(ratio * self.cooking_total as f32).ceil() as i32;
|
||||
}
|
||||
if time > 0 {
|
||||
if let Some(f) = self.items[1].as_mut() {
|
||||
let item = f.item.clone();
|
||||
f.count -= 1;
|
||||
if f.count == 0 {
|
||||
self.items[1] = remainder(&item).map(|r| ItemStack {
|
||||
item: Arc::from(r),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
lit = true;
|
||||
}
|
||||
}
|
||||
if lit {
|
||||
self.cooking_timer += 1;
|
||||
if self.cooking_timer >= self.cooking_total {
|
||||
self.cooking_timer = 0;
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
match &mut self.items[2] {
|
||||
Some(out) => out.count += recipe.result.count,
|
||||
None => self.items[2] = Some(recipe.result.clone()),
|
||||
}
|
||||
*self
|
||||
.recipes_used
|
||||
.entry(recipe_used_key(&recipe.id))
|
||||
.or_insert(0) += 1;
|
||||
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
|
||||
// the bucket in the fuel slot.
|
||||
if self.items[0]
|
||||
.as_ref()
|
||||
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
|
||||
&& self.items[1]
|
||||
.as_ref()
|
||||
.is_some_and(|f| &*f.item == "minecraft:bucket")
|
||||
{
|
||||
self.items[1] =
|
||||
Some(ItemStack::new("minecraft:water_bucket", 1));
|
||||
}
|
||||
if let Some(i) = self.items[0].as_mut() {
|
||||
i.count -= 1;
|
||||
if i.count == 0 {
|
||||
self.items[0] = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else if self.cooking_timer > 0 {
|
||||
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
|
||||
}
|
||||
(was_lit != lit).then_some(lit)
|
||||
}
|
||||
}
|
||||
fn total_time(time: u32, speed: f32) -> i32 {
|
||||
if speed > 0.0 {
|
||||
(time as f32 / speed).ceil() as i32
|
||||
} else {
|
||||
time as i32
|
||||
}
|
||||
}
|
||||
+485
-202
File diff suppressed because it is too large.
Load diff
@@ -408,6 +408,14 @@ impl Game<'_> {
|
||||
equipment[4] = slot(&m.extra.legs);
|
||||
equipment[5] = slot(&m.extra.feet);
|
||||
}
|
||||
// A wolf's armor is its body slot, with its durability.
|
||||
if m.species() == horizon_client::mobs::Species::Wolf {
|
||||
equipment[6] = m.extra.head.as_ref().map(|id| {
|
||||
let mut armor = ItemStack::new(id, 1);
|
||||
armor.damage = m.extra.body_damage.max(0) as u32;
|
||||
item_view(&armor)
|
||||
});
|
||||
}
|
||||
EntityView {
|
||||
kind: m.kind.id.clone(),
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
@@ -719,6 +727,32 @@ impl Game<'_> {
|
||||
adv::conditions(&c["player"], &pctx, w) && adv::conditions(&c[&key], &ectx, w)
|
||||
});
|
||||
}
|
||||
/// `KilledTrigger` (`player_killed_entity`): the entity killed and the
|
||||
/// killing blow's damage source (none: any `killing_blow` fails).
|
||||
pub(super) fn trigger_killed(&mut self, target: &EntityView, blow: Option<&adv::DamageView>) {
|
||||
let me = self.player_entity();
|
||||
let target = target.clone();
|
||||
let blow = blow.cloned();
|
||||
self.trigger("minecraft:player_killed_entity", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let ectx = adv::Entities {
|
||||
this: Some(&target),
|
||||
..pctx
|
||||
};
|
||||
let blow_ok = c["killing_blow"].is_null()
|
||||
|| blow
|
||||
.as_ref()
|
||||
.is_some_and(|d| adv::damage_source(&c["killing_blow"], d, w, me.position));
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::conditions(&c["entity"], &ectx, w)
|
||||
&& blow_ok
|
||||
});
|
||||
}
|
||||
/// `KilledByArrowTrigger`: what the arrow killed and the weapon it came from.
|
||||
pub(super) fn trigger_killed_by_arrow(
|
||||
&mut self,
|
||||
@@ -869,9 +903,6 @@ impl Game<'_> {
|
||||
stack: &ItemStack,
|
||||
target: &EntityView,
|
||||
) {
|
||||
if self.lan_acting.is_some() {
|
||||
return;
|
||||
}
|
||||
let me = self.player_entity();
|
||||
let it = item_view(stack);
|
||||
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
|
||||
@@ -993,6 +1024,42 @@ impl Game<'_> {
|
||||
block_ok && state_ok && adv::conditions(&c["player"], &pctx, w)
|
||||
});
|
||||
}
|
||||
/// `SlideDownBlockTrigger`: match the actual block and its state.
|
||||
pub(super) fn trigger_honey_slide(&mut self, pos: [i32; 3]) {
|
||||
let me = self.player_entity();
|
||||
self.trigger("minecraft:slide_down_block", move |c, w| {
|
||||
let ctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &ctx, w) && adv::block(c, pos, w)
|
||||
});
|
||||
}
|
||||
|
||||
/// `TargetBlockTrigger`: signal and projectile conditions are evaluated in
|
||||
/// the shooter's context, including distance from the shooter.
|
||||
pub(super) fn trigger_target_hit(&mut self, projectile: &EntityView, strength: i32) {
|
||||
let me = self.player_entity();
|
||||
let projectile = projectile.clone();
|
||||
self.trigger("minecraft:target_hit", move |c, w| {
|
||||
let player = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let target = adv::Entities {
|
||||
this: Some(&projectile),
|
||||
..player
|
||||
};
|
||||
adv::conditions(&c["player"], &player, w)
|
||||
&& adv::range_i(&c["signal_strength"], i64::from(strength))
|
||||
&& adv::conditions(&c["projectile"], &target, w)
|
||||
});
|
||||
}
|
||||
|
||||
/// `ItemUsedOnLocationTrigger` (placed_block, item_used_on_block).
|
||||
pub(super) fn trigger_block(&mut self, name: &str, pos: [i32; 3], stack: &ItemStack) {
|
||||
let me = self.player_entity();
|
||||
@@ -1007,6 +1074,110 @@ impl Game<'_> {
|
||||
adv::conditions(&c["location"], &ctx, w)
|
||||
});
|
||||
}
|
||||
/// `LightningStrikeTrigger`: the bolt, and the mobs around it that it did
|
||||
/// not hit (`bystander`), each tested from this player's position.
|
||||
pub(super) fn trigger_lightning_strike(&mut self, bolt: &EntityView, viewers: &[EntityView]) {
|
||||
let me = self.player_entity();
|
||||
let (bolt, viewers) = (bolt.clone(), viewers.to_vec());
|
||||
self.trigger("minecraft:lightning_strike", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let bctx = adv::Entities {
|
||||
this: Some(&bolt),
|
||||
..pctx
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::conditions(&c["lightning"], &bctx, w)
|
||||
&& (c["bystander"].is_null()
|
||||
|| viewers.iter().any(|v| {
|
||||
adv::conditions(
|
||||
&c["bystander"],
|
||||
&adv::Entities {
|
||||
this: Some(v),
|
||||
..pctx
|
||||
},
|
||||
w,
|
||||
)
|
||||
}))
|
||||
});
|
||||
}
|
||||
/// `ChanneledLightningTrigger`: each victim condition matched by some
|
||||
/// mob the bolt hit, from the player who channelled it.
|
||||
pub(super) fn trigger_channeled_lightning(&mut self, victims: &[EntityView]) {
|
||||
let me = self.player_entity();
|
||||
let victims = victims.to_vec();
|
||||
self.trigger("minecraft:channeled_lightning", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& c["victims"].as_array().into_iter().flatten().all(|want| {
|
||||
victims.iter().any(|v| {
|
||||
adv::conditions(
|
||||
want,
|
||||
&adv::Entities {
|
||||
this: Some(v),
|
||||
..pctx
|
||||
},
|
||||
w,
|
||||
)
|
||||
})
|
||||
})
|
||||
});
|
||||
}
|
||||
/// `SpearMobsTrigger`: the living entities the spear stabbed in this use.
|
||||
pub(super) fn trigger_spear_mobs(&mut self, stabbed: usize) {
|
||||
let me = self.player_entity();
|
||||
self.trigger("minecraft:spear_mobs", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& c["count"].as_u64().is_none_or(|n| stabbed as u64 >= n)
|
||||
});
|
||||
}
|
||||
/// `BeeNestDestroyedTrigger`: the nest's block state, the tool that broke
|
||||
/// it and the bees left inside.
|
||||
pub(super) fn trigger_bee_nest_destroyed(
|
||||
&mut self,
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
tool: Option<&ItemStack>,
|
||||
bees: i64,
|
||||
) {
|
||||
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
|
||||
return;
|
||||
};
|
||||
let me = self.player_entity();
|
||||
let tool = tool.map(item_view).unwrap_or_default();
|
||||
self.trigger("minecraft:bee_nest_destroyed", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let state_ok = c["state"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.all(|(k, v)| definition.properties.get(k).map(String::as_str) == v.as_str());
|
||||
adv::id_matches(&c["blocks"], "block", &definition.name, w)
|
||||
&& state_ok
|
||||
&& adv::item(&c["item"], &tool, w)
|
||||
&& adv::range_i(&c["num_bees_inside"], bees)
|
||||
&& adv::conditions(&c["player"], &pctx, w)
|
||||
});
|
||||
}
|
||||
/// A trigger with only the player condition (slept_in_bed, started_riding).
|
||||
pub(super) fn trigger_player(&mut self, name: &str) {
|
||||
let me = self.player_entity();
|
||||
@@ -1020,6 +1191,55 @@ impl Game<'_> {
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
});
|
||||
}
|
||||
/// `RecipeCraftedTrigger`: the recipe and the stacks that went into it
|
||||
/// (`ServerPlayer.triggerRecipeCrafted`); an empty slot is air.
|
||||
pub(super) fn trigger_recipe_crafted(
|
||||
&mut self,
|
||||
recipe: &str,
|
||||
ingredients: &[Option<ItemStack>],
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let used: Vec<ItemView> = ingredients
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.as_ref().map_or_else(
|
||||
|| ItemView {
|
||||
id: "minecraft:air".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
item_view,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let recipe = recipe.to_owned();
|
||||
self.trigger("minecraft:recipe_crafted", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::id_matches(&c["recipes"], "recipe", &recipe, w)
|
||||
&& adv::recipe_ingredients(&c["ingredients"], &used, w)
|
||||
});
|
||||
}
|
||||
/// `LootTableTrigger`: a container's loot table unpacked for this player
|
||||
/// (`RandomizableContainer.unpackLootTable`).
|
||||
pub(super) fn trigger_loot_table(&mut self, table: &str) {
|
||||
let me = self.player_entity();
|
||||
let table = table.to_owned();
|
||||
self.trigger("minecraft:player_generates_container_loot", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::id_matches(&c["loot_tables"], "loot_table", &table, w)
|
||||
});
|
||||
}
|
||||
/// `BredAnimalsTrigger`.
|
||||
pub(super) fn trigger_bred(
|
||||
&mut self,
|
||||
|
||||
@@ -597,6 +597,9 @@ impl Game<'_> {
|
||||
Mode::Delete { world } => {
|
||||
json!({ "mode": "delete-world", "folder": world.folder, "name": world.name })
|
||||
}
|
||||
Mode::BackupBeforeUpgrade { world } => {
|
||||
json!({ "mode": "backup-before-upgrade", "folder": world.folder, "name": world.name })
|
||||
}
|
||||
Mode::Error { message, .. } => {
|
||||
json!({ "mode": "world-operation-error", "message": message })
|
||||
}
|
||||
|
||||
@@ -89,6 +89,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The UUID of the player an allay likes (`LIKED_PLAYER`).
|
||||
fn allay_liked_uuid(&self, i: usize) -> [i32; 4] {
|
||||
// An allay saved before its player was kept belongs to the host.
|
||||
self.mobs[i]
|
||||
.extra
|
||||
.owner
|
||||
.filter(|u| *u != [0, 0, 0, 1])
|
||||
.or(self.level.player_uuid)
|
||||
.unwrap_or([0, 0, 0, 1])
|
||||
}
|
||||
/// `AllayAi.getLikedPlayer`: the player the allay was given an item by,
|
||||
/// found by UUID among the host and the guests of the level (the window
|
||||
/// is a guest on the server thread), when they are not a spectator and
|
||||
@@ -98,13 +108,7 @@ impl Game<'_> {
|
||||
if !m.extra.tame {
|
||||
return None;
|
||||
}
|
||||
// An allay saved before its player was kept belongs to the host.
|
||||
let uuid = m
|
||||
.extra
|
||||
.owner
|
||||
.filter(|u| *u != [0, 0, 0, 1])
|
||||
.or(self.level.player_uuid)
|
||||
.unwrap_or([0, 0, 0, 1]);
|
||||
let uuid = self.allay_liked_uuid(i);
|
||||
let host = self
|
||||
.player_view()
|
||||
.filter(|p| !p.spectator && self.level.player_uuid.unwrap_or([0, 0, 0, 1]) == uuid)
|
||||
@@ -159,6 +163,19 @@ impl Game<'_> {
|
||||
];
|
||||
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt().max(1e-6);
|
||||
let v = [d[0] / len * 0.2, d[1] / len * 0.3, d[2] / len * 0.2];
|
||||
// AllayAi.throwItem: the liked player hears of the block below the deposit.
|
||||
let below = [
|
||||
target.x.floor() as i32,
|
||||
(target.y - 1.0).floor() as i32,
|
||||
target.z.floor() as i32,
|
||||
];
|
||||
let uuid = self.allay_liked_uuid(i);
|
||||
let stack = ItemStack::new(&item, 1);
|
||||
self.for_each_player(|g| {
|
||||
if g.acting_uuid() == uuid && !g.spectator() {
|
||||
g.trigger_block("minecraft:allay_drop_item_on_block", below, &stack);
|
||||
}
|
||||
});
|
||||
let m = &mut self.mobs[i];
|
||||
m.extra.ints[1] -= 1;
|
||||
m.extra.ints[3] = 60;
|
||||
|
||||
@@ -16,6 +16,21 @@ use horizon_core::{
|
||||
};
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc};
|
||||
|
||||
/// `EquipmentLayerRenderer.getColorForLayer`: a dyeable layer (one with a
|
||||
/// colour when undyed) takes the stack's `minecraft:dyed_color`, else that
|
||||
/// colour; any other layer stays white, dyed or not. The result is in the
|
||||
/// renderer's byte order (R and B swapped).
|
||||
fn layer_color(undyed: Option<u32>, stack: Option<&ItemStack>) -> u32 {
|
||||
let dyed = stack
|
||||
.and_then(|s| s.data())
|
||||
.and_then(|d| d.dyed_color)
|
||||
.map(|c| c as u32 & 0xFF_FFFF);
|
||||
undyed.map_or(0xFFFF_FFFF, |undyed| {
|
||||
let c = dyed.unwrap_or(undyed);
|
||||
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
|
||||
})
|
||||
}
|
||||
|
||||
/// Armor stand models and the humanoid layers of every equipment asset.
|
||||
pub(super) struct ArmorStandAssets {
|
||||
pub main: Option<Part>,
|
||||
@@ -138,7 +153,13 @@ impl Game<'_> {
|
||||
pub(super) fn worn_armor_layers(&self, m: &Mob) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
let e = &m.extra;
|
||||
self.armor_layers(
|
||||
[&e.head, &e.chest, &e.legs, &e.feet].map(|s| s.as_deref().map(|i| (i, None))),
|
||||
[
|
||||
(e.head.as_deref(), e.armor[0].as_ref()),
|
||||
(e.chest.as_deref(), e.armor[1].as_ref()),
|
||||
(e.legs.as_deref(), e.armor[2].as_ref()),
|
||||
(e.feet.as_deref(), e.armor[3].as_ref()),
|
||||
]
|
||||
.map(|(item, stack)| item.map(|i| (i, stack))),
|
||||
)
|
||||
}
|
||||
/// The layers of the armour items `worn` (with their stacks when known,
|
||||
@@ -179,9 +200,7 @@ impl Game<'_> {
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let color = undyed.map_or(0xFFFF_FFFF, |c| {
|
||||
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
|
||||
});
|
||||
let color = layer_color(*undyed, stack);
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], color));
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
if let Some(r) = stack
|
||||
@@ -207,6 +226,7 @@ impl Game<'_> {
|
||||
.armor
|
||||
.iter()
|
||||
.chain(guests.flat_map(|g| g.armor.iter()))
|
||||
.chain(self.mobs.iter().flat_map(|m| m.extra.armor.iter()))
|
||||
.zip(
|
||||
["helmet", "chestplate", "leggings", "boots"]
|
||||
.into_iter()
|
||||
@@ -491,6 +511,25 @@ impl Game<'_> {
|
||||
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
|
||||
return false;
|
||||
}
|
||||
// The stand's box and id, before the hit may remove it.
|
||||
let (area, id) = (self.armor_stands[i].bounds(), self.armor_stands[i].id);
|
||||
let (_, _, _, magic) = self.melee_damage("minecraft:armor_stand", true);
|
||||
let hurt = self.punch_armor_stand(i);
|
||||
if hurt {
|
||||
self.attack_effects(
|
||||
area,
|
||||
Some(id as i32),
|
||||
super::mace::Spared::Stand(id),
|
||||
magic.then_some(id as i32),
|
||||
);
|
||||
self.swing_arm();
|
||||
}
|
||||
hurt
|
||||
}
|
||||
/// `ArmorStand.hurtServer` for a player's attack (also a sweep's): a
|
||||
/// creative player breaks it, otherwise the first hit wobbles it and a
|
||||
/// second within five ticks breaks it.
|
||||
pub(super) fn punch_armor_stand(&mut self, i: usize) -> bool {
|
||||
let time = self.world.game_time;
|
||||
let at = self.armor_stands[i].position;
|
||||
let at = [at.x, at.y, at.z];
|
||||
@@ -695,9 +734,7 @@ impl Game<'_> {
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let color = undyed.map_or(0xFFFF_FFFF, |c| {
|
||||
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
|
||||
});
|
||||
let color = layer_color(*undyed, Some(stack));
|
||||
// ArmorStandRenderer's HumanoidArmorLayer: the armour glint
|
||||
// when `stack.hasFoil()`, as on a living entity.
|
||||
let item_glint = self
|
||||
@@ -993,3 +1030,21 @@ mod burn_drop_tests {
|
||||
assert_eq!(drops, vec![kept]);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod layer_color_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn a_dyed_stack_overrides_the_undyed_leather_colour() {
|
||||
let mut dyed = ItemStack::new("minecraft:leather_helmet", 1);
|
||||
dyed.data_mut().dyed_color = Some(0x1E_90FF);
|
||||
// Dyed blue, R and B swapped into the renderer's order, opaque.
|
||||
assert_eq!(layer_color(Some(0xA06540), Some(&dyed)), 0xFFFF_901E);
|
||||
// An undyed stack keeps the asset's brown.
|
||||
let plain = ItemStack::new("minecraft:leather_helmet", 1);
|
||||
assert_eq!(layer_color(Some(0xA06540), Some(&plain)), 0xFF40_65A0);
|
||||
// No dye and no undyed colour: white.
|
||||
assert_eq!(layer_color(None, Some(&plain)), 0xFFFF_FFFF);
|
||||
// A layer that isn't dyeable stays white even on a dyed stack.
|
||||
assert_eq!(layer_color(None, Some(&dyed)), 0xFFFF_FFFF);
|
||||
}
|
||||
}
|
||||
@@ -563,13 +563,21 @@ impl Game<'_> {
|
||||
};
|
||||
let time = (self.world.game_time.rem_euclid(40)) as f32 + alpha as f32;
|
||||
let scoping = self.scoping();
|
||||
let view = f64::from(self.settings.render_distance * 16);
|
||||
for (k, e) in &self.world.block_entities {
|
||||
let BlockEntity::Beacon(b) = e else { continue };
|
||||
// `getBeamSections` is empty while Levels is 0.
|
||||
if b.levels == 0 || b.sections.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let base = glam::DVec3::new(f64::from(k.0) + 0.5, f64::from(k.1), f64::from(k.2) + 0.5);
|
||||
let distance = (base - camera).truncate().length() as f32;
|
||||
// `BeaconRenderer.shouldRender`: horizontal distance under render distance × 16.
|
||||
let offset = base - camera;
|
||||
let horizontal = glam::DVec2::new(offset.x, offset.z).length();
|
||||
if horizontal >= view {
|
||||
continue;
|
||||
}
|
||||
let distance = horizontal as f32;
|
||||
let scale = if scoping {
|
||||
1.0
|
||||
} else {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
beehive::{self, Beehive, HiveHost, Release},
|
||||
beehive::{self, Beehive, BeehiveExt, HiveHost, Release},
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
mobs::{Mob, Who, bee},
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::Game;
|
||||
use horizon_client::{
|
||||
block_entity_anim::{
|
||||
BellEntity, ConduitServer, ConduitTarget, EndGateway, Shulker, ShulkerStatus, Ticker,
|
||||
closer_to_center_than,
|
||||
},
|
||||
block_entity_render::BlockAnim,
|
||||
collisions::{SceneCollisions, no_collision_empty},
|
||||
@@ -49,11 +50,16 @@ impl Game<'_> {
|
||||
}
|
||||
Some(Ticker::Bell) => {
|
||||
let gathered = self.bell_gather(pos);
|
||||
self.be_anims
|
||||
.bells
|
||||
.entry(pos)
|
||||
.or_default()
|
||||
.trigger_event(a, b, time, || gathered);
|
||||
let rung =
|
||||
self.be_anims
|
||||
.bells
|
||||
.entry(pos)
|
||||
.or_default()
|
||||
.trigger_event(a, b, time, || gathered);
|
||||
// The server's copy tells the living entities within 32 blocks.
|
||||
if rung && self.remote.is_none() {
|
||||
self.bell_heard(pos, time);
|
||||
}
|
||||
}
|
||||
Some(Ticker::Gateway) => {
|
||||
let initial = self.gateway_loaded(pos);
|
||||
@@ -200,6 +206,26 @@ impl Game<'_> {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `BellBlockEntity.updateEntities`: every gathered entity alive within 32
|
||||
/// blocks of the bell's centre gets `HEARD_BELL_TIME` (only villagers
|
||||
/// have a brain that reads it).
|
||||
fn bell_heard(&mut self, pos: [i32; 3], now: i64) {
|
||||
let nearby = self
|
||||
.be_anims
|
||||
.bells
|
||||
.get(&pos)
|
||||
.and_then(|b| b.nearby.clone())
|
||||
.unwrap_or_default();
|
||||
for m in &mut self.mobs {
|
||||
if m.alive()
|
||||
&& !m.removed
|
||||
&& nearby.contains(&u64::from(m.id))
|
||||
&& closer_to_center_than(pos, [m.position.x, m.position.y, m.position.z], 32.0)
|
||||
{
|
||||
horizon_client::mobs::villager_brain::heard_bell(m, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ShulkerBoxBlock.canOpen`: a closed box opens only when its lid has
|
||||
/// room to rise (`level.noCollision` of the half-open lid box).
|
||||
pub(super) fn shulker_can_open(&self, pos: [i32; 3]) -> bool {
|
||||
|
||||
@@ -9,6 +9,7 @@ use horizon_client::{
|
||||
Result,
|
||||
block_entities::{Pos, Ticking},
|
||||
containers::{BlockEntity, FurnaceKind, HOPPER_SLOTS},
|
||||
furnace::FurnaceCooking,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
@@ -23,12 +24,16 @@ impl Game<'_> {
|
||||
let states = self.ticking_states();
|
||||
self.world.block_entities.set_states(states);
|
||||
}
|
||||
self.forget_gone_hoppers();
|
||||
// Only lingering cooldowns are dropped; a hopper that ticks checks
|
||||
// its own block. Once a second is soon enough for the rest.
|
||||
if self.world.game_time % 20 == 0 {
|
||||
self.forget_gone_hoppers();
|
||||
}
|
||||
let ticking = self.block_ticking_area();
|
||||
let mut spawners = Vec::new();
|
||||
let mut trial_spawners = Vec::new();
|
||||
self.item_columns = Some(Default::default());
|
||||
for slot in self.world.block_entity_tickers() {
|
||||
for slot in self.world.block_entity_tickers().iter().copied() {
|
||||
let pos = slot.0;
|
||||
// Ticks before this one can remove it (`TickingBlockEntity.isRemoved`).
|
||||
if !self.world.block_entities.is_live(slot)
|
||||
|
||||
@@ -811,15 +811,19 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
/// `VehicleEntity.hurtServer` from the player's attack.
|
||||
pub(super) fn attack_boat(&mut self, damage: f32) -> bool {
|
||||
pub(super) fn attack_boat(&mut self) -> bool {
|
||||
let Some((i, d)) = self.boat_hit() else {
|
||||
return false;
|
||||
};
|
||||
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
|
||||
return false;
|
||||
}
|
||||
self.vehicle_attack_sounds();
|
||||
let area = self.boats[i].bounds();
|
||||
// Player.attack's full damage against the boat's entity type.
|
||||
let (kind, id) = (self.boats[i].entity_type(), self.boats[i].id as i32);
|
||||
let (damage, _, _, magic) = self.melee_damage(&kind, false);
|
||||
self.hurt_boat(i, damage, self.creative);
|
||||
self.vehicle_attack_sounds(area, magic.then_some(id));
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -76,9 +76,11 @@ fn view_pages(data: Option<&StackData>) -> Vec<Text> {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, page)| match tagged.then(|| &w.page_tags[i]) {
|
||||
Some(tag @ horizon_world::nbt::Tag::Compound(_)) => {
|
||||
horizon_client::entity_nbt::name_text(tag)
|
||||
}
|
||||
// A component object, or a component list (`ComponentSerialization`
|
||||
// takes both; servers behind ViaVersion send the list form).
|
||||
Some(
|
||||
tag @ (horizon_world::nbt::Tag::Compound(_) | horizon_world::nbt::Tag::List(_)),
|
||||
) => horizon_client::entity_nbt::name_text(tag),
|
||||
_ => Text::literal(page),
|
||||
})
|
||||
.collect()
|
||||
@@ -137,6 +139,21 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
ClickEvent::OpenUrl(url) => self.click_url(url),
|
||||
ClickEvent::RunCommand(command) => self.click_command(&command),
|
||||
ClickEvent::SuggestCommand(command) => {
|
||||
// defaultHandleClickEvent: the chat screen takes it over.
|
||||
if self.player.is_some() {
|
||||
self.close_book();
|
||||
self.open_chat(&command);
|
||||
}
|
||||
}
|
||||
ClickEvent::CopyToClipboard(value) => {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&value);
|
||||
}
|
||||
// BookViewScreen.handleClickEvent: the rest is
|
||||
// Screen.defaultHandleGameClickEvent's.
|
||||
ClickEvent::ShowDialog(dialog) => self.show_dialog_ref(&dialog),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,9 +142,11 @@ impl Game<'_> {
|
||||
let Some(mut tag) = spill::saved_entity(&self.world, pos) else {
|
||||
return;
|
||||
};
|
||||
if tag.get("LootTable").is_none() {
|
||||
let Some(horizon_world::nbt::Tag::String(table)) = tag.get("LootTable") else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let table = table.to_string_lossy();
|
||||
self.trigger_loot_table(&table);
|
||||
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
|
||||
decorated_pot::unpack_loot(
|
||||
&mut tag,
|
||||
|
||||
@@ -675,7 +675,7 @@ impl Game<'_> {
|
||||
// unless a client mod owns the command.
|
||||
if self.remote.is_some() {
|
||||
if let Some(command) = message.strip_prefix('/')
|
||||
&& self.mod_command(command)
|
||||
&& self.client_mod_command(command)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -684,7 +684,7 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(command) = message.strip_prefix('/') {
|
||||
// A client mod's command first, then a server mod's, as on a server.
|
||||
if !self.mod_command(command) && !self.local_server_mod_command(command) {
|
||||
if !self.client_mod_command(command) && !self.local_server_mod_command(command) {
|
||||
self.run_command(command);
|
||||
}
|
||||
} else {
|
||||
@@ -786,6 +786,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some(ClickEvent::RunCommand(command)) => self.click_command(&command),
|
||||
// Screen.defaultHandleGameClickEvent: the dialog opens over the
|
||||
// screen, and closes back to it.
|
||||
Some(ClickEvent::ShowDialog(dialog)) => self.show_dialog_ref(&dialog),
|
||||
// Screen.defaultHandleClickEvent: a page link means nothing in chat.
|
||||
Some(ClickEvent::ChangePage(_)) | None => {}
|
||||
}
|
||||
@@ -808,16 +811,19 @@ impl Game<'_> {
|
||||
&self,
|
||||
url: &str,
|
||||
size: (f32, f32),
|
||||
) -> horizon_client::confirm_screen::Confirm {
|
||||
) -> horizon_client::confirm_screen::Choice {
|
||||
let warning = Text::translate("chat.link.warning", &[]).with_color(0xFF_CCCC);
|
||||
let message = Text::literal(url)
|
||||
.append(Text::literal("\n"))
|
||||
.append(warning);
|
||||
horizon_client::confirm_screen::Confirm::new(
|
||||
// ConfirmLinkScreen.addButtons: yes, copy and no, 100 wide each.
|
||||
horizon_client::confirm_screen::Choice::new(
|
||||
&self.gui.atlas.font,
|
||||
size,
|
||||
&Text::translate("chat.link.confirm", &[]),
|
||||
&message,
|
||||
3,
|
||||
100.0,
|
||||
)
|
||||
}
|
||||
pub(super) fn draw_link_prompt(&self, batch: &mut HudBatch) {
|
||||
@@ -827,12 +833,13 @@ impl Game<'_> {
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
let (yes, no) = (
|
||||
let labels = [
|
||||
Text::translate("gui.yes", &[]),
|
||||
Text::translate("chat.copy", &[]),
|
||||
Text::translate("gui.no", &[]),
|
||||
);
|
||||
];
|
||||
self.link_prompt_layout(url, (w, h))
|
||||
.draw(batch, atlas, self.cursor, [&yes, &no]);
|
||||
.draw(batch, atlas, self.cursor, &labels);
|
||||
}
|
||||
/// The prompt's answer: the link opens on yes; either way the chat is back.
|
||||
pub(super) fn answer_link_prompt(&mut self, yes: bool) {
|
||||
@@ -843,15 +850,20 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
pub(super) fn link_prompt_click(&mut self) {
|
||||
let Some(url) = &self.link_prompt else {
|
||||
let Some(url) = self.link_prompt.clone() else {
|
||||
return;
|
||||
};
|
||||
let answer = self
|
||||
.link_prompt_layout(url, self.gui_size())
|
||||
.answer_at(self.cursor);
|
||||
if let Some(yes) = answer {
|
||||
let pressed = self
|
||||
.link_prompt_layout(&url, self.gui_size())
|
||||
.pressed_at(self.cursor);
|
||||
if let Some(button) = pressed {
|
||||
self.audio.play_button_click();
|
||||
self.answer_link_prompt(yes);
|
||||
if button == LINK_COPY {
|
||||
// ConfirmLinkScreen.copyToClipboard, then the callback's no.
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&url);
|
||||
}
|
||||
self.answer_link_prompt(button == LINK_YES);
|
||||
}
|
||||
}
|
||||
/// `Screen.clickCommandAction`: run a clicked command as the player
|
||||
@@ -859,9 +871,9 @@ impl Game<'_> {
|
||||
/// first, as `ClientPacketListener.sendUnattendedCommand` lets client
|
||||
/// commands take it. (Vanilla asks first when the command fails to
|
||||
/// parse; here the server answers it.)
|
||||
fn click_command(&mut self, command: &str) {
|
||||
pub(super) fn click_command(&mut self, command: &str) {
|
||||
let command = command.strip_prefix('/').unwrap_or(command);
|
||||
if self.mod_command(command) {
|
||||
if self.client_mod_command(command) {
|
||||
return;
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
@@ -992,6 +1004,7 @@ impl Game<'_> {
|
||||
spawn_mobs: r.bool("spawn_mobs"),
|
||||
spawn_monsters: r.bool("spawn_monsters"),
|
||||
fire_spread_radius_around_player: r.int("fire_spread_radius_around_player"),
|
||||
spread_vines: r.bool("spread_vines"),
|
||||
}
|
||||
}
|
||||
/// Apply rules mirrored in other state (natural regeneration, difficulty).
|
||||
@@ -1016,10 +1029,7 @@ impl Game<'_> {
|
||||
self.ui.rules = GameRules::load(&self.world_root);
|
||||
self.ui.clock = WorldClock::default();
|
||||
// PrimaryLevelData: the difficulty comes back from level.dat's difficulty_settings.
|
||||
self.ui.difficulty = horizon_client::title::DIFFICULTIES
|
||||
.iter()
|
||||
.position(|d| *d == self.level.difficulty)
|
||||
.map_or(2, |i| i as u8);
|
||||
self.ui.difficulty = self.level.difficulty_id();
|
||||
if let Ok(bytes) = std::fs::read(self.world_root.join("data/minecraft/world_clocks.dat"))
|
||||
&& let Ok(raw) = horizon_world::compression::Compression::Gzip.decode(&bytes, 1 << 20)
|
||||
&& let Ok((root, _)) =
|
||||
@@ -1210,3 +1220,7 @@ pub fn screenshot_name(dir: &std::path::Path) -> String {
|
||||
fn owner_permission(allow_commands: bool) -> u8 {
|
||||
if allow_commands { 4 } else { 0 }
|
||||
}
|
||||
|
||||
/// `ConfirmLinkScreen`'s buttons, left to right: yes, copy, no.
|
||||
const LINK_YES: usize = 0;
|
||||
const LINK_COPY: usize = 1;
|
||||
@@ -39,6 +39,11 @@ pub(super) struct CombatTracker {
|
||||
pub(super) last_hurt_mob: Option<(u32, i64)>,
|
||||
/// The mob whose projectile is hitting the player now.
|
||||
pub(super) projectile_owner: Option<u32>,
|
||||
/// The display name of the projectile hitting the player now: the
|
||||
/// `DamageSource.directEntity` the death message names when no owner is
|
||||
/// known (`AbstractArrow.onHitEntity` makes an ownerless arrow its own
|
||||
/// cause).
|
||||
pub(super) projectile_direct: Option<Text>,
|
||||
/// The projectile hitting the player now is another player's that
|
||||
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
|
||||
pub(super) refuse_player_hit: bool,
|
||||
@@ -46,6 +51,9 @@ pub(super) struct CombatTracker {
|
||||
/// stand and the seconds their weapon disables a shield for
|
||||
/// (`getSecondsToDisableBlocking`).
|
||||
pub(super) player_attacker: Option<([f64; 3], f32)>,
|
||||
/// While set, a sweep attack's reach to the other players is kept here
|
||||
/// instead of landing, for the caller that holds the LAN host.
|
||||
pub(super) sweeps: Option<Vec<super::lan_pvp::Sweep>>,
|
||||
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
|
||||
/// fire decides whom the blast may hurt.
|
||||
pub(super) explosion_source: Option<u32>,
|
||||
@@ -208,6 +216,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.combat.pending.clear();
|
||||
self.combat.pending_direct.clear();
|
||||
self.combat.projectile_direct = None;
|
||||
self.combat.fall_before = self.player.as_ref().map_or(0.0, |p| p.fall_distance);
|
||||
}
|
||||
/// `FallLocation.getCurrentFallLocation` and the fall distance so far.
|
||||
|
||||
@@ -91,7 +91,7 @@ fn format_double(v: f64) -> String {
|
||||
impl Game<'_> {
|
||||
/// Commands.performCommand for a line without its slash.
|
||||
pub(super) fn run_command(&mut self, command: &str) -> bool {
|
||||
if self.mod_command(command) {
|
||||
if self.client_mod_command(command) {
|
||||
return true;
|
||||
}
|
||||
let d = vanilla::game_dispatcher();
|
||||
@@ -1851,7 +1851,7 @@ impl Game<'_> {
|
||||
if self.ui.difficulty == 0 && t.category == "monster" {
|
||||
return Err(err("commands.summon.failed.peaceful", &[]));
|
||||
}
|
||||
self.spawn_mob(&short, pos, true);
|
||||
self.spawn_mob_spawned(&short, pos);
|
||||
name = Text::translate(&format!("entity.minecraft.{short}"), &[]);
|
||||
} else {
|
||||
match short.as_str() {
|
||||
@@ -2085,6 +2085,49 @@ impl Game<'_> {
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
true
|
||||
}
|
||||
/// `RandomizableContainer.unpackLootTable` for a player who opens a
|
||||
/// container: a roll the container still holds fires
|
||||
/// `player_generates_container_loot` for its table, and from then on the
|
||||
/// container saves its items, not the table.
|
||||
pub(super) fn unpack_opened_loot(&mut self, key: (i32, i32, i32), name: &str) {
|
||||
let Some(rolled) = self
|
||||
.world
|
||||
.chunks
|
||||
.get(&(key.0 >> 4, key.2 >> 4))
|
||||
.and_then(|c| c.saved.as_ref())
|
||||
.and_then(|s| s.rolled_loot.iter().find(|r| r.pos == key))
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The roll is still there only while the container holds exactly its items.
|
||||
let still_rolled = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_mut(&key)
|
||||
.is_some_and(|entity| {
|
||||
let mut tag = horizon_client::save::encode_block_entity(key, entity, name);
|
||||
match &mut tag {
|
||||
horizon_world::nbt::Tag::Compound(map) => rolled.restore(map),
|
||||
_ => false,
|
||||
}
|
||||
});
|
||||
if still_rolled {
|
||||
self.trigger_loot_table(&rolled.table);
|
||||
}
|
||||
let Some(chunk) = self.world.chunks.get_mut(&(key.0 >> 4, key.2 >> 4)) else {
|
||||
return;
|
||||
};
|
||||
let chunk = std::sync::Arc::make_mut(chunk);
|
||||
let mut extras = chunk
|
||||
.saved
|
||||
.take()
|
||||
.map(std::sync::Arc::unwrap_or_clone)
|
||||
.unwrap_or_default();
|
||||
extras.rolled_loot.retain(|r| r.pos != key);
|
||||
chunk.saved = Some(std::sync::Arc::new(extras));
|
||||
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
|
||||
}
|
||||
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
|
||||
/// container (`setblock`, `data`): an empty container with a `LootTable`
|
||||
/// is rolled now, as a loaded chunk's are (`save::roll_loaded_loot`), and
|
||||
@@ -2573,6 +2616,9 @@ impl Game<'_> {
|
||||
)));
|
||||
}
|
||||
self.ui.difficulty = i;
|
||||
// The level data holds the world's difficulty: it survives the
|
||||
// reopen, so it follows the command.
|
||||
self.level.set_difficulty(i);
|
||||
self.sync_rules();
|
||||
if i == 0 {
|
||||
// Peaceful removes hostile mobs.
|
||||
|
||||
@@ -8,6 +8,10 @@ use horizon_client::{
|
||||
command::CommandError,
|
||||
text::{self, Style, Text},
|
||||
};
|
||||
use horizon_protocol::packets::{
|
||||
Empty,
|
||||
play::{self, Clientbound as C, DialogHolder},
|
||||
};
|
||||
use std::{collections::HashMap, time::Instant};
|
||||
|
||||
/// Waypoint.Icon: the locator bar style and colour of a transmitter.
|
||||
@@ -43,8 +47,6 @@ pub(in crate::game) struct CommandExtras {
|
||||
pub jfr: Option<(Instant, u64, String)>,
|
||||
/// Waypoint icons changed by `/waypoint modify`.
|
||||
pub waypoint_icons: HashMap<Target, WaypointIcon>,
|
||||
/// The dialog `/dialog show` opened (its id or inline definition).
|
||||
pub dialog: Option<String>,
|
||||
/// `/datapack disable vanilla` took the built-in pack out of the selection.
|
||||
pub vanilla_disabled: bool,
|
||||
/// The waypoint manager's tracked transmitters (None until first used).
|
||||
@@ -430,9 +432,9 @@ impl Game<'_> {
|
||||
let Some(Arg::Resource(dialog)) = ctx.get("dialog").cloned() else {
|
||||
return Ok(0);
|
||||
};
|
||||
self.open_dialog(&dialog)?;
|
||||
self.show_dialog_to(&targets, &dialog);
|
||||
} else {
|
||||
self.close_dialog();
|
||||
self.clear_dialog_to(&targets);
|
||||
}
|
||||
let (single, multiple) = if id == "dialog.show" {
|
||||
(
|
||||
@@ -1039,15 +1041,77 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ServerPlayer.openDialog`: the dialog screen for a dialog id or inline
|
||||
/// definition.
|
||||
pub(in crate::game) fn open_dialog(&mut self, dialog: &str) -> R<()> {
|
||||
self.cmds.dialog = Some(dialog.to_owned());
|
||||
Ok(())
|
||||
/// `ServerPlayer.openDialog` for every target of `/dialog show`: a guest
|
||||
/// is sent `ClientboundShowDialogPacket`, the window's own player opens
|
||||
/// the dialog on its screen.
|
||||
fn show_dialog_to(&mut self, targets: &[Target], dialog: &str) {
|
||||
let Some(holder) = self.dialog_holder(dialog) else {
|
||||
return;
|
||||
};
|
||||
let packet = C::ShowDialog(play::ShowDialog {
|
||||
dialog: holder.clone(),
|
||||
});
|
||||
for &t in targets {
|
||||
match t {
|
||||
Target::Player if self.lan_acting.is_some() => {
|
||||
self.guest_packets.push(packet.clone());
|
||||
}
|
||||
Target::Player => self.show_play_dialog(&holder),
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ClientboundClearDialogPacket`: close the dialog screen.
|
||||
pub(in crate::game) fn close_dialog(&mut self) {
|
||||
self.cmds.dialog = None;
|
||||
/// `ServerPlayer.closeDialog` for every target of `/dialog clear`.
|
||||
fn clear_dialog_to(&mut self, targets: &[Target]) {
|
||||
let packet = C::ClearDialog(Empty);
|
||||
for &t in targets {
|
||||
match t {
|
||||
Target::Player if self.lan_acting.is_some() => {
|
||||
self.guest_packets.push(packet.clone());
|
||||
}
|
||||
Target::Player => self.clear_dialog(),
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.openDialog`'s dialog: an entry of the `minecraft:dialog`
|
||||
/// registry, or the dialog an inline `ResourceOrIdArgument` carries.
|
||||
fn dialog_holder(&self, dialog: &str) -> Option<DialogHolder> {
|
||||
let text = dialog.trim();
|
||||
if text.starts_with('{') {
|
||||
return horizon_client::command::snbt::parse_fully(text)
|
||||
.ok()
|
||||
.map(DialogHolder::Direct);
|
||||
}
|
||||
// A registry id, written with or without its namespace.
|
||||
let id = if text.contains(':') {
|
||||
text.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{text}")
|
||||
};
|
||||
let index = self
|
||||
.ui
|
||||
.registries
|
||||
.ids("minecraft:dialog")
|
||||
.iter()
|
||||
.position(|name| *name == id);
|
||||
match index {
|
||||
Some(i) => Some(DialogHolder::Registry(i as i32)),
|
||||
None => {
|
||||
log::warn!("Horizon: no dialog {id} in the registry");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -227,7 +227,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// Whether the level in place has a running task, which the server pumps
|
||||
/// between its ticks (`pump_pregen`).
|
||||
/// between its ticks (`pump_chunks`).
|
||||
pub(in crate::game) fn pregen_runs_here(&self) -> bool {
|
||||
self.cmds
|
||||
.pregen
|
||||
@@ -243,11 +243,19 @@ impl Game<'_> {
|
||||
/// Keep the workers fed until `until`: collect what they finished and
|
||||
/// hand them the next jobs as soon as each one is done, rather than once
|
||||
/// a tick.
|
||||
pub(in crate::game) fn pump_pregen(&mut self, until: Instant) {
|
||||
pub(in crate::game) fn pump_chunks(&mut self, until: Instant, paused: bool) {
|
||||
// Force-loaded chunks too: decorating in vanilla's order, a chunk
|
||||
// waits for its neighbours far more often than once a tick allows.
|
||||
// And the players' chunks while they load, as vanilla's server runs
|
||||
// its chunk tasks while it waits for the next tick
|
||||
// (`MinecraftServer.waitUntilNextTick`): once a tick, a few jobs per
|
||||
// worker (`schedule_generation`) capped joining a world at about 120
|
||||
// columns a second with two workers, the workers idle most of the
|
||||
// time. Not while a respawn search waits for a chunk: its area is
|
||||
// only asked for by the tick's own `update_world`.
|
||||
let players = !paused && self.spawn_wait.is_empty();
|
||||
while self.pregen_runs_here()
|
||||
|| (!self.streaming.forced.is_empty() && self.streaming.busy())
|
||||
|| ((players || !self.streaming.forced.is_empty()) && self.streaming.busy())
|
||||
{
|
||||
let now = Instant::now();
|
||||
if now >= until {
|
||||
|
||||
@@ -343,8 +343,10 @@ impl Game<'_> {
|
||||
// hook does.
|
||||
let action = arg(2)?;
|
||||
self.mod_key(physical, action != "up", action == "repeat");
|
||||
self.native_key_event(physical, action != "up", action == "repeat");
|
||||
if action == "tap" {
|
||||
self.mod_key(physical, false, false);
|
||||
self.native_key_event(physical, false, false);
|
||||
}
|
||||
// Text screens consume raw presses; releases follow key binds.
|
||||
let typing = matches!(self.screen, Screen::Chat | Screen::Options);
|
||||
|
||||
@@ -277,6 +277,19 @@ impl Game<'_> {
|
||||
pub(super) fn spawn_mob(&mut self, kind: &str, at: [f64; 3], persistent: bool) -> Option<u32> {
|
||||
self.spawn_mob_for(kind, at, persistent, false)
|
||||
}
|
||||
/// `SummonCommand` and `SpawnEggItem`: vanilla spawns the mob, but it
|
||||
/// never calls `Mob.setPersistenceRequired` for it, so the mob is not
|
||||
/// persistent: the despawn rule holds it like any other mob, and only
|
||||
/// its NBT can make it persistent.
|
||||
pub(super) fn spawn_mob_spawned(&mut self, kind: &str, at: [f64; 3]) -> Option<u32> {
|
||||
// The spawn is not a natural one (no jockeys, the patrol leader roll
|
||||
// stays), but the mob is not persistent.
|
||||
let id = self.spawn_mob_for(kind, at, true, false)?;
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.persistent = false;
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
/// `spawn_mob` for a patrol or raid (`EntitySpawnReason.PATROL` / `EVENT`),
|
||||
/// which never rolls a patrol leader.
|
||||
pub(super) fn spawn_mob_for(
|
||||
@@ -334,7 +347,7 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let id = self
|
||||
.spawn_mob(kind, at, true)
|
||||
.spawn_mob_spawned(kind, at)
|
||||
.ok_or_else(|| format!("unknown or unsupported mob {kind}"))?;
|
||||
if baby && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.extra.age = if m.species() == Species::Sniffer {
|
||||
@@ -456,6 +469,7 @@ impl Game<'_> {
|
||||
biomes: self.res.biomes(),
|
||||
mob_griefing: self.ui.rules.bool("mob_griefing"),
|
||||
mob_drops: self.ui.rules.bool("mob_drops"),
|
||||
max_entity_cramming: self.ui.rules.int("max_entity_cramming"),
|
||||
end: self.dim == Dim::End,
|
||||
world_seed: self.level.seed,
|
||||
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
|
||||
@@ -732,6 +746,7 @@ impl Game<'_> {
|
||||
self.guardian_bubbles();
|
||||
self.villager_reputation_tick();
|
||||
self.merchant_tick();
|
||||
self.villager_item_sense();
|
||||
self.villager_pickups();
|
||||
for m in dead {
|
||||
self.mob_died(&m);
|
||||
@@ -789,14 +804,30 @@ impl Game<'_> {
|
||||
// takes the experience (before the loot is dropped). It takes what
|
||||
// would drop, whoever killed the mob.
|
||||
self.sculk_mob_died(m);
|
||||
let mut catalyst_xp = 0;
|
||||
let consumed =
|
||||
self.catalyst_takes_experience([m.position.x, m.position.y, m.position.z], |_| {
|
||||
if horizon_client::mobs::drops::drops_experience(&dying) {
|
||||
catalyst_xp = if horizon_client::mobs::drops::drops_experience(&dying) {
|
||||
horizon_client::mobs::drops::base_experience(&mut dying).max(0) as u32
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
catalyst_xp
|
||||
});
|
||||
// CatalystListener: with experience to take, the player who last hurt
|
||||
// the mob gets KILL_MOB_NEAR_SCULK_CATALYST.
|
||||
if consumed && catalyst_xp > 0 {
|
||||
let who = match killer {
|
||||
Some(Some(conn)) => Some(super::commands::Target::Guest(conn)),
|
||||
Some(None) if self.lan_acting.is_none() => Some(super::commands::Target::Player),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(who) = who {
|
||||
self.for_player(who, |g| {
|
||||
g.trigger_player("minecraft:kill_mob_near_sculk_catalyst")
|
||||
});
|
||||
}
|
||||
}
|
||||
if by_player {
|
||||
// A guest's kill is theirs: their statistics, scores and
|
||||
// advancements (LivingEntity.die credits lastHurtByPlayer).
|
||||
@@ -856,7 +887,42 @@ impl Game<'_> {
|
||||
self.stats.custom("mob_kills", 1);
|
||||
self.score_player_killed(super::commands::Target::Mob(m.id));
|
||||
let view = self.mob_entity(m);
|
||||
self.trigger_entity("minecraft:player_killed_entity", "entity", &view);
|
||||
let blow = m.last_damage.map(|(d, _)| self.damage_view(d));
|
||||
self.trigger_killed(&view, blow.as_ref());
|
||||
}
|
||||
/// A mob's damage source as advancement predicates see it: its type, the
|
||||
/// causing entity, and the projectile when one dealt it.
|
||||
fn damage_view(&self, d: Damage) -> horizon_client::advancements::DamageView {
|
||||
let source = match d.attacker {
|
||||
Some(Who::Player) => Some(self.player_entity()),
|
||||
Some(Who::Mob(id)) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map(|o| self.mob_entity(o)),
|
||||
None => None,
|
||||
};
|
||||
let projectile = match d.kind {
|
||||
DamageKind::Arrow => Some("minecraft:arrow"),
|
||||
DamageKind::Fireball => Some("minecraft:fireball"),
|
||||
_ => None,
|
||||
};
|
||||
let direct = match projectile {
|
||||
Some(kind) => Some(horizon_client::advancements::EntityView {
|
||||
kind: kind.into(),
|
||||
position: d.from.unwrap_or_default(),
|
||||
..Default::default()
|
||||
}),
|
||||
None => source.clone(),
|
||||
};
|
||||
horizon_client::advancements::DamageView {
|
||||
kind: damage_type(d.kind, matches!(d.attacker, Some(Who::Player))).to_owned(),
|
||||
// DamageSource.isDirect: the causing entity is the direct one.
|
||||
is_direct: projectile.is_none(),
|
||||
direct,
|
||||
source,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
/// `dropAllDeathLoot` through `mobs::drops`: the entity loot table from the
|
||||
/// world's random sequence, equipment by drop chance, species extras and
|
||||
@@ -1193,14 +1259,18 @@ impl Game<'_> {
|
||||
owner,
|
||||
} => self.add_fangs(at, yaw, delay, owner),
|
||||
MobEvent::Shoot(..) => {}
|
||||
MobEvent::ExplosionSource { mob, charged } => {
|
||||
MobEvent::ExplosionSource { mob, charged, name } => {
|
||||
self.combat.explosion_source = Some(mob);
|
||||
// The creeper causes the blast's damage, but it is out of
|
||||
// the mob list already: its name comes with the event.
|
||||
self.note_named_attacker("minecraft:explosion", mob, name);
|
||||
if charged {
|
||||
self.combat.charged_exploders.push(mob);
|
||||
}
|
||||
}
|
||||
MobEvent::Explode(at, radius, species) => {
|
||||
// The exploding mob causes the damage (Creeper.explodeCreeper).
|
||||
let noted = self.combat.explosion_source.is_some();
|
||||
let source_mob = self.combat.explosion_source.or_else(|| {
|
||||
self.mobs
|
||||
.iter()
|
||||
@@ -1211,7 +1281,7 @@ impl Game<'_> {
|
||||
.map(|m| m.id)
|
||||
.next()
|
||||
});
|
||||
if let Some(id) = source_mob {
|
||||
if !noted && let Some(id) = source_mob {
|
||||
self.note_attacker("minecraft:explosion", id);
|
||||
}
|
||||
self.combat.explosion_source = source_mob;
|
||||
@@ -1279,8 +1349,10 @@ impl Game<'_> {
|
||||
MobEvent::Hiss { mob } => {
|
||||
if let Some(m) = self.mobs.iter().find(|m| m.id == mob) {
|
||||
let at = m.center();
|
||||
let sound = horizon_client::mobs::finalize::variant_sound(m, "hiss")
|
||||
.unwrap_or("entity.cat.hiss");
|
||||
self.audio
|
||||
.play("minecraft:entity.cat.hiss", Some(at), 1.0, 1.0);
|
||||
.play(&format!("minecraft:{sound}"), Some(at), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
MobEvent::Tamed { .. } | MobEvent::Buck { .. } => self.mount_event(&e),
|
||||
@@ -1296,6 +1368,26 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
},
|
||||
MobEvent::Bonemeal { pos } => {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
if self.with_level(|b, level, ticks| b.grow_crop(level, ticks, at)) {
|
||||
self.level_events
|
||||
.push(horizon_world::level::LevelEvent::LevelEvent {
|
||||
id: 1505,
|
||||
pos: at,
|
||||
data: 15,
|
||||
});
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
}
|
||||
MobEvent::RingBell { bell } => {
|
||||
let pos = BlockPos::new(bell[0], bell[1], bell[2]);
|
||||
let rang = self
|
||||
.with_level(|b, level, ticks| b.bell_attempt_to_ring(level, ticks, pos));
|
||||
if rang {
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
}
|
||||
MobEvent::ChestOpen { chest, open } => self.golem_chest_open(chest, open),
|
||||
MobEvent::ChestSlot { chest, slot, stack } => {
|
||||
self.golem_chest_slot(chest, slot, stack)
|
||||
@@ -1857,6 +1949,9 @@ impl Game<'_> {
|
||||
&& let Some(child) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
child.extra.age = -24000;
|
||||
// getBreedOffspring creates the baby without finalizeSpawn: it keeps
|
||||
// the default (`classic`) sound variant.
|
||||
child.extra.sound_variant = None;
|
||||
// A llama's baby was drawn in the parent's tick, with the
|
||||
// experience.
|
||||
if let Some(o) = llama {
|
||||
@@ -1952,8 +2047,12 @@ impl Game<'_> {
|
||||
}
|
||||
// Offspring inherit a parent's variant (pigs/cows/chickens) or mixed wool.
|
||||
child.extra.variant = p.extra.variant;
|
||||
// Fox.getBreedOffspring: either parent's variant.
|
||||
if p.species() == Species::Fox && !coin {
|
||||
// Fox, Pig, Cow and Chicken.getBreedOffspring: either parent's variant.
|
||||
if matches!(
|
||||
p.species(),
|
||||
Species::Fox | Species::Pig | Species::Cow | Species::Chicken
|
||||
) && !coin
|
||||
{
|
||||
child.extra.variant = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant);
|
||||
}
|
||||
if p.species() == Species::Sheep {
|
||||
@@ -2083,6 +2182,10 @@ impl Game<'_> {
|
||||
if let Some(a) = attacker.or(owner) {
|
||||
self.note_attacker(damage_type, a);
|
||||
}
|
||||
// The projectile is the direct entity: named when it has no owner.
|
||||
if let Some(name) = self.combat.projectile_direct.take() {
|
||||
self.note_direct_entity(damage_type, name);
|
||||
}
|
||||
// DamageSource.scalesWithDifficulty: a mob (or its projectile's
|
||||
// owner) caused the hit, not a player.
|
||||
let living_cause = attacker.or(owner).is_some();
|
||||
@@ -2559,6 +2662,7 @@ impl Game<'_> {
|
||||
let name = match sound {
|
||||
ArrowSound::Splash => "minecraft:entity.generic.splash",
|
||||
ArrowSound::HitGround => "minecraft:entity.arrow.hit",
|
||||
ArrowSound::CrossbowHit => "minecraft:item.crossbow.hit",
|
||||
ArrowSound::Extinguish => "minecraft:entity.generic.extinguish_fire",
|
||||
ArrowSound::TridentHitGround => "minecraft:item.trident.hit_ground",
|
||||
ArrowSound::TridentHit => "minecraft:item.trident.hit",
|
||||
@@ -2666,10 +2770,25 @@ impl Game<'_> {
|
||||
let hit = self.with_level(|b, level, ticks| {
|
||||
b.target_projectile_hit(level, ticks, block, location, face, arrow)
|
||||
});
|
||||
if hit.is_some() {
|
||||
if let Some(strength) = hit {
|
||||
let _ = self.process_level_events();
|
||||
if p.owner == Some(Who::Player) {
|
||||
if p.owner == Some(Who::Player) && (p.guest.is_none() || p.guest == self.lan_acting) {
|
||||
self.stats.custom("target_hit", 1);
|
||||
let kind = if p
|
||||
.arrow
|
||||
.as_ref()
|
||||
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow")
|
||||
{
|
||||
"minecraft:spectral_arrow"
|
||||
} else {
|
||||
p.kind.id()
|
||||
};
|
||||
let projectile = horizon_client::advancements::EntityView {
|
||||
kind: kind.to_owned(),
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
..Default::default()
|
||||
};
|
||||
self.trigger_target_hit(&projectile, strength);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2685,6 +2804,26 @@ impl Game<'_> {
|
||||
Some(Who::Mob(id)) => Some(id),
|
||||
_ => None,
|
||||
};
|
||||
self.combat.projectile_direct = match hit {
|
||||
Hit::Entity {
|
||||
who: Who::Player, ..
|
||||
} => {
|
||||
let spectral = p
|
||||
.arrow
|
||||
.as_ref()
|
||||
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow");
|
||||
let id = if spectral {
|
||||
"minecraft:spectral_arrow"
|
||||
} else {
|
||||
p.kind.id()
|
||||
};
|
||||
Some(Text::translate(
|
||||
&format!("entity.{}", id.replace(':', ".")),
|
||||
&[],
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let at = match hit {
|
||||
Hit::Entity { at, .. } | Hit::Block { at, .. } => at,
|
||||
Hit::None => return Ok(()),
|
||||
@@ -2885,7 +3024,56 @@ impl Game<'_> {
|
||||
.find(|m| m.id == id)
|
||||
.map_or(0.0, |m| m.attrs.knockback_resistance),
|
||||
};
|
||||
let push = f64::from(p.bonus.punch) * 0.6 * (1.0 - resistance).max(0.0);
|
||||
let knockback = if !hurt {
|
||||
0.0
|
||||
} else if let Some(weapon) =
|
||||
p.arrow.as_ref().and_then(|a| a.fired_from_weapon.as_ref())
|
||||
{
|
||||
let victim = match who {
|
||||
Who::Player => Some("minecraft:player"),
|
||||
Who::Mob(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| m.kind.id.as_str()),
|
||||
};
|
||||
let owner = match p.owner {
|
||||
Some(Who::Player) => Some("minecraft:player"),
|
||||
Some(Who::Mob(id)) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| m.kind.id.as_str()),
|
||||
None => None,
|
||||
};
|
||||
let arrow_type = if p
|
||||
.arrow
|
||||
.as_ref()
|
||||
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow")
|
||||
{
|
||||
"minecraft:spectral_arrow"
|
||||
} else {
|
||||
"minecraft:arrow"
|
||||
};
|
||||
let ctx = horizon_client::enchant::EffectContext {
|
||||
this: victim,
|
||||
attacker: owner,
|
||||
direct_attacker: Some(arrow_type),
|
||||
damage_type: Some("minecraft:arrow"),
|
||||
items: Some(self.res.items()),
|
||||
..Default::default()
|
||||
};
|
||||
self.res.stations().enchantments.modify_value(
|
||||
Some(weapon),
|
||||
"minecraft:knockback",
|
||||
&ctx,
|
||||
&mut self.world.random,
|
||||
0.0,
|
||||
)
|
||||
} else {
|
||||
p.bonus.punch as f32
|
||||
};
|
||||
let push = f64::from(knockback) * 0.6 * (1.0 - resistance).max(0.0);
|
||||
if hurt && push > 0.0 && flight > 1e-6 {
|
||||
let k = push / flight;
|
||||
self.handle_mob_events(vec![MobEvent::Push {
|
||||
@@ -3254,7 +3442,16 @@ impl Game<'_> {
|
||||
}
|
||||
{
|
||||
let item = self.held().map(|h| h.item.to_string()).unwrap_or_default();
|
||||
let used = self.held().cloned();
|
||||
if self.interact_leash(index, &item) {
|
||||
if let Some(stack) = &used {
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_item_entity(
|
||||
"minecraft:player_interacted_with_entity",
|
||||
stack,
|
||||
&target,
|
||||
);
|
||||
}
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -3828,6 +4025,11 @@ impl Game<'_> {
|
||||
if !handled {
|
||||
return Ok(false);
|
||||
}
|
||||
// PlayerInteractTrigger: the item used on the mob, once the interaction took.
|
||||
if let Some(stack) = &held {
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
|
||||
}
|
||||
// Turtle.ageBoundaryReached: food that finishes a baby's growth.
|
||||
if baby_turtle && !self.mobs[index].is_baby() {
|
||||
self.handle_mob_events(vec![MobEvent::GiftLoot {
|
||||
@@ -3871,6 +4073,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if let Some(stack) = armor_drop {
|
||||
// Mob.shearItem: PLAYER_SHEARED_EQUIPMENT with the armor and the wolf.
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_item_entity("minecraft:player_sheared_equipment", &stack, &target);
|
||||
self.spawn_item(stack, [center[0], center[1] + 0.5, center[2]], [0.0; 3], 10);
|
||||
}
|
||||
for (item, count) in drops {
|
||||
@@ -3999,7 +4204,10 @@ impl Game<'_> {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let (velocity, on_ground) = (p.velocity, p.ground.on_ground);
|
||||
// Projectile.shootFromRotation adds getKnownMovement: what the thrower
|
||||
// last moved, not a velocity the server kept for a guest.
|
||||
let on_ground = p.ground.on_ground;
|
||||
let velocity = self.known_player_movement();
|
||||
let start = Vec3::new(eye[0], eye[1] - 0.1, eye[2]);
|
||||
let mut proj = Projectile::new(kind.clone(), start, Some(Who::Player));
|
||||
proj.shoot_from_rotation(
|
||||
@@ -4045,7 +4253,7 @@ impl Game<'_> {
|
||||
};
|
||||
let parent = &self.mobs[index];
|
||||
if !parent.alive()
|
||||
|| stack.item.strip_suffix("_spawn_egg") != Some(parent.kind.id.as_str())
|
||||
|| !horizon_client::mobs::finalize::spawn_egg_is_for(&stack.item, &parent.kind.id)
|
||||
|| !horizon_client::mobs::drops::can_be_baby(parent.species())
|
||||
// WanderingTrader.getBreedOffspring returns null: no offspring, the
|
||||
// click carries on to trading.
|
||||
@@ -4060,15 +4268,11 @@ impl Game<'_> {
|
||||
let (species, variant, color) =
|
||||
(parent.species(), parent.extra.variant, parent.extra.color);
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
let Some(id) = self.spawn_mob(&kind, at, true) else {
|
||||
let Some(id) = self.spawn_mob_spawned(&kind, at) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::mobs::finalize::set_baby(child, true);
|
||||
child.extra.variant = variant;
|
||||
if species == Species::Sheep {
|
||||
child.extra.color = color;
|
||||
}
|
||||
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
|
||||
}
|
||||
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
|
||||
self.consume_held();
|
||||
@@ -4106,7 +4310,7 @@ impl Game<'_> {
|
||||
f64::from(p[1]),
|
||||
f64::from(p[2]) + 0.5,
|
||||
];
|
||||
if self.spawn_mob(&kind, at, true).is_none() {
|
||||
if self.spawn_mob_spawned(&kind, at).is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
self.swing_arm();
|
||||
@@ -4424,6 +4628,63 @@ impl Game<'_> {
|
||||
self.wind_charge_geometry(a, c, packed, out);
|
||||
continue;
|
||||
}
|
||||
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
|
||||
// translucent copy of it (colour 0x26FFFFFF); always 15 block light.
|
||||
if matches!(a.kind, ProjectileKind::ShulkerBullet { .. }) {
|
||||
static BULLET: std::sync::OnceLock<Option<horizon_client::mobs::Part>> =
|
||||
std::sync::OnceLock::new();
|
||||
let model = BULLET.get_or_init(|| {
|
||||
horizon_client::mobs::load_model(
|
||||
&self.options.resources,
|
||||
"minecraft:shulker_bullet#main",
|
||||
)
|
||||
});
|
||||
if let (Some(model), Some(r)) =
|
||||
(model, atlas.sprites.get("entity/shulker/spark").copied())
|
||||
{
|
||||
use horizon_core::geometry::trig;
|
||||
let tc = a.age as f32 + alpha as f32;
|
||||
let mut poses = horizon_client::mobs::anim::Poses::new(model);
|
||||
poses.yrot("main", a.yaw.to_radians());
|
||||
poses.xrot("main", a.pitch.to_radians());
|
||||
let light = (packed & 0xFF) | ((15 * 16) << 8);
|
||||
let base = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.15, 0.0))
|
||||
* glam::Mat4::from_rotation_y(
|
||||
(trig::sin(f64::from(tc * 0.1)) * 180.0).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_rotation_x(
|
||||
(trig::cos(f64::from(tc * 0.1)) * 180.0).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_rotation_z(
|
||||
(trig::sin(f64::from(tc * 0.15)) * 360.0).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5));
|
||||
let uv = [r.u0, r.v0, r.u1, r.v1];
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base,
|
||||
&poses,
|
||||
uv,
|
||||
0xFFFF_FFFF,
|
||||
light,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
// The translucent shell blends over the opaque core.
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base * glam::Mat4::from_scale(glam::Vec3::splat(1.5)),
|
||||
&poses,
|
||||
uv,
|
||||
0x26FF_FFFF,
|
||||
light,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// WitherSkullRenderer: the skull model facing its flight.
|
||||
if let Some(dangerous) = super::wither::skull_kind(a) {
|
||||
static SKULL: std::sync::OnceLock<Option<horizon_client::mobs::Part>> =
|
||||
@@ -4546,6 +4807,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
|
||||
K::SonicBoom => "minecraft:sonic_boom",
|
||||
K::Freeze => "minecraft:freeze",
|
||||
K::OutOfWorld => "minecraft:out_of_world",
|
||||
K::Cramming => "minecraft:cramming",
|
||||
}
|
||||
}
|
||||
/// A Horizon item stack as a loot stack (id, count, damage, enchantments).
|
||||
|
||||
@@ -131,6 +131,8 @@ impl Game<'_> {
|
||||
map.insert("item".into(), save::item_to_nbt(item));
|
||||
}
|
||||
spill::put_saved_entity(&mut self.world, pos, tag);
|
||||
// The sherds and item go to the guests (BlockEntityData).
|
||||
self.mark_block_entity_updated((pos[0], pos[1], pos[2]));
|
||||
self.consume_held();
|
||||
self.audio.play(
|
||||
"minecraft:block.decorated_pot.insert",
|
||||
|
||||
@@ -163,7 +163,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// A `show_dialog` click event's dialog: a registry id or the dialog itself.
|
||||
fn show_dialog_ref(&mut self, dialog: &Tag) {
|
||||
pub(super) fn show_dialog_ref(&mut self, dialog: &Tag) {
|
||||
match dialog {
|
||||
Tag::String(id) => {
|
||||
let id = id.to_string_lossy();
|
||||
|
||||
@@ -15,7 +15,10 @@ use horizon_client::{
|
||||
gamerules::Value as RuleValue,
|
||||
item_data::{canon_stack, parse_stack},
|
||||
items::ItemStack,
|
||||
mobs::{Mob, Species},
|
||||
mobs::{
|
||||
Mob, Species,
|
||||
spawner::{SpawnDataExt, SpawnerExt},
|
||||
},
|
||||
motion::PlayerMotion,
|
||||
projectiles::ProjectileKind,
|
||||
world::World,
|
||||
|
||||
@@ -801,10 +801,7 @@ impl Game<'_> {
|
||||
}
|
||||
if !self.fight.killed {
|
||||
if self.dragon.is_none() {
|
||||
// createNewDragon: over the podium, holding pattern, a random yaw.
|
||||
let yaw = self.world.random.next_float() * 360.0;
|
||||
let seed = self.world.random.next_long();
|
||||
self.dragon = Some(Dragon::new(Vec3::new(0.0, 128.0, 0.0), yaw, seed));
|
||||
self.create_new_dragon();
|
||||
}
|
||||
self.fight.crystal_scan += 1;
|
||||
if self.fight.crystal_scan >= 100 {
|
||||
@@ -1083,7 +1080,7 @@ impl Game<'_> {
|
||||
self.stats.add("killed", "minecraft:ender_dragon", 1);
|
||||
self.stats.custom("mob_kills", 1);
|
||||
self.score_player_killed(super::commands::Target::Mob(id));
|
||||
self.trigger_entity("minecraft:player_killed_entity", "entity", view);
|
||||
self.trigger_killed(view, None);
|
||||
}
|
||||
/// Player melee on the dragon's parts or a crystal under the crosshair.
|
||||
/// The dragon part under the crosshair within reach: its id and distance.
|
||||
@@ -1122,22 +1119,21 @@ impl Game<'_> {
|
||||
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
|
||||
return false;
|
||||
}
|
||||
let hand_damage = self.attack_item_info().map_or(0.0, |i| i.attack_damage);
|
||||
let delay = horizon_client::attack_strength::delay(self.main_hand_attack_speed());
|
||||
let scale = ((self.attack_ticks as f32 + 0.5) / delay).clamp(0.0, 1.0);
|
||||
let mut amount =
|
||||
(1.0 + hand_damage + self.effect_attack_bonus()) * (0.2 + scale * scale * 0.8);
|
||||
let falling = self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| !p.ground.on_ground && p.velocity.y < 0.0 && !p.sprinting);
|
||||
if scale > 0.9 && falling {
|
||||
amount *= 1.5;
|
||||
}
|
||||
amount += self.enchant_damage_bonus("minecraft:ender_dragon") * scale;
|
||||
self.attack_ticks = 0;
|
||||
// A dragon part is no LivingEntity: `canCriticalAttack` is false.
|
||||
let (amount, _, _, _) = self.melee_damage("minecraft:ender_dragon", false);
|
||||
self.swing_arm();
|
||||
self.hit_special(id, amount, true, false);
|
||||
// The part's box.
|
||||
let area = self
|
||||
.special_targets()
|
||||
.into_iter()
|
||||
.find(|(part, _)| *part == id)
|
||||
.map(|(_, b)| b);
|
||||
let hurt = self.hit_special(id, amount, true, false);
|
||||
// The sweep and the sounds read the charge, which the hit then spends.
|
||||
if let (Some(area), Hurt::Landed { .. }) = (area, hurt) {
|
||||
self.attack_effects(area, None, super::mace::Spared::Nothing, None);
|
||||
}
|
||||
self.attack_ticks = 0;
|
||||
true
|
||||
}
|
||||
/// The 24 nodes (`findClosestNode` builds them on first use).
|
||||
@@ -2230,12 +2226,20 @@ impl Game<'_> {
|
||||
.filter(|p| p.alive && !p.creative && !p.spectator)
|
||||
.collect();
|
||||
let mut harmed: Vec<(FightWho, f32)> = Vec::new();
|
||||
// DragonSittingFlamingPhase.end discards the flame cloud.
|
||||
let flame_over = self
|
||||
.dragon
|
||||
.as_ref()
|
||||
.is_some_and(|d| !matches!(d.phase, Phase::Flaming { .. }));
|
||||
let mut clouds = std::mem::take(&mut self.breath_clouds);
|
||||
clouds.retain_mut(|c| {
|
||||
// Lingering potions' clouds tick with the potions.
|
||||
if c.potion.is_some() {
|
||||
return true;
|
||||
}
|
||||
if c.from_dragon && flame_over {
|
||||
return false;
|
||||
}
|
||||
c.age += 1;
|
||||
if c.age >= c.duration {
|
||||
return false;
|
||||
@@ -3307,6 +3311,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `EnderDragonFight.createNewDragon`: over the podium, holding pattern, a random yaw.
|
||||
fn create_new_dragon(&mut self) {
|
||||
let yaw = self.world.random.next_float() * 360.0;
|
||||
let seed = self.world.random.next_long();
|
||||
self.dragon = Some(Dragon::new(Vec3::new(0.0, 128.0, 0.0), yaw, seed));
|
||||
}
|
||||
/// The END stage: the portal crystals blow up (no block damage) and a
|
||||
/// new dragon takes over.
|
||||
fn finish_respawn(&mut self) {
|
||||
@@ -3333,6 +3343,31 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.fight.killed = false;
|
||||
// setRespawnStage(END): the new dragon is summoned for every player
|
||||
// of the fight (the boss bar's players: alive within 192 blocks).
|
||||
self.create_new_dragon();
|
||||
let view = self
|
||||
.dragon
|
||||
.as_ref()
|
||||
.map(|d| horizon_client::advancements::EntityView {
|
||||
kind: "minecraft:ender_dragon".to_owned(),
|
||||
position: [d.position.x, d.position.y, d.position.z],
|
||||
..Default::default()
|
||||
});
|
||||
if let Some(view) = view {
|
||||
self.for_each_player(|g| {
|
||||
let near = g.player.as_ref().is_some_and(|p| {
|
||||
!p.health.dead_or_dying()
|
||||
&& p.position.x.powi(2)
|
||||
+ (p.position.y - 128.0).powi(2)
|
||||
+ p.position.z.powi(2)
|
||||
<= 192.0 * 192.0
|
||||
});
|
||||
if near {
|
||||
g.trigger_entity("minecraft:summoned_entity", "entity", &view);
|
||||
}
|
||||
});
|
||||
}
|
||||
self.fight.dirty = true;
|
||||
self.count_crystals();
|
||||
}
|
||||
|
||||
@@ -73,8 +73,13 @@ impl Game<'_> {
|
||||
}
|
||||
/// `getEnchantedDamage - base`: damage bonus of the held weapon against a mob type.
|
||||
pub(super) fn enchant_damage_bonus(&mut self, target: &str) -> f32 {
|
||||
self.enchanted_damage(target, 0.0)
|
||||
}
|
||||
/// `Player.getEnchantedDamage`: `base` run through the held weapon's
|
||||
/// damage effects against a mob type.
|
||||
pub(super) fn enchanted_damage(&mut self, target: &str, base: f32) -> f32 {
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return 0.0;
|
||||
return base;
|
||||
};
|
||||
let ctx = EffectContext {
|
||||
this: Some(target),
|
||||
@@ -90,7 +95,7 @@ impl Game<'_> {
|
||||
"minecraft:damage",
|
||||
&ctx,
|
||||
&mut self.world.random,
|
||||
0.0,
|
||||
base,
|
||||
)
|
||||
}
|
||||
/// Strength / weakness on the attack damage attribute.
|
||||
@@ -541,7 +546,7 @@ impl Game<'_> {
|
||||
/// `ammo` is the item being fired: `Enchantment.itemContext` hands it to the
|
||||
/// `ammo_use` conditions as the tool, so Infinity only frees plain arrows.
|
||||
pub(super) fn bow_enchantments(&mut self, ammo: &str) -> (f64, bool) {
|
||||
let Some(bow) = self.inventory[self.selected].clone() else {
|
||||
let Some(bow) = self.held().cloned() else {
|
||||
return (0.0, false);
|
||||
};
|
||||
let e = &self.res.stations().enchantments;
|
||||
@@ -576,7 +581,7 @@ impl Game<'_> {
|
||||
/// `EnchantmentHelper.onProjectileSpawned` ignite effects of the held bow
|
||||
/// (flame): the seconds the new arrow burns for.
|
||||
pub(super) fn bow_projectile_ignite(&mut self) -> Option<f32> {
|
||||
let bow = self.inventory[self.selected].clone()?;
|
||||
let bow = self.held().cloned()?;
|
||||
let ctx = EffectContext {
|
||||
this: Some("minecraft:arrow"),
|
||||
items: Some(self.res.items()),
|
||||
|
||||
@@ -71,10 +71,18 @@ impl Game<'_> {
|
||||
Some(r) => self.settings.render_distance.min(r.view_distance),
|
||||
None => self.settings.render_distance,
|
||||
};
|
||||
(f64::from(distance) / 8.0).clamp(1.0, 2.5) * f64::from(self.settings.entity_distance)
|
||||
entity_view_scale(distance, self.settings.entity_distance)
|
||||
}
|
||||
}
|
||||
|
||||
/// Entity.setViewScale: `Mth.clamp(renderDistance / 8, 1, 2.5) *
|
||||
/// entityDistanceScaling`. Every entity's draw distance is scaled by it, so
|
||||
/// a small mob is drawn nearer than a big one and a longer render distance
|
||||
/// draws both further away.
|
||||
pub(super) fn entity_view_scale(render_distance: i32, entity_distance: f32) -> f64 {
|
||||
(f64::from(render_distance) / 8.0).clamp(1.0, 2.5) * f64::from(entity_distance)
|
||||
}
|
||||
|
||||
/// Entity.shouldRenderAtSqrDistance's default range: 64 times the bounding
|
||||
/// box's mean edge, scaled by the view scale.
|
||||
pub(super) fn default_range(size: glam::DVec3, view_scale: f64) -> f64 {
|
||||
@@ -135,3 +143,41 @@ impl Game<'_> {
|
||||
self.entity_in_view(camera, position, range, lo, hi)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `Entity.setViewScale`: the entity distance option times the render
|
||||
/// distance's boost — 1 at eight chunks, 1.5 at twelve, 2.5 at twenty.
|
||||
#[test]
|
||||
fn the_view_scale_follows_the_render_distance() {
|
||||
assert_eq!(entity_view_scale(4, 1.0), 1.0);
|
||||
assert_eq!(entity_view_scale(8, 1.0), 1.0);
|
||||
assert_eq!(entity_view_scale(12, 1.0), 1.5);
|
||||
assert_eq!(entity_view_scale(20, 1.0), 2.5);
|
||||
assert_eq!(entity_view_scale(32, 1.0), 2.5);
|
||||
// The entity distance option scales it down.
|
||||
assert_eq!(entity_view_scale(12, 0.5), 0.75);
|
||||
}
|
||||
|
||||
/// `Entity.shouldRenderAtSqrDistance`: a chicken's box (0.4 by 0.7 by
|
||||
/// 0.4) is drawn up to 48 blocks away at the vanilla defaults, not the
|
||||
/// 32 blocks a missing view scale leaves (#2273).
|
||||
#[test]
|
||||
fn a_chicken_is_drawn_forty_eight_blocks_away() {
|
||||
let range = default_range(glam::DVec3::new(0.4, 0.7, 0.4), entity_view_scale(12, 1.0));
|
||||
assert_eq!(range, 48.0);
|
||||
}
|
||||
|
||||
/// `Entity.shouldRenderAtSqrDistance` on a broken box: the size falls
|
||||
/// back to one block.
|
||||
#[test]
|
||||
fn a_broken_bounding_box_falls_back_to_one_block() {
|
||||
let range = default_range(
|
||||
glam::DVec3::new(f64::NAN, 0.7, 0.4),
|
||||
entity_view_scale(12, 1.0),
|
||||
);
|
||||
assert_eq!(range, 96.0);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! (`render.set-minimap`: its tiles, image and HUD) and zoom (`render.set-zoom`) on the camera; clicks
|
||||
//! and frame times for mods (`input.clicks-per-second`, `render.frame-stats`).
|
||||
//! Each hook does nothing while its extra is off and no mod runs.
|
||||
use super::{Dim, Game, Screen};
|
||||
use super::{Dim, Game, Screen, native_hooks::NativeMinimap};
|
||||
use horizon_client::{
|
||||
extras::{Corner, Cps, FpsCounter, fov_target},
|
||||
extras_hud::{self, Corners},
|
||||
@@ -36,14 +36,15 @@ impl Game<'_> {
|
||||
}
|
||||
/// Mouse buttons while playing, for mods' clicks.
|
||||
pub(super) fn extras_mouse(&mut self, button: MouseButton, pressed: bool) {
|
||||
self.native_mouse_event(button, pressed);
|
||||
let i = match button {
|
||||
MouseButton::Left => 0,
|
||||
MouseButton::Right => 1,
|
||||
_ => return,
|
||||
};
|
||||
self.extras.mouse[i] = pressed;
|
||||
// For mods (`input.clicks-per-second`).
|
||||
if pressed && self.mods.running() {
|
||||
// For mods (`input.clicks-per-second`, `native_clicks`).
|
||||
if pressed {
|
||||
let now = self.extras_now();
|
||||
self.extras.cps[i].click(now);
|
||||
}
|
||||
@@ -57,22 +58,17 @@ impl Game<'_> {
|
||||
self.extras.mouse = [false; 2];
|
||||
}
|
||||
self.update_minimap();
|
||||
// For mods (`render.frame-stats`).
|
||||
if self.mods.running() {
|
||||
let now = self.extras_now();
|
||||
self.extras.fps.record(now, elapsed.as_secs_f32() * 1000.0);
|
||||
} else {
|
||||
self.extras.fps.clear();
|
||||
}
|
||||
// For mods (`render.frame-stats`, `native_frame_rate`).
|
||||
let now = self.extras_now();
|
||||
self.extras.fps.record(now, elapsed.as_secs_f32() * 1000.0);
|
||||
self.native_frame(elapsed);
|
||||
}
|
||||
/// The frame counter for mods: fps, min, 1% low.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) fn frame_reading(&self) -> (u32, u32, u32) {
|
||||
let r = self.extras.fps.reading();
|
||||
(r.fps, r.min, r.low)
|
||||
}
|
||||
/// Attack and use clicks in the last second, and whether each is held.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) fn mouse_reading(&mut self) -> ((u32, u32), [bool; 2]) {
|
||||
let now = self.extras_now();
|
||||
let [l, r] = &mut self.extras.cps;
|
||||
@@ -135,7 +131,7 @@ impl Game<'_> {
|
||||
if self.title.is_some() || self.ui.hide_gui {
|
||||
return None;
|
||||
}
|
||||
let m = self.mod_minimap()?;
|
||||
let m = self.minimap_wanted()?;
|
||||
let view = View {
|
||||
x: p.position.x,
|
||||
z: p.position.z,
|
||||
@@ -146,11 +142,26 @@ impl Game<'_> {
|
||||
};
|
||||
Some((m.corner, m.size, view))
|
||||
}
|
||||
/// The minimap shown: a native mod's (`native_minimap`), else a
|
||||
/// WebAssembly mod's.
|
||||
fn minimap_wanted(&self) -> Option<NativeMinimap> {
|
||||
if let Some(m) = self.native_minimap_now() {
|
||||
return Some(m);
|
||||
}
|
||||
let m = self.mod_minimap()?;
|
||||
Some(NativeMinimap {
|
||||
corner: m.corner,
|
||||
size: m.size,
|
||||
blocks_per_pixel: m.blocks_per_pixel,
|
||||
rotate: m.rotate,
|
||||
round: m.round,
|
||||
})
|
||||
}
|
||||
/// Refresh the minimap's tiles within the frame budget, recompose its
|
||||
/// image when the view or a tile changed, and queue the upload.
|
||||
fn update_minimap(&mut self) {
|
||||
let Some((_, _, view)) = self.minimap_shown() else {
|
||||
if self.mod_minimap().is_none() || self.player.is_none() {
|
||||
if self.minimap_wanted().is_none() || self.player.is_none() {
|
||||
self.extras.minimap.clear();
|
||||
self.extras.minimap_dim = None;
|
||||
}
|
||||
@@ -201,6 +212,9 @@ impl Game<'_> {
|
||||
/// A mod's zoom in effect (`render.set-zoom`): (factor, smooth, turning
|
||||
/// scale); none is 1.
|
||||
fn zoom_now(&self) -> (f32, bool, f64) {
|
||||
if let Some(z) = self.native_zoom_now() {
|
||||
return (z.factor, z.smooth, f64::from(z.turn));
|
||||
}
|
||||
self.mod_zoom().map_or((1.0, true, 1.0), |z| {
|
||||
(z.factor, z.smooth, f64::from(z.turn))
|
||||
})
|
||||
@@ -228,9 +242,6 @@ impl Game<'_> {
|
||||
pub(super) fn draw_extras(&mut self, batch: &mut HudBatch) {
|
||||
let mod_panels = self.mods.has_panels();
|
||||
let map = self.minimap_shown();
|
||||
if map.is_none() && !mod_panels {
|
||||
return;
|
||||
}
|
||||
let visible = |corner| !(self.debug && corner == Corner::TopLeft);
|
||||
let mut corners = Corners::new(batch.width, batch.height, self.effect_rows_height());
|
||||
let atlas = &self.gui.atlas;
|
||||
@@ -256,6 +267,7 @@ impl Game<'_> {
|
||||
if mod_panels {
|
||||
self.draw_mod_panels(batch, &mut corners);
|
||||
}
|
||||
self.native_hud(batch, &mut corners);
|
||||
}
|
||||
/// The height the status effect icons take at the top right
|
||||
/// (`Hud.extractEffects`: beneficial at y 1, harmful at y 26).
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! Field of view (`Camera.tickFov`, `calculateFov`,
|
||||
//! `AbstractClientPlayer.getFieldOfViewModifier`), view and hurt bobbing
|
||||
//! (`GameRenderer.bobHurt`, `bobView`) and the spyglass (`SpyglassItem`,
|
||||
//! (`GameRenderer.bobHurt`, `bobView`), the portal and Nausea spin
|
||||
//! (`GameRenderer.renderLevel`) and the spyglass (`SpyglassItem`,
|
||||
//! `Hud.extractSpyglassOverlay`). The arithmetic is `horizon_client::camera`.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
camera::{
|
||||
BobState, CameraFluid, FovPlayer, HurtBob, UseItem, bob_matrix, field_of_view_modifier,
|
||||
spinning_effect_matrix,
|
||||
},
|
||||
fog::{FogType, fog_type},
|
||||
hud::HudBatch,
|
||||
@@ -26,6 +28,28 @@ impl Game<'_> {
|
||||
pub(super) fn fov_effect_scale(&self) -> f32 {
|
||||
self.settings.file.double("fovEffectScale") as f32
|
||||
}
|
||||
/// `options.screenEffectScale` (Distortion Effects).
|
||||
pub(super) fn screen_effect_scale(&self) -> f32 {
|
||||
self.settings.file.double("screenEffectScale") as f32
|
||||
}
|
||||
/// `LocalPlayer.getEffectBlendFactor(NAUSEA, partial)`: 0 without the effect.
|
||||
pub(super) fn nausea_blend(&self, partial: f32) -> f32 {
|
||||
if self.potions.effects.get("nausea").is_none() {
|
||||
return 0.0;
|
||||
}
|
||||
let fx = &self.screen_fx;
|
||||
fx.o_nausea + partial * (fx.nausea - fx.o_nausea)
|
||||
}
|
||||
/// The spin `GameRenderer.renderLevel` multiplies into the level's
|
||||
/// projection: the larger of the portal and Nausea intensities, scaled by
|
||||
/// Distortion Effects squared, turning by `LocalPlayer.getSpinningEffectAngle`.
|
||||
pub(super) fn spinning_matrix(&self, partial: f32) -> glam::Mat4 {
|
||||
let fx = &self.screen_fx;
|
||||
let portal = fx.o_portal + partial * (fx.portal - fx.o_portal);
|
||||
let scale = self.screen_effect_scale();
|
||||
let intensity = portal.max(self.nausea_blend(partial)) * (scale * scale);
|
||||
spinning_effect_matrix(intensity, fx.spinning_time + partial * fx.spinning_speed)
|
||||
}
|
||||
/// What `AbstractClientPlayer.getFieldOfViewModifier` reads.
|
||||
fn fov_player(&self) -> Option<FovPlayer> {
|
||||
let p = self.player.as_ref()?;
|
||||
|
||||
@@ -429,7 +429,9 @@ impl Game<'_> {
|
||||
if let Some(id) = map {
|
||||
// ItemFrameRenderer: the map fills the face, in 90-degree steps.
|
||||
let depth = if f.invisible { 0.5 } else { 0.4375 };
|
||||
let item_light = if f.glow { 240 | 240 << 8 } else { packed };
|
||||
// A glow frame's map is 15728850 = 0xF000D2 (sky coordinate 240,
|
||||
// block coordinate 210), not full bright.
|
||||
let item_light = if f.glow { 240 | 210 << 8 } else { packed };
|
||||
let m = pose
|
||||
* glam::DMat4::from_translation(glam::DVec3::new(0.0, 0.0, depth))
|
||||
* glam::DMat4::from_rotation_z(
|
||||
|
||||
@@ -78,9 +78,37 @@ impl Game<'_> {
|
||||
f64::from(base[1]) + 0.05,
|
||||
f64::from(base[2]) + 0.5,
|
||||
];
|
||||
self.create_triggered(kind, at, 0.0);
|
||||
let id = self.create_triggered(kind, at, 0.0);
|
||||
self.trigger_golem_summoned(id);
|
||||
self.update_pattern_blocks(&blocks)
|
||||
}
|
||||
/// `CarvedPumpkinBlock.spawnGolemInWorld`: SUMMONED_ENTITY for each player
|
||||
/// whose box is within 5 blocks of the golem's box (the local player here).
|
||||
fn trigger_golem_summoned(&mut self, id: Option<u32>) {
|
||||
let Some(golem) = id
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let me = self.player_entity();
|
||||
let half = f64::from(golem.width()) / 2.0;
|
||||
let height = f64::from(golem.height());
|
||||
let reach = 5.0;
|
||||
let [x, y, z] = me.position;
|
||||
let p = golem.position;
|
||||
// The player's box is 0.6 wide and 1.8 tall.
|
||||
let near = x + 0.3 >= p.x - half - reach
|
||||
&& x - 0.3 <= p.x + half + reach
|
||||
&& y + 1.8 >= p.y - reach
|
||||
&& y <= p.y + height + reach
|
||||
&& z + 0.3 >= p.z - half - reach
|
||||
&& z - 0.3 <= p.z + half + reach;
|
||||
if near {
|
||||
let view = self.mob_entity(&golem);
|
||||
self.trigger_entity("minecraft:summoned_entity", "entity", &view);
|
||||
}
|
||||
}
|
||||
/// Whether placing a golem head at `head` completes a golem.
|
||||
pub(super) fn completes_golem(&self, head: [i32; 3]) -> bool {
|
||||
self.find_golem(head).is_some() || self.copper_golem_base(head)
|
||||
@@ -186,6 +214,7 @@ impl Game<'_> {
|
||||
f64::from(head[2]) + 0.5,
|
||||
];
|
||||
let id = self.create_triggered("minecraft:copper_golem", at, 0.0);
|
||||
self.trigger_golem_summoned(id);
|
||||
self.update_pattern_blocks(&cells)?;
|
||||
let direction = match facing.as_str() {
|
||||
"south" => horizon_core::geometry::Direction::South,
|
||||
|
||||
@@ -169,6 +169,16 @@ enum Publish {
|
||||
/// of the connection and the world's name, or why it failed.
|
||||
type Ready = std::result::Result<(Pipe, String), String>;
|
||||
|
||||
/// An integrated server loading its world: the window keeps drawing its
|
||||
/// loading screen and joins once the server is ready, as vanilla's client
|
||||
/// does while its server prepares (`Minecraft.doWorldLoad`), instead of
|
||||
/// waiting for it frozen for the whole load.
|
||||
pub(super) struct Starting {
|
||||
handle: Integrated,
|
||||
ready: Receiver<Ready>,
|
||||
player: String,
|
||||
}
|
||||
|
||||
/// The world to open and the player who owns it.
|
||||
struct Launch {
|
||||
folder: String,
|
||||
@@ -197,8 +207,8 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.published().is_some())
|
||||
}
|
||||
/// `Minecraft.doWorldLoad`: start the server thread on the world, wait
|
||||
/// until it can be joined, then join it through memory.
|
||||
/// `Minecraft.doWorldLoad`: start the server thread on the world; the
|
||||
/// window joins it through memory once it is ready (`poll_integrated_start`).
|
||||
pub(super) fn open_integrated(
|
||||
&mut self,
|
||||
folder: &str,
|
||||
@@ -267,20 +277,53 @@ impl Game<'_> {
|
||||
let _ = ready_tx.send(Err(e.to_string()));
|
||||
}
|
||||
})?;
|
||||
let mut handle = Integrated {
|
||||
let handle = Integrated {
|
||||
flags,
|
||||
thread: Some(thread),
|
||||
tasks,
|
||||
world_name: String::new(),
|
||||
};
|
||||
let pipe = match ready.recv() {
|
||||
Ok(Ok((pipe, world_name))) => {
|
||||
handle.world_name = world_name;
|
||||
pipe
|
||||
self.integrated_starting = Some(Starting {
|
||||
handle,
|
||||
ready,
|
||||
player,
|
||||
});
|
||||
// Harnesses, control scripts and runs without a window go on with the world open.
|
||||
if self.window.is_none() || super::synchronous_startup(&self.options) {
|
||||
while self.integrated_starting.is_some() {
|
||||
self.poll_integrated_start()?;
|
||||
if self.integrated_starting.is_some() {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => return Err(e.into()),
|
||||
Err(_) => return Err("the integrated server stopped".into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The starting server's answer, polled each frame: joined once it is ready.
|
||||
pub(super) fn poll_integrated_start(&mut self) -> Result<()> {
|
||||
let Some(starting) = &self.integrated_starting else {
|
||||
return Ok(());
|
||||
};
|
||||
let (pipe, world_name) = match starting.ready.try_recv() {
|
||||
Ok(Ok(ready)) => ready,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(()),
|
||||
Ok(Err(e)) => {
|
||||
self.integrated_starting = None;
|
||||
return Err(e.into());
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
self.integrated_starting = None;
|
||||
return Err("the integrated server stopped".into());
|
||||
}
|
||||
};
|
||||
let Some(Starting {
|
||||
mut handle, player, ..
|
||||
}) = self.integrated_starting.take()
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
handle.world_name = world_name;
|
||||
self.connect_local(pipe, &player)?;
|
||||
self.integrated = Some(handle);
|
||||
Ok(())
|
||||
|
||||
@@ -82,11 +82,11 @@ fn civil(days: i64) -> (i64, u32, u32) {
|
||||
(yoe + era * 400 + i64::from(m <= 2), m, d)
|
||||
}
|
||||
/// `DateTimeFormatter.ofPattern(pattern).format(now)` for the month, day,
|
||||
/// year and time letters item models use (UTC: no time zone database).
|
||||
/// year and time letters item models use, in the system time zone
|
||||
/// (`LocalTime` without a `time_zone`).
|
||||
pub(super) fn local_time(pattern: &str) -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as i64);
|
||||
let now = chrono::Local::now();
|
||||
let secs = now.timestamp() + i64::from(now.offset().local_minus_utc());
|
||||
let (y, mo, d) = civil(secs.div_euclid(86_400));
|
||||
let s = secs.rem_euclid(86_400);
|
||||
let (h, mi, se) = (s / 3600, s / 60 % 60, s % 60);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
containers::{BlockEntity, Jukebox},
|
||||
first_person::Hand,
|
||||
items::ItemStack,
|
||||
mobs::Species,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
@@ -64,6 +65,10 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
if self.jukebox(pos).is_some_and(|j| j.record.is_some()) {
|
||||
// TRY_WITH_EMPTY_HAND: only the main hand goes on to useWithoutItem.
|
||||
if self.hand != Hand::Main {
|
||||
return false;
|
||||
}
|
||||
self.eject_record(pos);
|
||||
return true;
|
||||
}
|
||||
@@ -100,6 +105,30 @@ impl Game<'_> {
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, stack);
|
||||
true
|
||||
}
|
||||
/// `JukeboxBlock.useItemOn` on this client's level for the main hand:
|
||||
/// whether the jukebox at `pos` takes the click (`has_record` pops the
|
||||
/// disc out, or a `jukebox_playable` item goes in). The server does the
|
||||
/// rest, see `use_jukebox`.
|
||||
pub(super) fn jukebox_takes(&self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if &*self.res.facts().get(state).name != "minecraft:jukebox" {
|
||||
return false;
|
||||
}
|
||||
let has_record = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("has_record"))
|
||||
.is_some_and(|v| v == "true");
|
||||
has_record
|
||||
|| held.is_some_and(|stack| {
|
||||
self.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.is_some_and(|i| i.jukebox_song.is_some())
|
||||
})
|
||||
}
|
||||
/// Level event 1010: the song from the jukebox, and the "Now Playing" overlay.
|
||||
fn start_song(&mut self, pos: [i32; 3], song: &str) {
|
||||
self.stop_sound(pos);
|
||||
|
||||
@@ -909,16 +909,23 @@ fn mob_equipment(
|
||||
// slot's item (6) in `head`.
|
||||
let body = horizon_client::mobs::drops::head_is_body(m.species());
|
||||
let slots = [
|
||||
(0u8, &e.main_hand),
|
||||
(1, &e.off_hand),
|
||||
(2, &e.feet),
|
||||
(3, &e.legs),
|
||||
(4, &e.chest),
|
||||
(if body { 6 } else { 5 }, &e.head),
|
||||
(0u8, &e.main_hand, None),
|
||||
(1, &e.off_hand, None),
|
||||
(2, &e.feet, e.armor[3].as_ref()),
|
||||
(3, &e.legs, e.armor[2].as_ref()),
|
||||
(4, &e.chest, e.armor[1].as_ref()),
|
||||
// The body slot's item is no worn armour.
|
||||
(
|
||||
if body { 6 } else { 5 },
|
||||
&e.head,
|
||||
e.armor[0].as_ref().filter(|_| !body),
|
||||
),
|
||||
];
|
||||
for (slot, item) in slots {
|
||||
for (slot, item, worn) in slots {
|
||||
if let Some(item) = item {
|
||||
let stack = ItemStack {
|
||||
// The worn armour keeps its stack: a dyed leather piece keeps
|
||||
// its colour.
|
||||
let stack = worn.cloned().unwrap_or_else(|| ItemStack {
|
||||
item: item.clone(),
|
||||
count: 1,
|
||||
// A wolf's armor shows its cracks.
|
||||
@@ -932,7 +939,7 @@ fn mob_equipment(
|
||||
} else {
|
||||
None
|
||||
},
|
||||
};
|
||||
});
|
||||
out.push((slot, hub.stack(&Some(stack))));
|
||||
}
|
||||
}
|
||||
@@ -2040,6 +2047,14 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g))
|
||||
.map(|g| (g.chunk(), radius))
|
||||
.chain(
|
||||
self.arrival_chunks(self.dim)
|
||||
.into_iter()
|
||||
// An arrival keeps only its landing spot lit enough to
|
||||
// resolve (the 3x3 `resolve_arrivals` checks), where it
|
||||
// then ticks without a player.
|
||||
.map(|chunk| (chunk, 1)),
|
||||
)
|
||||
.collect();
|
||||
}
|
||||
|
||||
@@ -2136,6 +2151,7 @@ impl Game<'_> {
|
||||
.is_some_and(|s| s.data().is_some_and(|d| !d.charged.is_empty()));
|
||||
// ClientInformation.mainHand (HumanoidArm: 0 left, 1 right).
|
||||
m.extra.main_hand_left = g.info.main_hand == 0;
|
||||
m.extra.hidden_model_parts = !g.info.model_customisation & 0x7F;
|
||||
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
|
||||
let visible = || g.effects.list.iter().filter(|e| e.visible);
|
||||
m.extra.effect_particles = visible()
|
||||
|
||||
@@ -1038,12 +1038,23 @@ impl Game<'_> {
|
||||
if spectator {
|
||||
return;
|
||||
}
|
||||
if !host.hub.guests[gi].may_reach_block(a.pos) {
|
||||
return;
|
||||
}
|
||||
// ServerPlayerGameMode.handleBlockBreakAction: no dig starts
|
||||
// under spawn protection (the ack puts the block back).
|
||||
if self.under_spawn_protection(host, gi, a.pos) {
|
||||
self.spawn_protection_message(host, conn, a.pos);
|
||||
return;
|
||||
}
|
||||
// mayInteract: a block outside the world border can't be
|
||||
// dug (the ack puts it back on the client).
|
||||
if !self
|
||||
.border
|
||||
.contains(f64::from(a.pos[0]), f64::from(a.pos[2]))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let face = direction(i32::from(a.direction));
|
||||
let play = host.play.entry(conn).or_default();
|
||||
play.digging = Some((a.pos, face));
|
||||
@@ -1070,6 +1081,15 @@ impl Game<'_> {
|
||||
1 => {}
|
||||
3 => {
|
||||
host.hub.ack(conn, a.sequence);
|
||||
if !host.hub.guests[gi].may_reach_block(a.pos) {
|
||||
// Too far (handleBlockBreakAction's "too far"): the
|
||||
// dig drops and the ack has the client undo its
|
||||
// prediction.
|
||||
let play = host.play.entry(conn).or_default();
|
||||
play.digging = None;
|
||||
play.mining = None;
|
||||
return;
|
||||
}
|
||||
self.guest_stop_digging(host, conn, a.pos);
|
||||
}
|
||||
2 => {
|
||||
@@ -1162,6 +1182,14 @@ impl Game<'_> {
|
||||
if spectator {
|
||||
return;
|
||||
}
|
||||
// handleUseItemOn: within reach, and the hit point within the block.
|
||||
let inside = p
|
||||
.cursor
|
||||
.iter()
|
||||
.all(|&c| (f64::from(c) - 0.5).abs() < 1.0000001);
|
||||
if !(inside && host.hub.guests[gi].may_reach_block(p.pos)) {
|
||||
return;
|
||||
}
|
||||
let face = direction(p.face);
|
||||
let hit = Hit {
|
||||
pos: p.pos,
|
||||
@@ -1175,9 +1203,14 @@ impl Game<'_> {
|
||||
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
|
||||
// handleUseItemOn: under spawn protection nothing is used or
|
||||
// placed; the player is told and the blocks below go back.
|
||||
// mayInteract: nothing is used on a block outside the world
|
||||
// border either.
|
||||
if self.under_spawn_protection(host, gi, p.pos) {
|
||||
self.spawn_protection_message(host, conn, p.pos);
|
||||
} else {
|
||||
} else if self
|
||||
.border
|
||||
.contains(f64::from(p.pos[0]), f64::from(p.pos[2]))
|
||||
{
|
||||
let logged = self.world.block_log.as_ref().map_or(0, Vec::len);
|
||||
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
|
||||
let swung = self.as_guest(host, conn, |g| {
|
||||
@@ -1213,7 +1246,14 @@ impl Game<'_> {
|
||||
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
|
||||
let g = &mut host.hub.guests[gi];
|
||||
(g.yaw, g.pitch) = (p.yaw, p.pitch);
|
||||
// ServerPlayer.getKnownMovement: the move their client just sent,
|
||||
// which a throw adds to the projectile.
|
||||
let movement = g
|
||||
.moves
|
||||
.last()
|
||||
.map_or(Vec3::ZERO, |m| Vec3::new(m.0[0], m.0[1], m.0[2]));
|
||||
let swung = self.as_guest(host, conn, |g| {
|
||||
g.known_movement = movement;
|
||||
g.guest_use = Some(GuestUse::Item(hand));
|
||||
let swings = g.swings_started;
|
||||
let _ = g.use_item();
|
||||
@@ -1384,6 +1424,9 @@ impl Game<'_> {
|
||||
} else {
|
||||
p.difficulty as u8
|
||||
};
|
||||
// The level data holds the world's difficulty: it
|
||||
// survives the reopen, so it follows the change.
|
||||
self.level.set_difficulty(self.ui.difficulty);
|
||||
self.sync_rules();
|
||||
if self.ui.difficulty == 0 {
|
||||
self.mobs.retain(|m| !m.hostile());
|
||||
@@ -2607,6 +2650,9 @@ impl Game<'_> {
|
||||
let _ = w.tick_environment_damage();
|
||||
let _ = w.apply_fire_contact();
|
||||
let _ = w.server_hazards_tick(in_wall);
|
||||
// LivingEntity.aiStep: a Riptide spin counts down on the server's
|
||||
// copy too, or it would stay in the spin pose (eye 0.4) for good.
|
||||
w.player.count_down_server_spin();
|
||||
// Player.tick ends with updatePlayerPose: a swimmer, or a player
|
||||
// crawling under a block, takes the short box its moves are checked with.
|
||||
let _ = w.update_player_pose();
|
||||
@@ -2706,8 +2752,15 @@ impl Game<'_> {
|
||||
// others (Player.playSound excludes the player, whose client
|
||||
// plays it), the entity event 53 everyone around.
|
||||
MotionEvent::HoneySlide {
|
||||
sound, particles, ..
|
||||
pos,
|
||||
sound,
|
||||
particles,
|
||||
} => {
|
||||
if self.world.game_time % 20 == 0 {
|
||||
self.as_guest(host, conn, |game| {
|
||||
game.trigger_honey_slide([pos.x, pos.y, pos.z])
|
||||
});
|
||||
}
|
||||
let Some((id, at)) = host.hub.guest(conn).map(|g| (g.entity_id, g.position))
|
||||
else {
|
||||
continue;
|
||||
@@ -3031,10 +3084,10 @@ impl Game<'_> {
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
// Entity.take: the clients play the pickup sound themselves
|
||||
// (handleTakeItemEntity).
|
||||
for (item, count) in taken {
|
||||
host.hub.take_item(item as i32, id, count as i32, position);
|
||||
host.hub
|
||||
.sound("minecraft:entity.item.pickup", position, 0.2, 1.4, 7, None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
furnace::FurnaceCooking,
|
||||
items::ItemStack,
|
||||
lan::{guest::Guest, menu::Slots},
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
@@ -128,8 +129,8 @@ impl Game<'_> {
|
||||
let dropped = std::mem::take(&mut menu.dropped);
|
||||
let equipped = std::mem::take(&mut menu.equipped);
|
||||
let brewed = std::mem::take(&mut menu.brewed);
|
||||
let crafted = std::mem::take(&mut menu.crafted);
|
||||
menu.drops.clear();
|
||||
menu.crafted.clear();
|
||||
store_player(menu, &mut host.hub.guests[gi]);
|
||||
if !brewed.is_empty() {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
@@ -139,6 +140,14 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
}
|
||||
if !crafted.is_empty() {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
self.as_guest(host, conn, |g| {
|
||||
for (recipe, ingredients) in &crafted {
|
||||
g.trigger_recipe_crafted(recipe, ingredients);
|
||||
}
|
||||
});
|
||||
}
|
||||
if !equipped.is_empty() {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
self.as_guest(host, conn, |g| {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! attack (blocking, armour, knockback), when the `pvp` game rule allows it
|
||||
//! (`ServerPlayer.canHarmPlayer`, `isPvpAllowed`). A player's arrow on
|
||||
//! another player lands the same way.
|
||||
use super::{Game, lan::LanHost, lan_mobs::guest_bounds, ray_box, view_vector};
|
||||
use super::{Game, lan::LanHost, lan_mobs::guest_bounds, mace::Spared, ray_box, view_vector};
|
||||
|
||||
use horizon_client::{
|
||||
Result,
|
||||
@@ -50,8 +50,6 @@ pub(super) struct Strike {
|
||||
knockback_attack: bool,
|
||||
/// The attacker's team, for the victim's `Player.canHarmPlayer`.
|
||||
team: Option<horizon_client::scoreboard::Team>,
|
||||
/// The sweep's damage to the players around the victim.
|
||||
sweep_hit: Option<Sweep>,
|
||||
}
|
||||
|
||||
/// A sweep attack's reach to the other players (`Player.doSweepAttack`),
|
||||
@@ -68,8 +66,8 @@ pub(super) struct Sweep {
|
||||
team: Option<horizon_client::scoreboard::Team>,
|
||||
/// The target's box grown by (1, 0.25, 1).
|
||||
pub(super) hit_box: Aabb,
|
||||
/// The player the sweep started from, which it spares.
|
||||
skip: Option<Victim>,
|
||||
/// The entity id of the player the sweep started from, which it spares.
|
||||
pub(super) skip: Option<i32>,
|
||||
}
|
||||
|
||||
impl Strike {
|
||||
@@ -146,12 +144,8 @@ impl Game<'_> {
|
||||
pub(super) fn pvp_strike(&mut self) -> Option<Strike> {
|
||||
let p = self.player.as_ref()?;
|
||||
let from = [p.position.x, p.position.y, p.position.z];
|
||||
let (amount, scale, crit, magic) = self.melee_damage("minecraft:player");
|
||||
let (amount, scale, crit, magic) = self.melee_damage("minecraft:player", true);
|
||||
let sweep = self.sweep_attack_position(scale, crit);
|
||||
let sweep_hit = sweep.map(|_| {
|
||||
let base = self.sweep_base_damage(scale);
|
||||
self.player_sweep(base, scale)
|
||||
});
|
||||
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
|
||||
let (xd, zd) = self.extra_knockback_direction();
|
||||
self.attack_ticks = 0;
|
||||
@@ -176,7 +170,6 @@ impl Game<'_> {
|
||||
knockback: (strength, xd, zd),
|
||||
knockback_attack,
|
||||
team: self.player_team().cloned(),
|
||||
sweep_hit,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -369,21 +362,29 @@ impl Game<'_> {
|
||||
(true, false, false) => "minecraft:entity.player.attack.weak",
|
||||
};
|
||||
if let Some(at) = s.sweep.filter(|_| hurt) {
|
||||
self.sweep_attack(at, Some(s.from));
|
||||
// The other players next to the victim (Player.doSweepAttack).
|
||||
let bounds = match victim {
|
||||
Victim::Host => self.player.as_ref().map(|p| p.bounds()),
|
||||
Victim::Guest(conn) => host.hub.guest(conn).map(guest_bounds),
|
||||
// The sweep around the player that was hit, as its attacker.
|
||||
let (area, id) = match victim {
|
||||
Victim::Host => (self.player.as_ref().map(|p| p.bounds()), host.host_entity),
|
||||
Victim::Guest(conn) => {
|
||||
let g = host.hub.guest(conn);
|
||||
(g.map(guest_bounds), g.map_or(-1, |g| g.entity_id))
|
||||
}
|
||||
};
|
||||
if let (Some(sweep), Some(bounds)) = (&s.sweep_hit, bounds) {
|
||||
self.sweep_players(
|
||||
host,
|
||||
&Sweep {
|
||||
hit_box: bounds.inflate(Vec3::new(1.0, 0.25, 1.0)),
|
||||
skip: Some(victim),
|
||||
..sweep.clone()
|
||||
},
|
||||
);
|
||||
let target = area.map(|area| (area, Spared::Player(id)));
|
||||
match s.attacker {
|
||||
None => {
|
||||
// The host is checked out here: collect the sweep for the players.
|
||||
self.combat.sweeps = Some(Vec::new());
|
||||
self.sweep_attack(at, Some(s.from), target, s.scale);
|
||||
for sweep in self.combat.sweeps.take().unwrap_or_default() {
|
||||
self.sweep_players(host, &sweep);
|
||||
}
|
||||
}
|
||||
Some(conn) => {
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.sweep_attack(at, Some(s.from), target, s.scale)
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.audio.play(sound, Some(s.from), 1.0, 1.0);
|
||||
@@ -400,7 +401,7 @@ impl Game<'_> {
|
||||
.player
|
||||
.as_ref()
|
||||
.map_or([0.0; 3], |p| [p.position.x, p.position.y, p.position.z]);
|
||||
let damage = (base + self.enchant_damage_bonus("minecraft:player")) * scale;
|
||||
let damage = self.enchanted_damage("minecraft:player", base) * scale;
|
||||
Sweep {
|
||||
attacker: self.lan_acting,
|
||||
from,
|
||||
@@ -416,7 +417,9 @@ impl Game<'_> {
|
||||
/// turn the host holds the other players (`as_guest`), so it waits for
|
||||
/// the turn to end.
|
||||
pub(super) fn sweep_other_players(&mut self, sweep: Sweep) {
|
||||
if self.lan_acting.is_some() {
|
||||
if let Some(collect) = self.combat.sweeps.as_mut() {
|
||||
collect.push(sweep);
|
||||
} else if self.lan_acting.is_some() {
|
||||
self.creatures.sweeps.push(sweep);
|
||||
} else if let Some(mut host) = self.lan.take() {
|
||||
self.sweep_players(&mut host, &sweep);
|
||||
@@ -446,7 +449,7 @@ impl Game<'_> {
|
||||
&& p.bounds().intersects(sweep.hit_box)
|
||||
&& p.position.distance_squared(from) < 9.0
|
||||
{
|
||||
victims.push((Victim::Host, self.player_team().cloned()));
|
||||
victims.push((Victim::Host, host.host_entity, self.player_team().cloned()));
|
||||
}
|
||||
for g in &host.hub.guests {
|
||||
if host.hub.here(g)
|
||||
@@ -459,7 +462,7 @@ impl Game<'_> {
|
||||
< 9.0
|
||||
{
|
||||
let team = self.score.board.players_team(&g.name).cloned();
|
||||
victims.push((Victim::Guest(g.conn), team));
|
||||
victims.push((Victim::Guest(g.conn), g.entity_id, team));
|
||||
}
|
||||
}
|
||||
let (xd, zd) = sweep.knockback;
|
||||
@@ -469,8 +472,8 @@ impl Game<'_> {
|
||||
g.knock_back_player(0.4, xd, zd);
|
||||
}
|
||||
};
|
||||
for (victim, team) in victims {
|
||||
if sweep.skip == Some(victim)
|
||||
for (victim, id, team) in victims {
|
||||
if sweep.skip == Some(id)
|
||||
|| horizon_client::scoreboard::teams_allied(team.as_ref(), sweep.team.as_ref())
|
||||
|| !self.ui.rules.bool("pvp")
|
||||
{
|
||||
|
||||
@@ -90,6 +90,14 @@ impl Game<'_> {
|
||||
.any(|g| Some(g.conn) != self.lan_acting && g.vehicle == Some(vehicle))
|
||||
})
|
||||
}
|
||||
/// Whether a server player rides `vehicle` (`Entity.isPassenger`: a
|
||||
/// passenger does not use a portal on its own, the vehicle carries it
|
||||
/// across, `Entity.teleportCrossDimension`).
|
||||
pub(super) fn guest_rides(&self, vehicle: u32) -> bool {
|
||||
self.lan
|
||||
.as_ref()
|
||||
.is_some_and(|h| h.hub.guests.iter().any(|g| g.vehicle == Some(vehicle)))
|
||||
}
|
||||
|
||||
/// `Player.rideTick` for a server player, before its moves this tick:
|
||||
/// it stays in its seat, steers its minecart, or gets out.
|
||||
|
||||
@@ -52,8 +52,8 @@ pub(super) struct Parked {
|
||||
armor_stands: Vec<horizon_client::entities::ArmorStand>,
|
||||
cushions: Vec<horizon_client::entities::Cushion>,
|
||||
others: Vec<commands::OtherEntity>,
|
||||
boats: Vec<boats::Boat>,
|
||||
carts: Vec<minecarts::Minecart>,
|
||||
pub(super) boats: Vec<boats::Boat>,
|
||||
pub(super) carts: Vec<minecarts::Minecart>,
|
||||
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
|
||||
bolts: Vec<lightning::Bolt>,
|
||||
crystals: Vec<dragon::Crystal>,
|
||||
@@ -65,7 +65,7 @@ pub(super) struct Parked {
|
||||
ominous_spawners: Vec<ominous_item_spawner::OminousItemSpawner>,
|
||||
tnts: Vec<tnt::PrimedTnt>,
|
||||
hoppers: HashMap<(i32, i32, i32), machines::HopperState>,
|
||||
mobs: Vec<Mob>,
|
||||
pub(super) mobs: Vec<Mob>,
|
||||
/// The entity ids of the level's fence knots (`knot_ids`).
|
||||
knot_ids: HashMap<[i32; 3], u32>,
|
||||
projectiles: Vec<horizon_client::projectiles::Projectile>,
|
||||
@@ -122,6 +122,12 @@ impl Levels {
|
||||
.iter_mut()
|
||||
.map(|(d, p)| (*d, &mut p.world.chunks))
|
||||
}
|
||||
|
||||
/// The level not in place, in `dim`.
|
||||
pub(super) fn parked_mut(&mut self, dim: Dim) -> Option<&mut Parked> {
|
||||
self.parked.get_mut(&dim)
|
||||
}
|
||||
|
||||
/// The levels that are not in place, by dimension.
|
||||
pub(super) fn parked_dims(&self) -> Vec<Dim> {
|
||||
self.parked.keys().copied().collect()
|
||||
@@ -297,6 +303,23 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The entity's memory (its UUID and the compound it was loaded with)
|
||||
/// moves with it from the level not in place to the level in place, so
|
||||
/// it is saved in the other level as it was.
|
||||
pub(super) fn move_entity_memory(&mut self, from: Dim, id: u32) {
|
||||
let Some(p) = self.levels.parked.get_mut(&from) else {
|
||||
return;
|
||||
};
|
||||
let uuid = p.persist.uuids.remove(&id);
|
||||
let raw = p.persist.raw.remove(&id);
|
||||
if let Some(u) = uuid {
|
||||
self.persist.uuids.insert(id, u);
|
||||
}
|
||||
if let Some(r) = raw {
|
||||
self.persist.raw.insert(id, r);
|
||||
}
|
||||
}
|
||||
|
||||
/// Put `dim`'s level in place for a command whose source is there
|
||||
/// (`execute in`, `execute at` a player in another level), opening it if
|
||||
/// nobody is in it; returns the level to put back afterwards. Only a
|
||||
@@ -408,9 +431,9 @@ impl Game<'_> {
|
||||
}
|
||||
*next += self.clock.tick_duration();
|
||||
if *next > now {
|
||||
// A `/pregen` task or force-loaded chunks keep the workers fed
|
||||
// until the next tick.
|
||||
self.pump_pregen(*next);
|
||||
// The chunks loading (the players', force-loaded ones, a
|
||||
// `/pregen` task) keep the workers fed until the next tick.
|
||||
self.pump_chunks(*next, paused);
|
||||
let now = Instant::now();
|
||||
if *next > now {
|
||||
std::thread::sleep(*next - now);
|
||||
@@ -442,6 +465,9 @@ impl Game<'_> {
|
||||
guests.iter().any(|g| g.dimension == d.id())
|
||||
|| forced
|
||||
|| self.pregen_level() == Some(*d)
|
||||
// A pending portal arrival keeps its destination level
|
||||
// running so it can land and tick (the portal ticket).
|
||||
|| !self.arrival_chunks(*d).is_empty()
|
||||
})
|
||||
.collect();
|
||||
if dims.is_empty() {
|
||||
@@ -454,9 +480,17 @@ impl Game<'_> {
|
||||
/// (other than the Overworld) nobody has been in for a while.
|
||||
pub(super) fn close_idle_levels(&mut self) -> Result<()> {
|
||||
let occupied = self.levels_to_tick();
|
||||
// A vehicle waiting there for its rider's arrival in another level.
|
||||
let waiting: Vec<Dim> = self
|
||||
.levels
|
||||
.parked
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|d| self.vehicle_waits_in(*d))
|
||||
.collect();
|
||||
let mut closing = Vec::new();
|
||||
for (dim, p) in &mut self.levels.parked {
|
||||
if occupied.contains(dim) {
|
||||
if occupied.contains(dim) || waiting.contains(dim) {
|
||||
p.idle = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! `thunderHit` (damage and burning, charged creepers, zombified piglins,
|
||||
//! witches, mooshroom colours, turtles, burnt items), lightning rods, the sky
|
||||
//! flash, the sounds and the branching additive bolt.
|
||||
use super::Game;
|
||||
use super::{Game, commands::Target};
|
||||
use horizon_client::advancements::EntityView;
|
||||
use horizon_client::mobs::{Damage, DamageKind, MobEvent, SpawnSetup, Species};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
|
||||
@@ -18,6 +19,13 @@ pub(super) struct Bolt {
|
||||
visual_only: bool,
|
||||
/// Mooshrooms and copper golems change once per bolt.
|
||||
converted: Vec<u32>,
|
||||
/// The mobs it has struck (`hitEntities`).
|
||||
hit: Vec<u32>,
|
||||
/// Blocks it set on fire (`blocksSetOnFire`).
|
||||
fires: i32,
|
||||
/// The player who channelled it (`LightningBolt.cause`): `Some(None)` for
|
||||
/// the host, `Some(Some(conn))` for a guest.
|
||||
pub cause: Option<Option<u32>>,
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `ServerChunkCache.tickSpawningChunk`: one `tickThunder` roll per
|
||||
@@ -29,10 +37,13 @@ impl Game<'_> {
|
||||
if rain <= 0.2 || thunder <= 0.9 || self.dim != super::Dim::Overworld {
|
||||
return;
|
||||
}
|
||||
let (_, mut chunks) = self.spawning_chunks(&self.level_players());
|
||||
let mut scratch = std::mem::take(&mut self.spawn_scratch);
|
||||
self.spawning_chunks(&self.level_players(), &mut scratch);
|
||||
let levels = self.tickets.levels();
|
||||
chunks.retain(|&(x, z)| levels.random_ticking(x, z));
|
||||
for (cx, cz) in chunks {
|
||||
scratch
|
||||
.candidates
|
||||
.retain(|&(x, z)| levels.random_ticking(x, z));
|
||||
for (cx, cz) in scratch.candidates.drain(..) {
|
||||
if self.world.random.next_int_bound(100_000).unwrap_or(1) != 0 {
|
||||
continue;
|
||||
}
|
||||
@@ -77,6 +88,7 @@ impl Game<'_> {
|
||||
];
|
||||
self.spawn_bolt(at, trap);
|
||||
}
|
||||
self.spawn_scratch = scratch;
|
||||
}
|
||||
/// `ServerLevel.findLightningRod`: `PoiManager.findClosest` over the
|
||||
/// lightning rod points of interest within 128 blocks of `center` whose
|
||||
@@ -206,6 +218,9 @@ impl Game<'_> {
|
||||
seed,
|
||||
visual_only,
|
||||
converted: Vec::new(),
|
||||
hit: Vec::new(),
|
||||
fires: 0,
|
||||
cause: None,
|
||||
});
|
||||
}
|
||||
/// `ClientLevel.addEntity` for a server's lightning bolt: on a client it
|
||||
@@ -224,11 +239,16 @@ impl Game<'_> {
|
||||
seed,
|
||||
visual_only: true,
|
||||
converted: Vec::new(),
|
||||
hit: Vec::new(),
|
||||
fires: 0,
|
||||
cause: None,
|
||||
});
|
||||
}
|
||||
/// `spawnFire`: fire at the strike point and `extra` random neighbours,
|
||||
/// wherever `BaseFireBlock.getState` fire can survive.
|
||||
fn bolt_fire(&mut self, at: [f64; 3], extra: i32) {
|
||||
/// wherever `BaseFireBlock.getState` fire can survive. Returns how many
|
||||
/// blocks it set on fire.
|
||||
fn bolt_fire(&mut self, at: [f64; 3], extra: i32) -> i32 {
|
||||
let mut set = 0;
|
||||
let base = [
|
||||
at[0].floor() as i32,
|
||||
at[1].floor() as i32,
|
||||
@@ -253,8 +273,10 @@ impl Game<'_> {
|
||||
});
|
||||
if let Some(fire) = fire {
|
||||
let _ = self.level_set(p, fire);
|
||||
set += 1;
|
||||
}
|
||||
}
|
||||
set
|
||||
}
|
||||
/// `LightningBolt.tick` for every bolt.
|
||||
pub(super) fn tick_bolts(&mut self) {
|
||||
@@ -278,7 +300,7 @@ impl Game<'_> {
|
||||
if !b.visual_only {
|
||||
// Only Normal and Hard start fires with the strike itself.
|
||||
if self.ui.difficulty >= 2 {
|
||||
self.bolt_fire(b.at, 4);
|
||||
b.fires += self.bolt_fire(b.at, 4);
|
||||
}
|
||||
self.strike_rod(b.at);
|
||||
}
|
||||
@@ -293,6 +315,7 @@ impl Game<'_> {
|
||||
b.life -= 1;
|
||||
if b.life < 0 {
|
||||
if b.flashes == 0 {
|
||||
self.lightning_strike_triggers(b);
|
||||
return false;
|
||||
}
|
||||
if b.life < -self.world.random.next_int_bound(10).unwrap_or(0) {
|
||||
@@ -300,7 +323,7 @@ impl Game<'_> {
|
||||
b.life = 1;
|
||||
b.seed = self.world.random.next_long();
|
||||
if !b.visual_only {
|
||||
self.bolt_fire(b.at, 0);
|
||||
b.fires += self.bolt_fire(b.at, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,7 +333,11 @@ impl Game<'_> {
|
||||
}
|
||||
self.flash = 2;
|
||||
if !b.visual_only {
|
||||
self.thunder_hit(b);
|
||||
let struck = self.thunder_hit(b);
|
||||
if let Some(cause) = b.cause {
|
||||
let who = cause.map_or(Target::Player, Target::Guest);
|
||||
self.for_player(who, |g| g.trigger_channeled_lightning(&struck));
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
@@ -355,8 +382,41 @@ impl Game<'_> {
|
||||
self.with_level(|b, level, ticks| b.lightning_clear_copper(level, ticks, p));
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
/// `thunderHit` for entities within 3 blocks (up to 9 above).
|
||||
fn thunder_hit(&mut self, b: &mut Bolt) {
|
||||
/// `LightningStrikeTrigger` at the bolt's discard: every player within 256
|
||||
/// blocks, with the mobs in its 30-block box that it did not hit.
|
||||
fn lightning_strike_triggers(&mut self, b: &Bolt) {
|
||||
let at = b.at;
|
||||
let bolt = EntityView {
|
||||
kind: "minecraft:lightning_bolt".into(),
|
||||
position: at,
|
||||
blocks_set_on_fire: b.fires,
|
||||
..Default::default()
|
||||
};
|
||||
let viewers: Vec<EntityView> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive() && !b.hit.contains(&m.id))
|
||||
.filter(|m| {
|
||||
(m.position.x - at[0]).abs() <= 15.0
|
||||
&& (m.position.z - at[2]).abs() <= 15.0
|
||||
&& m.position.y >= at[1] - 15.0
|
||||
&& m.position.y <= at[1] + 21.0
|
||||
})
|
||||
.map(|m| self.mob_entity(m))
|
||||
.collect();
|
||||
self.for_each_player(|g| {
|
||||
let Some(p) = g.player.as_ref().map(|p| p.position) else {
|
||||
return;
|
||||
};
|
||||
let d2 = (p.x - at[0]).powi(2) + (p.y - at[1]).powi(2) + (p.z - at[2]).powi(2);
|
||||
if d2 < 256.0 * 256.0 {
|
||||
g.trigger_lightning_strike(&bolt, &viewers);
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `thunderHit` for entities within 3 blocks (up to 9 above). Returns the
|
||||
/// mobs in that box before the hit, as `CHANNELED_LIGHTNING` counts them.
|
||||
fn thunder_hit(&mut self, b: &mut Bolt) -> Vec<EntityView> {
|
||||
let at = b.at;
|
||||
let inside = move |p: [f64; 3]| {
|
||||
(p[0] - at[0]).abs() <= 3.0
|
||||
@@ -364,6 +424,13 @@ impl Game<'_> {
|
||||
&& p[1] >= at[1] - 3.0
|
||||
&& p[1] <= at[1] + 9.0
|
||||
};
|
||||
let struck: Vec<(u32, EntityView)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive() && inside([m.position.x, m.position.y, m.position.z]))
|
||||
.map(|m| (m.id, self.mob_entity(m)))
|
||||
.collect();
|
||||
b.hit.extend(struck.iter().map(|(id, _)| *id));
|
||||
let damage = Damage {
|
||||
kind: DamageKind::Generic,
|
||||
attacker: None,
|
||||
@@ -461,6 +528,7 @@ impl Game<'_> {
|
||||
!inside([e.position.x, e.position.y, e.position.z])
|
||||
|| e.stack.item.contains("netherite")
|
||||
});
|
||||
struck.into_iter().map(|(_, view)| view).collect()
|
||||
}
|
||||
/// Simulation-order render inputs; seeded geometry lives in the renderer.
|
||||
pub(super) fn bolt_instances(&self) -> Vec<horizon_client::gfx::lightning::Bolt> {
|
||||
|
||||
@@ -10,7 +10,11 @@ use horizon_client::{
|
||||
mobs::{Damage, Mob, Species},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
/// `MaceItem.SMASH_ATTACK_FALL_THRESHOLD`.
|
||||
@@ -38,6 +42,22 @@ pub(super) fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool {
|
||||
|| m.species() == Species::Allay && m.extra.tame
|
||||
}
|
||||
|
||||
/// The entity a sweep leaves out: the one the attack hit (`target` of
|
||||
/// `Player.doSweepAttack`).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum Spared {
|
||||
Nothing,
|
||||
Mob(u32),
|
||||
Stand(u32),
|
||||
/// A player of the open world, by entity id.
|
||||
Player(i32),
|
||||
}
|
||||
|
||||
/// `Player.doSweepAttack`'s `1 + ratio * f`, where `f` is the attack damage
|
||||
/// after `baseDamageScaleFactor` (0.2 + strength^2 * 0.8).
|
||||
fn sweep_base_damage(ratio: f32, attack_damage: f32, scale: f32) -> f32 {
|
||||
1.0 + ratio * attack_damage * (0.2 + scale * scale * 0.8)
|
||||
}
|
||||
/// A smash's knockback on the players, put off while a guest's turn holds
|
||||
/// the other players.
|
||||
pub(super) struct SmashPush {
|
||||
@@ -340,6 +360,136 @@ impl Game<'_> {
|
||||
resistance,
|
||||
);
|
||||
}
|
||||
/// `Player.doSweepAttack`'s victims: every other living mob and every
|
||||
/// armor stand (but a marker one) in `target`'s box inflated by
|
||||
/// (1, 0.25, 1) and under three blocks from the player takes
|
||||
/// `getEnchantedDamage(1 + sweeping ratio * attack damage) * strength`,
|
||||
/// knocked back 0.4 along the player's yaw. The player's allies and the
|
||||
/// target (`spared`) are spared.
|
||||
pub(super) fn sweep_damage(&mut self, target: Aabb, spared: Spared, scale: f32) {
|
||||
let Some(player) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let from = [player.position.x, player.position.y, player.position.z];
|
||||
let area = target.inflate(Vec3::new(1.0, 0.25, 1.0));
|
||||
let near = |at: Vec3| {
|
||||
(0..3)
|
||||
.map(|a| [at.x, at.y, at.z][a] - from[a])
|
||||
.map(|d| d * d)
|
||||
.sum::<f64>()
|
||||
< 9.0
|
||||
};
|
||||
// The enchantment's `sweeping_damage_ratio` attribute modifier (level /
|
||||
// (level + 1)) on the attribute's 0 to 1 range.
|
||||
let ratio = self
|
||||
.res
|
||||
.stations()
|
||||
.enchantments
|
||||
.attribute_add(
|
||||
self.inventory[self.selected].as_ref(),
|
||||
"minecraft:sweeping_damage_ratio",
|
||||
)
|
||||
.clamp(0.0, 1.0) as f32;
|
||||
// `f` of doSweepAttack: the attack damage attribute after
|
||||
// baseDamageScaleFactor (a sweep is never a smash or a critical hit).
|
||||
let spin = self.player.as_ref().map_or(0.0, |p| p.auto_spin_attack_dmg);
|
||||
let attribute = if spin > 0.0 {
|
||||
spin
|
||||
} else {
|
||||
1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage)
|
||||
+ self.effect_attack_bonus()
|
||||
};
|
||||
let base = sweep_base_damage(ratio, attribute, scale);
|
||||
let (xd, zd) = self.extra_knockback_direction();
|
||||
let uuid = self.level.player_uuid.or(Some([0, 0, 0, 1]));
|
||||
let allies = self.player_allies();
|
||||
// getEntitiesOfClass walks the entity sections (x, then z, then y)
|
||||
// under the box, each in the order its entities were added.
|
||||
let section = |at: Vec3| {
|
||||
(
|
||||
(at.x.floor() as i32) >> 4,
|
||||
(at.z.floor() as i32) >> 4,
|
||||
(at.y.floor() as i32) >> 4,
|
||||
)
|
||||
};
|
||||
let mut victims: Vec<((i32, i32, i32), u32, bool)> = Vec::new();
|
||||
for m in &self.mobs {
|
||||
if spared != Spared::Mob(m.id)
|
||||
&& m.alive()
|
||||
&& !m.removed
|
||||
&& !allies.contains(&m.id)
|
||||
&& !ally_of_player(m, uuid)
|
||||
&& m.bounds().intersects(area)
|
||||
&& near(m.position)
|
||||
{
|
||||
victims.push((section(m.position), m.id, false));
|
||||
}
|
||||
}
|
||||
for s in &self.armor_stands {
|
||||
if !s.marker
|
||||
&& spared != Spared::Stand(s.id)
|
||||
&& s.bounds().intersects(area)
|
||||
&& near(s.position)
|
||||
{
|
||||
victims.push((section(s.position), s.id, true));
|
||||
}
|
||||
}
|
||||
victims.sort_by_key(|&(key, id, _)| (key, id));
|
||||
for (_, id, is_stand) in victims {
|
||||
if is_stand {
|
||||
let Some(i) = self.armor_stands.iter().position(|s| s.id == id) else {
|
||||
continue;
|
||||
};
|
||||
// knockback(0.4, ..) follows a hurtServer that landed, on a
|
||||
// stand that is still there (LivingEntity.knockback).
|
||||
if !self.punch_armor_stand(i) {
|
||||
continue;
|
||||
}
|
||||
let (mut kx, mut kz) = (xd, zd);
|
||||
while kx * kx + kz * kz < f64::from(1.0e-5_f32) {
|
||||
let r = &mut self.world.random;
|
||||
kx = (r.next_double() - r.next_double()) * 0.01;
|
||||
kz = (r.next_double() - r.next_double()) * 0.01;
|
||||
}
|
||||
let len = (kx * kx + kz * kz).sqrt();
|
||||
if let Some(s) = self.armor_stands.iter_mut().find(|s| s.id == id) {
|
||||
s.velocity = Vec3::new(
|
||||
s.velocity.x / 2.0 - kx / len * 0.4,
|
||||
if s.on_ground {
|
||||
(s.velocity.y / 2.0 + 0.4).min(0.4)
|
||||
} else {
|
||||
s.velocity.y
|
||||
},
|
||||
s.velocity.z / 2.0 - kz / len * 0.4,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let kind = self.mobs[i].kind.id.clone();
|
||||
let kind = format!("minecraft:{}", kind.trim_start_matches("minecraft:"));
|
||||
let amount = self.enchanted_damage(&kind, base) * scale;
|
||||
let source = self.attack_source(from);
|
||||
// hurtServer first; the knockback and post-attack effects follow
|
||||
// only when it landed.
|
||||
if self.hurt_by_player(i, amount, source) {
|
||||
self.mobs[i].knockback(0.4, xd, zd);
|
||||
self.last_hurt_by_player
|
||||
.insert(self.mobs[i].id, self.lan_acting);
|
||||
self.post_attack(i);
|
||||
}
|
||||
}
|
||||
// `getEntitiesOfClass(LivingEntity.class)` takes the other players too.
|
||||
let mut sweep = self.player_sweep(base, scale);
|
||||
sweep.hit_box = area;
|
||||
sweep.skip = match spared {
|
||||
Spared::Player(id) => Some(id),
|
||||
_ => None,
|
||||
};
|
||||
self.sweep_other_players(sweep);
|
||||
}
|
||||
/// `ServerPlayer.checkFallDamage` after a smash on a grounded target:
|
||||
/// the landing block's particles, 50 for every block fallen (200 at most).
|
||||
pub(super) fn smash_landing_particles(
|
||||
@@ -409,3 +559,19 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sweep_damage_is_one_without_the_enchantment() {
|
||||
// A fully charged diamond sword (8 attack damage): neighbours take 1.
|
||||
assert_eq!(sweep_base_damage(0.0, 8.0, 1.0), 1.0);
|
||||
// Sweeping Edge III adds the ratio of the (charged) attack damage.
|
||||
assert_eq!(sweep_base_damage(0.75, 8.0, 1.0), 7.0);
|
||||
// baseDamageScaleFactor at 0.95 strength.
|
||||
let scaled = sweep_base_damage(0.5, 8.0, 0.95);
|
||||
assert!((scaled - (1.0 + 0.5 * 8.0 * (0.2 + 0.95 * 0.95 * 0.8))).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -595,6 +595,18 @@ impl Game<'_> {
|
||||
/// `HopperBlockEntity.pushItemsTick` of the hopper at `pos`, if its block
|
||||
/// is still a hopper (`BlockEntityType.isValid`).
|
||||
pub(super) fn tick_hopper(&mut self, pos: Pos) {
|
||||
let time = self.world.game_time;
|
||||
let waiting = {
|
||||
let state = self.hoppers.entry(pos).or_default();
|
||||
state.cooldown -= 1;
|
||||
state.ticked = time;
|
||||
state.cooldown > 0
|
||||
};
|
||||
if waiting {
|
||||
return;
|
||||
}
|
||||
// Most ticks only advance the cooldown: the block is checked when the
|
||||
// hopper is about to move, not on every tick it waits.
|
||||
let hopper = self
|
||||
.res
|
||||
.facts()
|
||||
@@ -606,14 +618,10 @@ impl Game<'_> {
|
||||
self.hoppers.remove(&pos);
|
||||
return;
|
||||
}
|
||||
let time = self.world.game_time;
|
||||
let state = self.hoppers.entry(pos).or_default();
|
||||
state.cooldown -= 1;
|
||||
state.ticked = time;
|
||||
if state.cooldown <= 0 {
|
||||
if let Some(state) = self.hoppers.get_mut(&pos) {
|
||||
state.cooldown = 0;
|
||||
self.hopper_try_move_items(pos);
|
||||
}
|
||||
self.hopper_try_move_items(pos);
|
||||
}
|
||||
/// Drops the cooldowns of hoppers whose block entity is gone.
|
||||
pub(super) fn forget_gone_hoppers(&mut self) {
|
||||
|
||||
@@ -739,6 +739,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
let at = [x, y, z].map(|v| f64::from(v) + 0.5);
|
||||
self.trigger_block("minecraft:item_used_on_block", hit.pos, stack);
|
||||
self.audio
|
||||
.play("minecraft:item.lodestone_compass.lock", Some(at), 1.0, 1.0);
|
||||
let target = Tag::compound([(
|
||||
|
||||
@@ -202,7 +202,7 @@ impl Minecart {
|
||||
self.velocity.x.hypot(self.velocity.z)
|
||||
}
|
||||
/// `AbstractContainerMenu.getRedstoneSignalFromContainer` of `slots`.
|
||||
fn container_signal(slots: &[Option<ItemStack>], items: &Items) -> i32 {
|
||||
pub(super) fn container_signal(slots: &[Option<ItemStack>], items: &Items) -> i32 {
|
||||
let mut total = 0.0f32;
|
||||
for s in slots.iter().flatten() {
|
||||
let max = items.get(&s.item).map_or(64, |i| i.max_stack).min(64);
|
||||
@@ -1029,21 +1029,16 @@ impl Game<'_> {
|
||||
}
|
||||
best
|
||||
}
|
||||
/// Each cart's kind and comparator signal, for the detector rail
|
||||
/// (`DetectorRailBlock.getAnalogOutputSignal`). A cart whose menu is open
|
||||
/// counts the items in that menu.
|
||||
pub(super) fn cart_signals(&self) -> Vec<(CartKind, i32)> {
|
||||
self.carts
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
|
||||
let slots = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::Chest(open)) => open,
|
||||
_ => &c.items,
|
||||
};
|
||||
(c.kind, Minecart::container_signal(slots, self.res.items()))
|
||||
})
|
||||
.collect()
|
||||
/// `ContainerEntity.unpackChestVehicleLootTable` for a player who opens a
|
||||
/// chest minecart: a roll it still holds fires `player_generates_container_loot`
|
||||
/// for its table, and the cart keeps its items from then on.
|
||||
fn unpack_cart_loot(&mut self, i: usize) {
|
||||
let Some(loot) = self.carts[i].loot.take() else {
|
||||
return;
|
||||
};
|
||||
if loot.items == horizon_client::save::encode_items(&self.carts[i].items) {
|
||||
self.trigger_loot_table(&loot.table);
|
||||
}
|
||||
}
|
||||
/// `interact`: ride, open, fuel or ignite the cart under the crosshair.
|
||||
pub(super) fn interact_cart(&mut self) -> bool {
|
||||
@@ -1064,6 +1059,7 @@ impl Game<'_> {
|
||||
}
|
||||
// AbstractMinecartContainer.interact: no sneak check.
|
||||
CartKind::Chest | CartKind::Hopper => {
|
||||
self.unpack_cart_loot(i);
|
||||
let c = self.carts[i].clone();
|
||||
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
|
||||
self.world
|
||||
@@ -1163,7 +1159,7 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
/// `VehicleEntity.hurtServer` from the player's attack.
|
||||
pub(super) fn attack_cart(&mut self, damage: f32) -> bool {
|
||||
pub(super) fn attack_cart(&mut self) -> bool {
|
||||
let Some((i, d)) = self.cart_hit() else {
|
||||
return false;
|
||||
};
|
||||
@@ -1173,8 +1169,12 @@ impl Game<'_> {
|
||||
let burning = self
|
||||
.held()
|
||||
.is_some_and(|s| s.enchantment_level("minecraft:fire_aspect") > 0);
|
||||
self.vehicle_attack_sounds();
|
||||
let area = self.carts[i].bounds();
|
||||
// Player.attack's full damage against the cart's entity type.
|
||||
let id = self.carts[i].id as i32;
|
||||
let (damage, _, _, magic) = self.melee_damage(self.carts[i].kind.id(), false);
|
||||
self.hurt_cart(i, damage, self.creative, burning);
|
||||
self.vehicle_attack_sounds(area, magic.then_some(id));
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
@@ -1475,6 +1475,7 @@ impl Game<'_> {
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|c| self.gui.atlas.glyph_cell(c),
|
||||
glyphs: self.gui.atlas.font.epoch(),
|
||||
};
|
||||
self.res
|
||||
.block_entity_render()
|
||||
|
||||
@@ -130,6 +130,7 @@ impl Game<'_> {
|
||||
&& let Some(name) = horizon_client::keybinds::key_name(key)
|
||||
&& let Some(stack) = self.mod_browser_pointed()
|
||||
{
|
||||
let stack = super::mod_panel::browser_stack(stack);
|
||||
self.mods.mods.browser_key(name, &stack);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Capability `ui`'s item panel of a WebAssembly mod (WIT `interface
|
||||
//! browser`, docs/mods.md): its items and recipe page as the native panel's
|
||||
//! (`native_panel.rs`), and the panel's events back in the runtime's types.
|
||||
use super::{
|
||||
Game,
|
||||
native_hooks::{
|
||||
NativePanelEvent as BrowserEvent, NativeRecipeLayout, NativeRecipeView,
|
||||
NativeStack as BrowserStack,
|
||||
},
|
||||
native_panel::{Owner, Shown},
|
||||
native_panel_recipes::RecipePage,
|
||||
};
|
||||
use horizon_mods as m;
|
||||
|
||||
/// A WebAssembly mod's panel, converted once per `set-items` or
|
||||
/// `set-recipes`.
|
||||
impl Game<'_> {
|
||||
pub(super) fn panel_wasm_sync(&mut self) {
|
||||
let Some((index, version, items)) = self.mods.mods.browser_items() else {
|
||||
self.panel.wasm = None;
|
||||
return;
|
||||
};
|
||||
let owner = Owner::Wasm(index);
|
||||
let wasm = self.panel.wasm.as_ref();
|
||||
if wasm.is_some_and(|s| s.owner == owner && s.version == version) {
|
||||
return;
|
||||
}
|
||||
let items = items.iter().map(stack).collect();
|
||||
let recipes = self.mods.mods.browser_recipes(index).map(page);
|
||||
self.panel.wasm = Some(Shown {
|
||||
owner,
|
||||
version,
|
||||
items,
|
||||
recipes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn stack(s: &m::BrowserStack) -> BrowserStack {
|
||||
BrowserStack {
|
||||
item: s.item.clone(),
|
||||
count: s.count,
|
||||
components: s.components.clone(),
|
||||
}
|
||||
}
|
||||
/// A native stack as the runtime's (`browser_key`).
|
||||
pub(super) fn browser_stack(s: BrowserStack) -> m::BrowserStack {
|
||||
m::BrowserStack {
|
||||
item: s.item,
|
||||
count: s.count,
|
||||
components: s.components,
|
||||
}
|
||||
}
|
||||
fn layout(l: m::BrowserLayout) -> NativeRecipeLayout {
|
||||
match l {
|
||||
m::BrowserLayout::Grid => NativeRecipeLayout::Grid,
|
||||
m::BrowserLayout::Cooking => NativeRecipeLayout::Cooking,
|
||||
m::BrowserLayout::Single => NativeRecipeLayout::Single,
|
||||
m::BrowserLayout::Smithing => NativeRecipeLayout::Smithing,
|
||||
m::BrowserLayout::Brewing => NativeRecipeLayout::Brewing,
|
||||
}
|
||||
}
|
||||
fn stacks(list: &[m::BrowserStack]) -> Vec<BrowserStack> {
|
||||
list.iter().map(stack).collect()
|
||||
}
|
||||
pub(super) fn page(p: &m::RecipePage) -> RecipePage {
|
||||
RecipePage {
|
||||
title: p.title.clone(),
|
||||
tabs: stacks(&p.tabs),
|
||||
tab: p.tab,
|
||||
recipes: p
|
||||
.recipes
|
||||
.iter()
|
||||
.map(|r| NativeRecipeView {
|
||||
layout: layout(r.layout),
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
slots: r.slots.iter().map(|s| stacks(s)).collect(),
|
||||
results: stacks(&r.results),
|
||||
note: r.note.clone(),
|
||||
action: r.action,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
pub(super) fn event(e: BrowserEvent) -> m::BrowserEvent {
|
||||
match e {
|
||||
BrowserEvent::Clicked { index, right } => {
|
||||
m::BrowserEvent::Clicked(m::BrowserClick { index, right })
|
||||
}
|
||||
BrowserEvent::Search(text) => m::BrowserEvent::Search(text),
|
||||
// Not sent to WebAssembly mods: they hear their own keys
|
||||
// (`browser_key`).
|
||||
BrowserEvent::Key { key, stack } => m::BrowserEvent::Key(m::BrowserItemKey {
|
||||
key,
|
||||
stack: browser_stack(stack),
|
||||
}),
|
||||
BrowserEvent::SlotClicked {
|
||||
recipe,
|
||||
slot,
|
||||
stack,
|
||||
right,
|
||||
} => m::BrowserEvent::SlotClicked(m::BrowserSlotClick {
|
||||
recipe,
|
||||
slot,
|
||||
stack: browser_stack(stack),
|
||||
right,
|
||||
}),
|
||||
BrowserEvent::Action(i) => m::BrowserEvent::Action(i),
|
||||
BrowserEvent::Page(i) => m::BrowserEvent::Page(i),
|
||||
BrowserEvent::Back => m::BrowserEvent::Back,
|
||||
BrowserEvent::Tab(i) => m::BrowserEvent::Tab(i),
|
||||
}
|
||||
}
|
||||
@@ -92,12 +92,6 @@ pub(super) struct ModsState {
|
||||
/// Capability `recipes`'s tables, made once a mod reads them.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) recipes: Option<std::sync::Arc<horizon_mods::Recipes>>,
|
||||
/// The recipe book's known displays, and the book's version they are of.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) known_recipes: (Option<u64>, std::sync::Arc<[i32]>),
|
||||
/// Capability `ui`'s item panel beside container screens.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) browser: super::mod_browser::Browser,
|
||||
/// Extras that are official mods now, being installed.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) moved: super::moved_extras::MovedState,
|
||||
@@ -267,6 +261,7 @@ impl Game<'_> {
|
||||
"Horizon: {found} mod(s) not started: this build has no mod runtime (build with --features mods)"
|
||||
);
|
||||
}
|
||||
self.list_native_mods();
|
||||
}
|
||||
pub(super) fn merge_remote_tree(&mut self) {}
|
||||
pub(super) fn sync_mods(&mut self) {}
|
||||
@@ -302,8 +297,14 @@ impl Game<'_> {
|
||||
.filter_map(|p| horizon_mods::Package::read(p).ok())
|
||||
.map(|p| p.manifest.id)
|
||||
.collect();
|
||||
// A native mod of the same id (one built into this game) takes over.
|
||||
let mut native = Vec::new();
|
||||
self.native_mods(&mut native);
|
||||
let enabled = &self.settings.mods.enabled;
|
||||
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
|
||||
let on = |id: &str| {
|
||||
(enabled.contains(id) || dev_ids.iter().any(|d| d == id))
|
||||
&& !native.iter().any(|n| n.id == id)
|
||||
};
|
||||
let mods = Mods::load_for(self.mod_runs(), &paths, on, vanilla_command);
|
||||
self.mods.mods = mods;
|
||||
let chosen = &self.settings.mods;
|
||||
@@ -349,6 +350,7 @@ impl Game<'_> {
|
||||
problem,
|
||||
dev: false,
|
||||
has_settings: false,
|
||||
native: None,
|
||||
};
|
||||
};
|
||||
// What it may do first: the row cuts long descriptions.
|
||||
@@ -365,9 +367,11 @@ impl Game<'_> {
|
||||
problem,
|
||||
dev: dev.contains(&e.path),
|
||||
has_settings: e.has_ui_settings(),
|
||||
native: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.list_native_mods();
|
||||
}
|
||||
/// The chat's tree after the running mods changed.
|
||||
fn sync_mod_commands(&mut self) {
|
||||
|
||||
@@ -259,6 +259,7 @@ impl Game<'_> {
|
||||
Species::CamelHusk => "minecraft:entity.camel_husk.eat",
|
||||
Species::Donkey => "minecraft:entity.donkey.eat",
|
||||
Species::Mule => "minecraft:entity.mule.eat",
|
||||
Species::Horse if m.is_baby() => "minecraft:entity.baby_horse.eat",
|
||||
_ => "minecraft:entity.horse.eat",
|
||||
};
|
||||
let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
|
||||
@@ -337,7 +338,16 @@ impl Game<'_> {
|
||||
}
|
||||
// The nautilus plays its dash sound itself.
|
||||
if equine::is_equine(m.species()) {
|
||||
let sound = "minecraft:entity.horse.jump";
|
||||
// playJumpSound: Donkey and Mule have their own; a skeleton
|
||||
// horse in water splashes.
|
||||
let sound = match m.species() {
|
||||
Species::Donkey => "minecraft:entity.donkey.jump",
|
||||
Species::Mule => "minecraft:entity.mule.jump",
|
||||
Species::SkeletonHorse if m.fluids.in_water => {
|
||||
"minecraft:entity.skeleton_horse.jump_water"
|
||||
}
|
||||
_ => "minecraft:entity.horse.jump",
|
||||
};
|
||||
let at = m.center();
|
||||
self.audio.play(sound, Some(at), 0.4, 1.0);
|
||||
}
|
||||
|
||||
@@ -45,14 +45,28 @@ impl Game<'_> {
|
||||
};
|
||||
self.audio.tick(self.pause, &ctx);
|
||||
}
|
||||
/// `Minecraft.setLevel` -> `updateLevelInEngines`: `SoundManager.stop()`.
|
||||
/// The music manager keeps its stopped track and, on its next tick, waits
|
||||
/// the level's music delay before the next one.
|
||||
pub(super) fn stop_sounds_for_new_level(&mut self) {
|
||||
self.ambience.clear(&mut self.audio);
|
||||
self.audio.stop_all();
|
||||
}
|
||||
/// `getSituationalMusic` and `getMusicVolume`.
|
||||
fn situational_music(&self) -> (Option<Music>, f32) {
|
||||
pub(super) fn situational_music(&self) -> (Option<Music>, f32) {
|
||||
let empty = BackgroundMusic::default();
|
||||
// The title screen has no player (and no level for the camera probe).
|
||||
// A server's client has its player from handleLogin on, before the
|
||||
// first teleport makes it here: the camera is still at the origin.
|
||||
let eye = if self.title.is_some() {
|
||||
None
|
||||
} else {
|
||||
self.eye_position()
|
||||
self.eye_position().or_else(|| {
|
||||
self.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.logged_in)
|
||||
.then_some([0.0; 3])
|
||||
})
|
||||
};
|
||||
let (background, probe_volume) = match eye {
|
||||
Some(eye) => {
|
||||
|
||||
@@ -81,10 +81,13 @@ impl Game<'_> {
|
||||
let viewer = self.player_team();
|
||||
let mut tags = Vec::new();
|
||||
let players = self.other_players();
|
||||
let named = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter_map(|m| Some((m, m.custom_name.clone()?, false)));
|
||||
let named = self.mobs.iter().filter_map(|m| {
|
||||
let name = m.custom_name.as_ref()?;
|
||||
// The tag's 64 block distance is measured to the entity: copy the
|
||||
// name only for a mob the frame can show it on.
|
||||
let at = glam::DVec3::new(m.position.x, m.position.y, m.position.z);
|
||||
(at.distance_squared(camera) < 64.0 * 64.0).then(|| (m, name.clone(), false))
|
||||
});
|
||||
let players = players.into_iter().map(|(m, name)| (m, name, true));
|
||||
// Who is riding whom: asked once per tag below, and rescanning every mob
|
||||
// for each tag made this quadratic in the number of loaded mobs.
|
||||
@@ -328,7 +331,15 @@ impl Game<'_> {
|
||||
) {
|
||||
// Options.getBackgroundOpacity(0.25) black, as ARGB.color.
|
||||
const BACKGROUND: u32 = 0x3F00_0000;
|
||||
let forward = glam::DVec3::from_array(view_vector(self.yaw, self.pitch));
|
||||
// The camera's facing (Camera.rotation()), turned round in third-person
|
||||
// front view, so the text reads the right way from where it is seen.
|
||||
let (view_yaw, view_pitch) =
|
||||
if self.camera_type == super::perspective::CameraType::ThirdPersonFront {
|
||||
(self.yaw + 180.0, -self.pitch)
|
||||
} else {
|
||||
(self.yaw, self.pitch)
|
||||
};
|
||||
let forward = glam::DVec3::from_array(view_vector(view_yaw, view_pitch));
|
||||
let right = forward.cross(glam::DVec3::Y).normalize_or_zero();
|
||||
let up = right.cross(forward).normalize_or_zero();
|
||||
let white = self.gui.atlas.font.white();
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
//! Hook points for native mods (docs/native-mods.md, "Hook points"): empty
|
||||
//! functions the game calls at fixed places, for horizon-mixins bundles to
|
||||
//! hook with HEAD. Hooks that answer take an out parameter (`claimed`,
|
||||
//! `zoom`…) so several mods can hook the same point. A native mod keeps its
|
||||
//! own state in a module of its bundle; the helpers below read and draw what
|
||||
//! Horizon's own HUD extras do, and keep a mod's settings where the
|
||||
//! WebAssembly mod of the same id kept them.
|
||||
//!
|
||||
//! API 2 adds the item panel beside container screens and its recipe pages
|
||||
//! (`native_panel_items`, `native_panel_recipes`, `native_panel_event`),
|
||||
//! and the game's items and recipes (`native_recipes`,
|
||||
//! `native_known_recipes`, `native_place_recipe`, `native_give`): what a
|
||||
//! recipe viewer needs.
|
||||
//!
|
||||
//! Names, parameters and call sites are kept from release to release, and
|
||||
//! hook points are only ever added: each addition raises [`NATIVE_HOOKS_API`].
|
||||
//! A bundle that hooks only these names the API it needs (`hooks`), and the
|
||||
//! launcher builds it on any Horizon whose API is at least that one. A change
|
||||
//! that would break such a bundle is not made.
|
||||
//! Bodies have no `cfg` and no macros: horizon-mixins refuses a target with
|
||||
//! either (`tests::hook_points_stay_hookable`).
|
||||
use super::{Game, Screen, execution_role::ExecutionRole, native_panel_recipes::RecipePage};
|
||||
use horizon_client::{
|
||||
chat::ChatSource,
|
||||
extras::Corner,
|
||||
extras_hud::Corners,
|
||||
hud::{HudBatch, rgba},
|
||||
keybinds,
|
||||
mod_list::{self, Installed, NativePage},
|
||||
text::Text,
|
||||
};
|
||||
use std::{fs, path::PathBuf, sync::Arc, time::Duration};
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
|
||||
/// The hook points' API version. The launcher reads this line from the
|
||||
/// source it builds; keep its form.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 2;
|
||||
|
||||
/// The zoom a native mod asks for (`native_zoom`): the factor (1 is none),
|
||||
/// eased through the field of view like the spyglass when `smooth`, and the
|
||||
/// scale of mouse turning.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct NativeZoom {
|
||||
pub(crate) factor: f32,
|
||||
pub(crate) smooth: bool,
|
||||
pub(crate) turn: f32,
|
||||
}
|
||||
|
||||
/// The minimap a native mod shows (`native_minimap`), drawn by Horizon's
|
||||
/// minimap: its corner, its side in GUI pixels, blocks per map pixel,
|
||||
/// turning with the player or north up, round or square.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct NativeMinimap {
|
||||
pub(crate) corner: Corner,
|
||||
pub(crate) size: f32,
|
||||
pub(crate) blocks_per_pixel: f32,
|
||||
pub(crate) rotate: bool,
|
||||
pub(crate) round: bool,
|
||||
}
|
||||
|
||||
/// The player as a native mod reads it.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct NativePlayer {
|
||||
pub(crate) x: f64,
|
||||
pub(crate) y: f64,
|
||||
pub(crate) z: f64,
|
||||
/// Degrees; 0 faces south, 90 west (`Direction.fromYRot`).
|
||||
pub(crate) yaw: f32,
|
||||
pub(crate) pitch: f32,
|
||||
/// `minecraft:overworld`, `minecraft:the_nether`, `minecraft:the_end`.
|
||||
pub(crate) dimension: &'static str,
|
||||
}
|
||||
|
||||
/// A native mod as Options > Mods lists it (`native_mods`).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct NativeMod {
|
||||
/// Its id: its settings folder and its keys' lines.
|
||||
pub(crate) id: String,
|
||||
/// `Name 1.0`.
|
||||
pub(crate) title: String,
|
||||
pub(crate) description: String,
|
||||
/// Its keys: name, label and default key (`key.keyboard.z`). The page
|
||||
/// lists them first; `native_mod_key` reads the player's choice.
|
||||
pub(crate) keys: Vec<[String; 3]>,
|
||||
}
|
||||
|
||||
/// An item the panel shows (`native_panel_items`): its id
|
||||
/// (`minecraft:diamond`), a count (1 draws none) and its components beyond
|
||||
/// the item's own, in vanilla's item syntax, or "".
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct NativeStack {
|
||||
pub(crate) item: String,
|
||||
pub(crate) count: u32,
|
||||
pub(crate) components: String,
|
||||
}
|
||||
|
||||
/// How the panel lays out a recipe of `native_panel_recipes`.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum NativeRecipeLayout {
|
||||
/// A crafting grid of `width` x `height` slots, an arrow and the result.
|
||||
Grid,
|
||||
/// One input over a flame, an arrow and the result (furnaces, smokers,
|
||||
/// campfires).
|
||||
Cooking,
|
||||
/// One input, an arrow and the result (the stonecutter).
|
||||
Single,
|
||||
/// Template, base and addition, an arrow and the result.
|
||||
Smithing,
|
||||
/// A potion and an ingredient, an arrow and the result.
|
||||
Brewing,
|
||||
}
|
||||
|
||||
/// A recipe the panel shows (`native_panel_recipes`). Each slot, and the
|
||||
/// result, cycles through its stacks, one a second; an empty slot shows
|
||||
/// nothing.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct NativeRecipeView {
|
||||
pub(crate) layout: NativeRecipeLayout,
|
||||
/// The grid's size (`Grid` only), at most 3 x 3.
|
||||
pub(crate) width: u32,
|
||||
pub(crate) height: u32,
|
||||
pub(crate) slots: Vec<Vec<NativeStack>>,
|
||||
pub(crate) results: Vec<NativeStack>,
|
||||
/// A short line under the recipe (a cooking time), or "".
|
||||
pub(crate) note: String,
|
||||
/// The action button beside it: none, enabled, or shown greyed.
|
||||
pub(crate) action: Option<bool>,
|
||||
}
|
||||
|
||||
/// What the player did on a native mod's panel (`native_panel_event`).
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum NativePanelEvent {
|
||||
/// A click on an item of the panel, by its index in
|
||||
/// `native_panel_items`' list.
|
||||
Clicked { index: u32, right: bool },
|
||||
/// The search box's text, each time the player changes it.
|
||||
Search(String),
|
||||
/// A key (`key.keyboard.r`; compare it with `native_mod_key`) went down
|
||||
/// while the pointer was on an item: a slot of the screen's, or the
|
||||
/// panel's.
|
||||
Key { key: String, stack: NativeStack },
|
||||
/// A click on a slot of a recipe the panel shows: the recipe's index in
|
||||
/// `native_panel_recipes`' list and the slot's in it (the results come
|
||||
/// after the slots), and the stack it showed then.
|
||||
SlotClicked {
|
||||
recipe: u32,
|
||||
slot: u32,
|
||||
stack: NativeStack,
|
||||
right: bool,
|
||||
},
|
||||
/// The action button of the recipe at this index.
|
||||
Action(u32),
|
||||
/// The player turned the recipes' page: the index of the first recipe
|
||||
/// now shown.
|
||||
Page(u32),
|
||||
/// The recipe page's back button.
|
||||
Back,
|
||||
/// The player picked the tab at this index.
|
||||
Tab(u32),
|
||||
}
|
||||
|
||||
/// An item of [`NativeRecipes::items`], by its index there, with a count
|
||||
/// and the components it has beyond the item's own, in vanilla's item
|
||||
/// syntax (`[minecraft:potion_contents={potion:"minecraft:swiftness"}]`),
|
||||
/// or "".
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct NativeRecipeStack {
|
||||
pub(crate) item: u32,
|
||||
pub(crate) count: u32,
|
||||
pub(crate) components: String,
|
||||
}
|
||||
|
||||
/// The station a recipe is made at.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum NativeRecipeKind {
|
||||
CraftingShaped,
|
||||
CraftingShapeless,
|
||||
Smelting,
|
||||
Blasting,
|
||||
Smoking,
|
||||
Campfire,
|
||||
Stonecutting,
|
||||
Smithing,
|
||||
Brewing,
|
||||
}
|
||||
|
||||
/// A recipe of [`NativeRecipes`].
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct NativeRecipe {
|
||||
/// `minecraft:oak_planks`.
|
||||
pub(crate) id: String,
|
||||
pub(crate) kind: NativeRecipeKind,
|
||||
/// The recipe book's tab (`crafting_building_blocks`, `furnace_food`),
|
||||
/// or "".
|
||||
pub(crate) category: String,
|
||||
/// Recipes the book shows on one button share a group, or "".
|
||||
pub(crate) group: String,
|
||||
/// A shaped recipe's grid: `slots` holds its cells row by row. 0 for
|
||||
/// the other kinds.
|
||||
pub(crate) width: u32,
|
||||
pub(crate) height: u32,
|
||||
/// Each slot's stacks, any one of which fits; an empty list is an empty
|
||||
/// cell. Cooking and stonecutting: the input. Smithing: the template,
|
||||
/// the base, the addition. Brewing: the bottle, the reagent.
|
||||
pub(crate) slots: Vec<Vec<NativeRecipeStack>>,
|
||||
/// What it makes; several when that varies (a trim on any armour).
|
||||
pub(crate) results: Vec<NativeRecipeStack>,
|
||||
/// Cooking: game ticks and experience; 0 for the other kinds.
|
||||
pub(crate) cooking_time: u32,
|
||||
pub(crate) experience: f32,
|
||||
/// The recipe book's id for it (one per way the book shows it), as
|
||||
/// `native_known_recipes` lists them; -1 for the kinds the book doesn't
|
||||
/// show (campfire, stonecutting, smithing, brewing).
|
||||
pub(crate) book_id: i32,
|
||||
}
|
||||
|
||||
/// An item of [`NativeRecipes::items`].
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct NativeItem {
|
||||
/// `minecraft:diamond`, `<mod>:<path>`.
|
||||
pub(crate) id: String,
|
||||
/// In the player's language.
|
||||
pub(crate) name: String,
|
||||
/// Its item tags, without the `#`.
|
||||
pub(crate) tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Every item and recipe the game knows (`native_recipes`): `items` in the
|
||||
/// registry's order, which each [`NativeRecipeStack::item`] indexes; the
|
||||
/// recipes are the book's in its order, then campfire, stonecutting,
|
||||
/// smithing and brewing.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct NativeRecipes {
|
||||
pub(crate) items: Vec<NativeItem>,
|
||||
pub(crate) recipes: Vec<NativeRecipe>,
|
||||
}
|
||||
|
||||
/// The mappings `native_mapping` answers for, with the key their default
|
||||
/// binding is (none: a mouse button, attack or use).
|
||||
#[allow(dead_code)]
|
||||
const MAPPINGS: [(&str, Option<KeyCode>); 9] = [
|
||||
("key.forward", Some(KeyCode::KeyW)),
|
||||
("key.left", Some(KeyCode::KeyA)),
|
||||
("key.back", Some(KeyCode::KeyS)),
|
||||
("key.right", Some(KeyCode::KeyD)),
|
||||
("key.jump", Some(KeyCode::Space)),
|
||||
("key.sneak", Some(KeyCode::ShiftLeft)),
|
||||
("key.sprint", Some(KeyCode::ControlLeft)),
|
||||
("key.attack", None),
|
||||
("key.use", None),
|
||||
];
|
||||
|
||||
/// The hook points. Each does nothing in Horizon.
|
||||
impl Game<'_> {
|
||||
/// Once a frame, in a world or not, after Horizon's extras.
|
||||
pub(super) fn native_frame(&mut self, elapsed: Duration) {
|
||||
let _ = elapsed;
|
||||
}
|
||||
/// Once a tick, in every `Game`: the window's (in single player, on LAN
|
||||
/// and on servers), and the server's own (`native_window` is false
|
||||
/// there: the single player server thread, a dedicated server).
|
||||
pub(super) fn native_tick(&mut self) {}
|
||||
/// The HUD (not drawn under F1), after the minimap: place a box with
|
||||
/// `corners.place` (boxes stack away from their edge) and draw it with
|
||||
/// `native_fill` and `native_text`. Skip a corner `native_corner_shown`
|
||||
/// says is taken.
|
||||
pub(super) fn native_hud(&mut self, batch: &mut HudBatch, corners: &mut Corners) {
|
||||
let _ = (batch, corners);
|
||||
}
|
||||
/// A key or mouse button went down or up, by its vanilla name
|
||||
/// (`key.keyboard.z`, `key.mouse.left`): keys on any screen (check
|
||||
/// `native_playing`), mouse buttons while playing. Repeats are left out.
|
||||
pub(super) fn native_key(&mut self, key: &str, pressed: bool) {
|
||||
let _ = (key, pressed);
|
||||
}
|
||||
/// Wheel steps while playing: set `claimed` to keep them from the hotbar.
|
||||
pub(super) fn native_wheel(&mut self, steps: f32, claimed: &mut bool) {
|
||||
let _ = (steps, claimed);
|
||||
}
|
||||
/// A command typed or clicked in chat, without its slash, before the
|
||||
/// server and Horizon's commands see it: set `ran` when the mod ran it.
|
||||
pub(super) fn native_command(&mut self, command: &str, ran: &mut bool) {
|
||||
let _ = (command, ran);
|
||||
}
|
||||
/// The zoom a native mod asks for, applied while playing (the spyglass's
|
||||
/// own view wins): set `zoom` when it is none yet.
|
||||
pub(super) fn native_zoom(&self, zoom: &mut Option<NativeZoom>) {
|
||||
let _ = zoom;
|
||||
}
|
||||
/// The minimap a native mod shows: set `minimap` when it is none yet.
|
||||
pub(super) fn native_minimap(&self, minimap: &mut Option<NativeMinimap>) {
|
||||
let _ = minimap;
|
||||
}
|
||||
/// Options > Mods lists the native mods: push yours.
|
||||
pub(super) fn native_mods(&mut self, mods: &mut Vec<NativeMod>) {
|
||||
let _ = mods;
|
||||
}
|
||||
/// The buttons of mod `mod_id`'s settings page, under its keys: push
|
||||
/// their labels when it is yours. Asked again after each click.
|
||||
pub(super) fn native_settings(&mut self, mod_id: &str, buttons: &mut Vec<String>) {
|
||||
let _ = (mod_id, buttons);
|
||||
}
|
||||
/// Button `button` of mod `mod_id`'s settings page was clicked.
|
||||
pub(super) fn native_settings_click(&mut self, mod_id: &str, button: usize) {
|
||||
let _ = (mod_id, button);
|
||||
}
|
||||
/// The player did `event` on mod `mod_id`'s panel (`native_panel_items`):
|
||||
/// act on it when it is yours. API 2.
|
||||
pub(super) fn native_panel_event(&mut self, mod_id: &str, event: &NativePanelEvent) {
|
||||
let _ = (mod_id, event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers for native mods, kept as stable as the hook points.
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// Whether the player left mod `mod_id` on in Options > Mods. A mod
|
||||
/// turned off does nothing (check this first in each hook) until the
|
||||
/// launcher's next build leaves it out.
|
||||
pub(super) fn native_on(&self, mod_id: &str) -> bool {
|
||||
!self.settings.mods.disabled.contains(mod_id)
|
||||
}
|
||||
/// Playing, with nothing over the world: keys and zoom apply.
|
||||
pub(super) fn native_playing(&self) -> bool {
|
||||
self.screen == Screen::Playing
|
||||
&& self.title.is_none()
|
||||
&& self.sign_edit.is_none()
|
||||
&& self.book.is_none()
|
||||
}
|
||||
/// This `Game` is the window's, not a server's.
|
||||
pub(super) fn native_window(&self) -> bool {
|
||||
self.options.execution_role == ExecutionRole::Client
|
||||
}
|
||||
/// Whether a box in `corner` shows: F3's debug screen takes the top left.
|
||||
pub(super) fn native_corner_shown(&self, corner: Corner) -> bool {
|
||||
!self.debug || corner != Corner::TopLeft
|
||||
}
|
||||
/// The width of `text` in GUI pixels.
|
||||
pub(super) fn native_text_width(&self, text: &str) -> f32 {
|
||||
HudBatch::text_width(&self.gui.atlas, text)
|
||||
}
|
||||
/// A rectangle in GUI pixels, `argb` (0x78000000 is the extras' box).
|
||||
pub(super) fn native_fill(&self, batch: &mut HudBatch, rect: [f32; 4], argb: u32) {
|
||||
let [x, y, w, h] = rect;
|
||||
let [a, r, g, b] = argb.to_be_bytes();
|
||||
batch.fill(&self.gui.atlas, x, y, w, h, rgba(r, g, b, a));
|
||||
}
|
||||
/// A line of text at `(x, y)` in GUI pixels, opaque `rgb`.
|
||||
pub(super) fn native_text(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
text: &str,
|
||||
at: [f32; 2],
|
||||
rgb: u32,
|
||||
shadow: bool,
|
||||
) {
|
||||
let [x, y] = at;
|
||||
let [_, r, g, b] = rgb.to_be_bytes();
|
||||
let color = rgba(r, g, b, 0xFF);
|
||||
batch.text_styled(&self.gui.atlas, &Text::literal(text), x, y, color, shadow);
|
||||
}
|
||||
/// The player, in a world.
|
||||
pub(super) fn native_player(&self) -> Option<NativePlayer> {
|
||||
let p = self.player.as_ref()?;
|
||||
Some(NativePlayer {
|
||||
x: p.position.x,
|
||||
y: p.position.y,
|
||||
z: p.position.z,
|
||||
yaw: self.yaw,
|
||||
pitch: self.pitch,
|
||||
dimension: self.dim.id(),
|
||||
})
|
||||
}
|
||||
/// The frame counter: frames in the last second, the slowest frame and
|
||||
/// the 1% low of the last 5 seconds, in frames per second.
|
||||
pub(super) fn native_frame_rate(&self) -> (u32, u32, u32) {
|
||||
self.frame_reading()
|
||||
}
|
||||
/// Attack and use clicks in the last second.
|
||||
pub(super) fn native_clicks(&mut self) -> (u32, u32) {
|
||||
self.mouse_reading().0
|
||||
}
|
||||
/// A vanilla mapping (`key.forward`, `key.jump`, `key.attack`…): whether
|
||||
/// it is held, and its key's label as Controls shows it.
|
||||
pub(super) fn native_mapping(&mut self, name: &str) -> Option<(bool, String)> {
|
||||
let &(_, key) = MAPPINGS.iter().find(|m| m.0 == name)?;
|
||||
let label = keybinds::display_name(self.settings.keys.get(name)).plain();
|
||||
let down = match key {
|
||||
Some(k) => self.keys.contains(&k),
|
||||
None => self.mouse_reading().1[usize::from(name == "key.use")],
|
||||
};
|
||||
Some((down, label))
|
||||
}
|
||||
/// The key the player chose for a mod's key `name`, else `default`
|
||||
/// (`key.keyboard.z`), as the WebAssembly mods' keys were kept
|
||||
/// (`key.<mod id>.<name>` in `horizon-mods.txt`).
|
||||
pub(super) fn native_mod_key(&self, mod_id: &str, name: &str, default: &str) -> String {
|
||||
let chosen = self
|
||||
.settings
|
||||
.mods
|
||||
.keys
|
||||
.get(mod_id)
|
||||
.and_then(|k| k.get(name));
|
||||
chosen.map_or(default, String::as_str).to_owned()
|
||||
}
|
||||
/// A line in chat from the client.
|
||||
pub(super) fn native_chat(&mut self, text: &str) {
|
||||
let focused = self.screen == Screen::Chat;
|
||||
let line = Text::literal(text);
|
||||
let atlas = &self.gui.atlas;
|
||||
self.ui
|
||||
.chat
|
||||
.add(atlas, line, None, ChatSource::Client, self.ticks, focused);
|
||||
}
|
||||
/// A native mod's own folder: `horizon-mods/.horizon-mod-data/<id>/`,
|
||||
/// where the WebAssembly mod of the same id kept its storage, so a
|
||||
/// player's settings carry over. Not created here.
|
||||
pub(super) fn native_data_dir(&self, mod_id: &str) -> PathBuf {
|
||||
let mods = Self::options_path().with_file_name(mod_list::DIR);
|
||||
mods.join(".horizon-mod-data").join(mod_id)
|
||||
}
|
||||
/// A mod's setting `key` (one file per key in `native_data_dir`).
|
||||
pub(super) fn native_setting(&self, mod_id: &str, key: &str) -> Option<String> {
|
||||
let path = self.native_data_dir(mod_id).join(key);
|
||||
fs::read_to_string(path).ok().map(|v| v.trim().to_owned())
|
||||
}
|
||||
/// Keeps a mod's setting `key`.
|
||||
pub(super) fn native_set_setting(&self, mod_id: &str, key: &str, value: &str) {
|
||||
let dir = self.native_data_dir(mod_id);
|
||||
let _ = fs::create_dir_all(&dir).and_then(|()| fs::write(dir.join(key), value));
|
||||
}
|
||||
/// Shows the panel beside container screens with these items (at most
|
||||
/// 8192), as the game's own slots in pages, with a search box; the
|
||||
/// player's clicks, searches and keys over items come back as
|
||||
/// `native_panel_event`. The panel is the last mod's to show one; empty
|
||||
/// items and no recipe page take yours away.
|
||||
pub(super) fn native_panel_items(&mut self, mod_id: &str, items: Vec<NativeStack>) {
|
||||
self.panel.show_items(mod_id, items);
|
||||
}
|
||||
/// Shows these recipes in the panel instead of its items, as many as
|
||||
/// fit a page, under `title` and a back button, with a row of `tabs`
|
||||
/// (each an icon; `tab` is the selected one) between them. At most 256
|
||||
/// recipes and 16 tabs; no recipes shows the items again. The same title
|
||||
/// and tab again keep the page the player turned to.
|
||||
pub(super) fn native_panel_recipes(
|
||||
&mut self,
|
||||
mod_id: &str,
|
||||
title: &str,
|
||||
tabs: Vec<NativeStack>,
|
||||
tab: u32,
|
||||
views: Vec<NativeRecipeView>,
|
||||
) {
|
||||
let page = RecipePage::new(title, tabs, tab, views);
|
||||
self.panel.show_recipes(mod_id, page);
|
||||
}
|
||||
/// Every item and recipe the game knows, made once (the same for every
|
||||
/// call); none outside a world. They are the window's own: the world's
|
||||
/// in single player, vanilla's on a server, which sends a client no
|
||||
/// others.
|
||||
pub(super) fn native_recipes(&mut self) -> Option<Arc<NativeRecipes>> {
|
||||
self.player.as_ref()?;
|
||||
self.made_recipes()
|
||||
}
|
||||
/// The `book_id`s of the recipes the player's recipe book holds now
|
||||
/// (the server's book on a server); empty outside a world.
|
||||
pub(super) fn native_known_recipes(&mut self) -> Arc<[i32]> {
|
||||
if self.player.is_none() {
|
||||
return Arc::from([]);
|
||||
}
|
||||
self.known_displays()
|
||||
}
|
||||
/// Places a recipe in the open screen's grid as a click in the recipe
|
||||
/// book does (`ServerboundPlaceRecipePacket`): the inventory's grid or a
|
||||
/// crafting table's. `all` places as many as the items allow, as with
|
||||
/// Shift. Refused while no such screen is open and for a recipe the
|
||||
/// player's book doesn't hold; one missing items shows as a ghost.
|
||||
pub(super) fn native_place_recipe(&mut self, book_id: i32, all: bool) -> Result<(), String> {
|
||||
self.recipe_place(book_id, all)
|
||||
}
|
||||
/// Creative mode only: gives the player `count` of `item`
|
||||
/// (`minecraft:diamond`) as taking it from the creative inventory does
|
||||
/// (`ServerboundSetCreativeModeSlot`): the selected slot first, then the
|
||||
/// hotbar, then the inventory. The count is cut to one stack.
|
||||
pub(super) fn native_give(&mut self, item: &str, count: u32) -> Result<(), String> {
|
||||
self.recipe_give(item, count)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the game calls the hook points.
|
||||
impl Game<'_> {
|
||||
/// Options > Mods' rows for the native mods, after the others.
|
||||
pub(super) fn list_native_mods(&mut self) {
|
||||
let mut mods = Vec::new();
|
||||
self.native_mods(&mut mods);
|
||||
// A WebAssembly mod of the same id doesn't run (`start_mods`): the
|
||||
// native row stands for it, and alone toggles its `mod.<id>` line.
|
||||
self.settings
|
||||
.mods
|
||||
.installed
|
||||
.retain(|row| row.native.is_none() && !mods.iter().any(|m| m.id == row.id));
|
||||
for m in mods {
|
||||
let mut buttons = Vec::new();
|
||||
self.native_settings(&m.id, &mut buttons);
|
||||
let page = NativePage {
|
||||
keys: m.keys,
|
||||
buttons,
|
||||
};
|
||||
self.settings.mods.installed.push(Installed {
|
||||
id: m.id,
|
||||
title: m.title,
|
||||
description: m.description,
|
||||
problem: None,
|
||||
dev: false,
|
||||
has_settings: false,
|
||||
native: Some(page),
|
||||
});
|
||||
}
|
||||
}
|
||||
/// A click on button `button` of native mod `i`'s page (its index in
|
||||
/// Options > Mods), and the page's labels after it.
|
||||
pub(super) fn native_settings_event(&mut self, i: usize, button: usize) {
|
||||
let Some(id) = self.settings.mods.installed.get(i).map(|m| m.id.clone()) else {
|
||||
return;
|
||||
};
|
||||
self.native_settings_click(&id, button);
|
||||
let mut buttons = Vec::new();
|
||||
self.native_settings(&id, &mut buttons);
|
||||
let row = self.settings.mods.installed.get_mut(i);
|
||||
if let Some(page) = row.and_then(|m| m.native.as_mut()) {
|
||||
page.buttons = buttons;
|
||||
}
|
||||
}
|
||||
/// A key event for `native_key`.
|
||||
pub(super) fn native_key_event(&mut self, key: KeyCode, pressed: bool, repeat: bool) {
|
||||
if repeat {
|
||||
return;
|
||||
}
|
||||
if let Some(name) = keybinds::key_name(key) {
|
||||
if pressed {
|
||||
self.native_panel_key(name);
|
||||
}
|
||||
self.native_key(name, pressed);
|
||||
}
|
||||
}
|
||||
/// A mouse button while playing, for `native_key`.
|
||||
pub(super) fn native_mouse_event(&mut self, button: MouseButton, pressed: bool) {
|
||||
let name = keybinds::mouse_name(button);
|
||||
self.native_key(&name, pressed);
|
||||
}
|
||||
/// Wheel steps while playing: whether a native mod took them.
|
||||
pub(super) fn native_wheel_event(&mut self, steps: f32) -> bool {
|
||||
let mut claimed = false;
|
||||
self.native_wheel(steps, &mut claimed);
|
||||
claimed
|
||||
}
|
||||
/// A client mod's command: a native mod's first, then a WebAssembly mod's.
|
||||
pub(super) fn client_mod_command(&mut self, command: &str) -> bool {
|
||||
let mut ran = false;
|
||||
self.native_command(command, &mut ran);
|
||||
ran || self.mod_command(command)
|
||||
}
|
||||
/// The zoom in effect while playing (a native mod's); the spyglass's own
|
||||
/// view wins.
|
||||
pub(super) fn native_zoom_now(&self) -> Option<NativeZoom> {
|
||||
if !self.native_playing() || self.scoping() {
|
||||
return None;
|
||||
}
|
||||
let mut zoom = None;
|
||||
self.native_zoom(&mut zoom);
|
||||
// As the WebAssembly runtime clamped a mod's zoom.
|
||||
let z = zoom?;
|
||||
let factor = finite(z.factor, 1.0, 10.0);
|
||||
(factor > 1.0).then_some(NativeZoom {
|
||||
factor,
|
||||
smooth: z.smooth,
|
||||
turn: if z.turn.is_finite() {
|
||||
z.turn.clamp(0.05, 1.0)
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
})
|
||||
}
|
||||
/// The minimap a native mod shows.
|
||||
pub(super) fn native_minimap_now(&self) -> Option<NativeMinimap> {
|
||||
let mut minimap = None;
|
||||
self.native_minimap(&mut minimap);
|
||||
minimap.map(|m| NativeMinimap {
|
||||
size: finite(m.size, 16.0, 256.0),
|
||||
blocks_per_pixel: finite(m.blocks_per_pixel, 0.5, 4.0),
|
||||
..m
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` within `lo..=hi`; `lo` when it is not a number.
|
||||
fn finite(v: f32, lo: f32, hi: f32) -> f32 {
|
||||
if v.is_finite() { v.clamp(lo, hi) } else { lo }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// horizon-mixins refuses targets whose body has a `cfg` or a macro:
|
||||
/// the hook points and helpers must stay free of both. The launcher
|
||||
/// reads the API line as it is.
|
||||
#[test]
|
||||
fn hook_points_stay_hookable() {
|
||||
let source = include_str!("native_hooks.rs");
|
||||
let code = source.split("#[cfg(test)]").next().unwrap_or_default();
|
||||
assert!(code.contains("\npub(crate) const NATIVE_HOOKS_API: u32 = "));
|
||||
let lines = code.lines().filter(|l| !l.trim_start().starts_with("//"));
|
||||
for line in lines {
|
||||
assert!(!line.contains("cfg"), "a cfg in the hook points: {line}");
|
||||
for mac in ["!(", "![", "! {", "!{"] {
|
||||
assert!(!line.contains(mac), "a macro in the hook points: {line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+314
-105
@@ -1,64 +1,144 @@
|
||||
//! Capability `ui`'s panel beside container screens (WIT `interface
|
||||
//! browser`, docs/mods.md): the items a recipe viewer mod lists, drawn as
|
||||
//! the game's own item slots in pages right of the screen's window, with a
|
||||
//! search box under them. The mod says what the panel holds; the game pages
|
||||
//! it and tells the mod what the player clicked, typed, and which of the
|
||||
//! mod's keys went down over an item. With no mod showing a panel, each
|
||||
//! hook here returns after one check.
|
||||
use super::Game;
|
||||
#[cfg(feature = "mods")]
|
||||
use {
|
||||
super::Screen,
|
||||
horizon_client::{
|
||||
hud::{HudBatch, rgba},
|
||||
items::ItemStack,
|
||||
ui::EditBox,
|
||||
},
|
||||
horizon_mods::{BrowserClick, BrowserEvent, BrowserStack},
|
||||
//! The item panel beside container screens: the items a recipe viewer mod
|
||||
//! lists (a native mod's `native_panel_items`, or WIT `interface browser`
|
||||
//! of a WebAssembly mod, docs/mods.md), drawn as the game's own item slots
|
||||
//! in pages right of the screen's window, with a search box under them. The
|
||||
//! mod says what the panel holds; the game pages it and tells the mod what
|
||||
//! the player clicked, typed, and which keys went down over an item
|
||||
//! (`native_panel_event`). With no mod showing a panel, each hook here
|
||||
//! returns after one check.
|
||||
use super::{
|
||||
Game, Screen,
|
||||
native_hooks::{NativePanelEvent as BrowserEvent, NativeStack as BrowserStack},
|
||||
native_panel_recipes::RecipePage,
|
||||
};
|
||||
use horizon_client::{
|
||||
hud::{HudBatch, rgba},
|
||||
items::ItemStack,
|
||||
ui::EditBox,
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "mods"))]
|
||||
impl Game<'_> {
|
||||
pub(super) fn draw_mod_browser(&mut self, _batch: &mut horizon_client::hud::HudBatch) {}
|
||||
pub(super) fn mod_browser_press(&mut self, _right: bool) -> bool {
|
||||
false
|
||||
}
|
||||
pub(super) fn mod_browser_typing(&self) -> bool {
|
||||
false
|
||||
}
|
||||
pub(super) fn mod_browser_key(&mut self, _event: &winit::event::KeyEvent) {}
|
||||
pub(super) fn mod_browser_text(&mut self, _text: Option<&str>, _backspace: bool) {}
|
||||
pub(super) fn mod_browser_closed(&mut self) {}
|
||||
}
|
||||
|
||||
/// One slot of the panel, in GUI pixels.
|
||||
#[cfg(feature = "mods")]
|
||||
const SLOT: f32 = 18.0;
|
||||
/// The most columns the panel takes, and the fewest it shows with.
|
||||
#[cfg(feature = "mods")]
|
||||
const COLUMNS: (usize, usize) = (12, 2);
|
||||
/// The gap between the window and the panel, and the screen's edges.
|
||||
#[cfg(feature = "mods")]
|
||||
const MARGIN: f32 = 8.0;
|
||||
|
||||
/// Items a panel may hold.
|
||||
const ITEMS: usize = 8192;
|
||||
|
||||
/// Whose panel it is: a native mod's id, or a running WebAssembly mod's
|
||||
/// index.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) enum Owner {
|
||||
Native(String),
|
||||
#[cfg(feature = "mods")]
|
||||
Wasm(usize),
|
||||
}
|
||||
impl Owner {
|
||||
fn native_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Owner::Native(id) => Some(id),
|
||||
#[cfg(feature = "mods")]
|
||||
Owner::Wasm(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a mod shows on the panel: its items, and the recipes shown in
|
||||
/// their place.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct Shown {
|
||||
pub(super) owner: Owner,
|
||||
/// Which change the panel is from, so the game converts it once.
|
||||
pub(super) version: u64,
|
||||
pub(super) items: Vec<BrowserStack>,
|
||||
pub(super) recipes: Option<RecipePage>,
|
||||
}
|
||||
|
||||
/// The panel's state in the game.
|
||||
#[cfg(feature = "mods")]
|
||||
#[derive(Default)]
|
||||
pub(super) struct Browser {
|
||||
pub(super) struct Panel {
|
||||
/// A native mod's panel: the last one to show something.
|
||||
native: Option<Shown>,
|
||||
/// A WebAssembly mod's, converted once per change.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) wasm: Option<Shown>,
|
||||
/// The last native change.
|
||||
versions: u64,
|
||||
/// The items' owner and version they were made from.
|
||||
of: Option<(usize, u64)>,
|
||||
of: Option<(Owner, u64)>,
|
||||
/// The mod's items as stacks; `None` for an item the game doesn't have.
|
||||
stacks: Vec<Option<ItemStack>>,
|
||||
page: usize,
|
||||
search: Option<EditBox>,
|
||||
/// The search text the mod last heard.
|
||||
sent: String,
|
||||
/// `set-recipes`' recipes, shown instead of the items.
|
||||
pub(super) recipes: Option<super::mod_browser_recipes::Pages>,
|
||||
/// The recipes shown instead of the items.
|
||||
pub(super) recipes: Option<super::native_panel_recipes::Pages>,
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
/// What the panel shows: a native mod's (one `on`), else a
|
||||
/// WebAssembly mod's.
|
||||
pub(super) fn shown(&self, on: impl Fn(&str) -> bool) -> Option<&Shown> {
|
||||
let native = self
|
||||
.native
|
||||
.as_ref()
|
||||
.filter(|s| s.owner.native_id().is_some_and(&on));
|
||||
#[cfg(feature = "mods")]
|
||||
let native = native.or(self.wasm.as_ref());
|
||||
native
|
||||
}
|
||||
/// `native_panel_items`.
|
||||
pub(super) fn show_items(&mut self, mod_id: &str, mut items: Vec<BrowserStack>) {
|
||||
items.truncate(ITEMS);
|
||||
if let Some(shown) = self.native_of(mod_id, !items.is_empty()) {
|
||||
shown.items = items;
|
||||
self.native_changed();
|
||||
}
|
||||
}
|
||||
/// `native_panel_recipes`.
|
||||
pub(super) fn show_recipes(&mut self, mod_id: &str, page: Option<RecipePage>) {
|
||||
if let Some(shown) = self.native_of(mod_id, page.is_some()) {
|
||||
shown.recipes = page;
|
||||
self.native_changed();
|
||||
}
|
||||
}
|
||||
/// Mod `mod_id`'s panel, made when it shows something on another's;
|
||||
/// none when it clears a panel that isn't its own.
|
||||
fn native_of(&mut self, mod_id: &str, showing: bool) -> Option<&mut Shown> {
|
||||
let own = self
|
||||
.native
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.owner.native_id() == Some(mod_id));
|
||||
if !own {
|
||||
if !showing {
|
||||
return None;
|
||||
}
|
||||
self.native = Some(Shown {
|
||||
owner: Owner::Native(mod_id.to_owned()),
|
||||
version: 0,
|
||||
items: Vec::new(),
|
||||
recipes: None,
|
||||
});
|
||||
}
|
||||
self.native.as_mut()
|
||||
}
|
||||
/// A native panel changed: a new version, and no panel once it shows
|
||||
/// nothing.
|
||||
fn native_changed(&mut self) {
|
||||
self.versions += 1;
|
||||
if let Some(shown) = self.native.as_mut() {
|
||||
shown.version = self.versions;
|
||||
if shown.items.is_empty() && shown.recipes.is_none() {
|
||||
self.native = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the panel's parts are this frame.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) struct Layout {
|
||||
/// The panel's whole area, for clicks that hit none of its parts.
|
||||
pub(super) area: [f32; 4],
|
||||
@@ -70,7 +150,6 @@ pub(super) struct Layout {
|
||||
pub(super) arrows_y: f32,
|
||||
pub(super) search: [f32; 4],
|
||||
}
|
||||
#[cfg(feature = "mods")]
|
||||
impl Layout {
|
||||
fn per_page(&self) -> usize {
|
||||
self.columns * self.rows
|
||||
@@ -93,48 +172,62 @@ impl Layout {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) fn inside(p: (f32, f32), [x, y, w, h]: [f32; 4]) -> bool {
|
||||
p.0 >= x && p.1 >= y && p.0 < x + w && p.1 < y + h
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
impl Game<'_> {
|
||||
fn browser_batch(&self) -> Option<HudBatch> {
|
||||
let r = self.renderer.as_ref()?;
|
||||
Some(HudBatch::new([r.config.width, r.config.height]))
|
||||
}
|
||||
/// The mod showing a panel, with its items made into stacks once per
|
||||
/// `set-items`; none while no mod shows one or no container screen is up.
|
||||
fn browser_sync(&mut self) -> Option<usize> {
|
||||
/// change; none while no mod shows one or no container screen is up.
|
||||
fn browser_sync(&mut self) -> Option<Owner> {
|
||||
if self.screen != Screen::Inventory || self.title.is_some() {
|
||||
return None;
|
||||
}
|
||||
let Some((owner, version)) = self.mods.mods.browser_items().map(|(o, v, _)| (o, v)) else {
|
||||
self.mods.browser.of = None;
|
||||
self.mods.browser.stacks.clear();
|
||||
self.panel_wasm_sync();
|
||||
let disabled = &self.settings.mods.disabled;
|
||||
let Some(shown) = self.panel.shown(|id| !disabled.contains(id)) else {
|
||||
self.panel.of = None;
|
||||
self.panel.stacks.clear();
|
||||
return None;
|
||||
};
|
||||
if self.mods.browser.of != Some((owner, version)) {
|
||||
let of = (shown.owner.clone(), shown.version);
|
||||
if self.panel.of.as_ref() != Some(&of) {
|
||||
let items = self.res.items();
|
||||
let stacks = self
|
||||
.mods
|
||||
.mods
|
||||
.browser_items()
|
||||
.map_or_else(Vec::new, |(_, _, list)| {
|
||||
list.iter().map(|s| browser_stack(items, s)).collect()
|
||||
});
|
||||
self.mods.browser.stacks = stacks;
|
||||
let stacks = shown
|
||||
.items
|
||||
.iter()
|
||||
.map(|s| browser_stack(items, s))
|
||||
.collect();
|
||||
let shown = shown.recipes.clone();
|
||||
self.panel.stacks = stacks;
|
||||
// The same title again (the mod updating its buttons) keeps the page.
|
||||
let old = self.mods.browser.recipes.take();
|
||||
self.mods.browser.recipes = self
|
||||
let old = self.panel.recipes.take();
|
||||
self.panel.recipes = shown
|
||||
.as_ref()
|
||||
.map(|shown| super::native_panel_recipes::Pages::new(items, shown, old));
|
||||
self.panel.of = Some(of.clone());
|
||||
}
|
||||
Some(of.0)
|
||||
}
|
||||
/// What the panel shows now.
|
||||
fn panel_shown(&self) -> Option<&Shown> {
|
||||
let disabled = &self.settings.mods.disabled;
|
||||
self.panel.shown(|id| !disabled.contains(id))
|
||||
}
|
||||
/// The player did `event` on `owner`'s panel.
|
||||
pub(super) fn panel_event(&mut self, owner: &Owner, event: BrowserEvent) {
|
||||
match owner {
|
||||
Owner::Native(id) => self.native_panel_event(id, &event),
|
||||
#[cfg(feature = "mods")]
|
||||
Owner::Wasm(i) => self
|
||||
.mods
|
||||
.mods
|
||||
.browser_recipes(owner)
|
||||
.map(|shown| super::mod_browser_recipes::Pages::new(items, shown, old));
|
||||
self.mods.browser.of = Some((owner, version));
|
||||
.browser_event(*i, super::mod_panel::event(event)),
|
||||
}
|
||||
Some(owner)
|
||||
}
|
||||
/// The panel's parts: right of the window, from the top of the screen to
|
||||
/// the search box at its foot. None when the screen has no room for it.
|
||||
@@ -161,17 +254,12 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
fn browser_pages(&self, layout: &Layout) -> usize {
|
||||
self.mods
|
||||
.browser
|
||||
.stacks
|
||||
.len()
|
||||
.div_ceil(layout.per_page())
|
||||
.max(1)
|
||||
self.panel.stacks.len().div_ceil(layout.per_page()).max(1)
|
||||
}
|
||||
/// The stack under the pointer on the panel, an item's or a recipe's,
|
||||
/// as the mod sent it and as a stack.
|
||||
fn browser_pointed_stack(&self, layout: &Layout) -> Option<(BrowserStack, ItemStack)> {
|
||||
if self.mods.browser.recipes.is_some() {
|
||||
if self.panel.recipes.is_some() {
|
||||
if let Some((_, sent, stack)) = self.recipe_tab_hovered(layout) {
|
||||
return Some((sent, stack));
|
||||
}
|
||||
@@ -179,26 +267,26 @@ impl Game<'_> {
|
||||
return Some((sent, stack));
|
||||
}
|
||||
let (index, stack) = self.browser_hovered(layout)?;
|
||||
let (_, _, items) = self.mods.mods.browser_items()?;
|
||||
let items = &self.panel_shown()?.items;
|
||||
Some((items.get(index)?.clone(), stack))
|
||||
}
|
||||
/// The panel's item under the pointer: its index and stack.
|
||||
fn browser_hovered(&self, layout: &Layout) -> Option<(usize, ItemStack)> {
|
||||
if self.mods.browser.recipes.is_some() {
|
||||
if self.panel.recipes.is_some() {
|
||||
return None;
|
||||
}
|
||||
let first = self.mods.browser.page * layout.per_page();
|
||||
let first = self.panel.page * layout.per_page();
|
||||
(0..layout.per_page()).find_map(|n| {
|
||||
let (x, y) = layout.slot(n);
|
||||
if !inside(self.cursor, [x, y, SLOT, SLOT]) {
|
||||
return None;
|
||||
}
|
||||
let stack = self.mods.browser.stacks.get(first + n)?.clone()?;
|
||||
let stack = self.panel.stacks.get(first + n)?.clone()?;
|
||||
Some((first + n, stack))
|
||||
})
|
||||
}
|
||||
fn browser_search(&mut self, layout: &Layout) -> &mut EditBox {
|
||||
let search = self.mods.browser.search.get_or_insert_with(|| {
|
||||
let search = self.panel.search.get_or_insert_with(|| {
|
||||
let mut edit = EditBox::new(0.0, 0.0, 0.0, 0.0);
|
||||
edit.max_length = 50;
|
||||
edit.focused = false;
|
||||
@@ -211,8 +299,8 @@ impl Game<'_> {
|
||||
/// The items' page arrows and grid.
|
||||
fn draw_browser_items(&mut self, batch: &mut HudBatch, layout: &Layout) {
|
||||
let pages = self.browser_pages(layout);
|
||||
self.mods.browser.page = self.mods.browser.page.min(pages - 1);
|
||||
let page = self.mods.browser.page;
|
||||
self.panel.page = self.panel.page.min(pages - 1);
|
||||
let page = self.panel.page;
|
||||
let cursor = self.cursor;
|
||||
if pages > 1 {
|
||||
let labels = [
|
||||
@@ -247,7 +335,7 @@ impl Game<'_> {
|
||||
}
|
||||
let first = page * layout.per_page();
|
||||
for n in 0..layout.per_page() {
|
||||
let Some(stack) = self.mods.browser.stacks.get(first + n) else {
|
||||
let Some(stack) = self.panel.stacks.get(first + n) else {
|
||||
break;
|
||||
};
|
||||
let Some(stack) = stack.clone() else {
|
||||
@@ -285,7 +373,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let cursor = self.cursor;
|
||||
if self.mods.browser.recipes.is_some() {
|
||||
if self.panel.recipes.is_some() {
|
||||
self.draw_browser_recipes(batch, &layout);
|
||||
} else {
|
||||
self.draw_browser_items(batch, &layout);
|
||||
@@ -296,7 +384,7 @@ impl Game<'_> {
|
||||
search.suggestion = (search.value.is_empty() && !search.focused).then_some(hint);
|
||||
let mut search = search.clone();
|
||||
search.draw(batch, &self.gui.atlas, None);
|
||||
self.mods.browser.search = Some(search);
|
||||
self.panel.search = Some(search);
|
||||
if self.carried.is_some() {
|
||||
self.draw_carried(batch);
|
||||
} else if let Some(stack) = self.browser_pointed_stack(&layout) {
|
||||
@@ -323,7 +411,7 @@ impl Game<'_> {
|
||||
let cursor = self.cursor;
|
||||
let shift = self.has_shift_down();
|
||||
let atlas = &self.gui.atlas;
|
||||
let search = self.mods.browser.search.get_or_insert_with(|| {
|
||||
let search = self.panel.search.get_or_insert_with(|| {
|
||||
let mut edit = EditBox::new(0.0, 0.0, 0.0, 0.0);
|
||||
edit.max_length = 50;
|
||||
edit
|
||||
@@ -336,12 +424,12 @@ impl Game<'_> {
|
||||
return true;
|
||||
}
|
||||
search.focused = false;
|
||||
if self.mods.browser.recipes.is_some() {
|
||||
return self.browser_recipes_press(&layout, owner, right)
|
||||
if self.panel.recipes.is_some() {
|
||||
return self.browser_recipes_press(&layout, &owner, right)
|
||||
|| inside(cursor, layout.area);
|
||||
}
|
||||
let pages = self.browser_pages(&layout);
|
||||
let page = self.mods.browser.page;
|
||||
let page = self.panel.page;
|
||||
let [back, forward] = layout.arrows();
|
||||
let step = if pages > 1 && page > 0 && inside(cursor, [back.0, back.1, 12.0, 17.0]) {
|
||||
Some(page - 1)
|
||||
@@ -354,17 +442,17 @@ impl Game<'_> {
|
||||
None
|
||||
};
|
||||
if let Some(to) = step {
|
||||
self.mods.browser.page = to;
|
||||
self.panel.page = to;
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
return true;
|
||||
}
|
||||
if let Some((index, _)) = self.browser_hovered(&layout) {
|
||||
let event = BrowserEvent::Clicked(BrowserClick {
|
||||
let event = BrowserEvent::Clicked {
|
||||
index: index as u32,
|
||||
right,
|
||||
});
|
||||
self.mods.mods.browser_event(owner, event);
|
||||
};
|
||||
self.panel_event(&owner, event);
|
||||
return true;
|
||||
}
|
||||
inside(cursor, layout.area)
|
||||
@@ -373,8 +461,8 @@ impl Game<'_> {
|
||||
/// Whether keys go to the panel's search box.
|
||||
pub(super) fn mod_browser_typing(&self) -> bool {
|
||||
self.screen == Screen::Inventory
|
||||
&& self.mods.browser.of.is_some()
|
||||
&& self.mods.browser.search.as_ref().is_some_and(|s| s.focused)
|
||||
&& self.panel.of.is_some()
|
||||
&& self.panel.search.as_ref().is_some_and(|s| s.focused)
|
||||
}
|
||||
/// A key press while the search box has focus.
|
||||
pub(super) fn mod_browser_key(&mut self, event: &winit::event::KeyEvent) {
|
||||
@@ -382,7 +470,7 @@ impl Game<'_> {
|
||||
let shortcut = self.edit_shortcut(event);
|
||||
let ctrl = self.has_control_down();
|
||||
let shift = self.has_shift_down();
|
||||
let Some(search) = self.mods.browser.search.as_mut() else {
|
||||
let Some(search) = self.panel.search.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Some(shortcut) = shortcut {
|
||||
@@ -405,7 +493,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// Text typed (or one backspace) into the search box (`horizon-ctl type`).
|
||||
pub(super) fn mod_browser_text(&mut self, text: Option<&str>, backspace: bool) {
|
||||
let Some(search) = self.mods.browser.search.as_mut() else {
|
||||
let Some(search) = self.panel.search.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if backspace {
|
||||
@@ -417,27 +505,24 @@ impl Game<'_> {
|
||||
}
|
||||
/// The mod hears the search text when it changed.
|
||||
fn browser_searched(&mut self) {
|
||||
let (Some((owner, _)), Some(search)) = (self.mods.browser.of, &self.mods.browser.search)
|
||||
else {
|
||||
let (Some((owner, _)), Some(search)) = (&self.panel.of, &self.panel.search) else {
|
||||
return;
|
||||
};
|
||||
if search.value != self.mods.browser.sent {
|
||||
let value = search.value.clone();
|
||||
self.mods.browser.sent.clone_from(&value);
|
||||
self.mods.browser.page = 0;
|
||||
self.mods
|
||||
.mods
|
||||
.browser_event(owner, BrowserEvent::Search(value));
|
||||
if search.value != self.panel.sent {
|
||||
let (owner, value) = (owner.clone(), search.value.clone());
|
||||
self.panel.sent.clone_from(&value);
|
||||
self.panel.page = 0;
|
||||
self.panel_event(&owner, BrowserEvent::Search(value));
|
||||
}
|
||||
}
|
||||
/// The container screen closed: the search box lets go of the keys.
|
||||
pub(super) fn mod_browser_closed(&mut self) {
|
||||
if let Some(search) = self.mods.browser.search.as_mut() {
|
||||
if let Some(search) = self.panel.search.as_mut() {
|
||||
search.focused = false;
|
||||
}
|
||||
}
|
||||
/// The item under the pointer on a container screen, for the mods' keys
|
||||
/// (`browser`'s `key` event): the panel's, else a slot's.
|
||||
/// (the `Key` event): the panel's, else a slot's.
|
||||
pub(super) fn mod_browser_pointed(&mut self) -> Option<BrowserStack> {
|
||||
let batch = self.browser_batch()?;
|
||||
let stack = match self
|
||||
@@ -456,14 +541,27 @@ impl Game<'_> {
|
||||
Some(BrowserStack {
|
||||
item: stack.item.to_string(),
|
||||
count: stack.count,
|
||||
components: super::mod_recipes::components(&stack),
|
||||
components: super::native_recipes::components(&stack),
|
||||
})
|
||||
}
|
||||
/// A key `key` (`key.keyboard.r`) went down: over an item on a container
|
||||
/// screen, a native mod's panel hears it as a `Key` event.
|
||||
pub(super) fn native_panel_key(&mut self, key: &str) {
|
||||
if self.screen != Screen::Inventory || self.title.is_some() || self.mod_browser_typing() {
|
||||
return;
|
||||
}
|
||||
let Some(owner) = self.browser_sync().filter(|o| o.native_id().is_some()) else {
|
||||
return;
|
||||
};
|
||||
if let Some(stack) = self.mod_browser_pointed() {
|
||||
let key = key.to_owned();
|
||||
self.panel_event(&owner, BrowserEvent::Key { key, stack });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A mod's item as a stack: an item the game has, its components read as
|
||||
/// `/give` reads them (ones it can't read are left out).
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) fn browser_stack(
|
||||
items: &horizon_client::items::Items,
|
||||
s: &BrowserStack,
|
||||
@@ -483,3 +581,114 @@ pub(super) fn browser_stack(
|
||||
extra,
|
||||
})
|
||||
}
|
||||
|
||||
/// No WebAssembly mods: no panel of theirs (`mod_panel.rs` with them).
|
||||
#[cfg(not(feature = "mods"))]
|
||||
impl Game<'_> {
|
||||
fn panel_wasm_sync(&mut self) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{BrowserStack, Owner, Panel, RecipePage};
|
||||
use crate::game::native_hooks::{NativeRecipeLayout, NativeRecipeView};
|
||||
|
||||
fn stack(item: &str) -> BrowserStack {
|
||||
BrowserStack {
|
||||
item: item.into(),
|
||||
count: 1,
|
||||
components: String::new(),
|
||||
}
|
||||
}
|
||||
fn view() -> NativeRecipeView {
|
||||
NativeRecipeView {
|
||||
layout: NativeRecipeLayout::Grid,
|
||||
width: 5,
|
||||
height: 1,
|
||||
slots: vec![vec![stack("minecraft:oak_log")]; 12],
|
||||
results: vec![stack("minecraft:oak_planks")],
|
||||
note: String::new(),
|
||||
action: Some(true),
|
||||
}
|
||||
}
|
||||
|
||||
/// A native mod's items and recipe page are what the panel holds, and
|
||||
/// its owner (who hears the panel's events) is that mod.
|
||||
#[test]
|
||||
fn native_owner_holds_the_panel() {
|
||||
let mut panel = Panel::default();
|
||||
let items = vec![stack("minecraft:stone"), stack("minecraft:dirt")];
|
||||
panel.show_items("recipes", items.clone());
|
||||
let shown = panel.shown(|_| true).expect("a panel");
|
||||
assert_eq!(shown.owner, Owner::Native("recipes".into()));
|
||||
assert_eq!(shown.items, items);
|
||||
assert_eq!(shown.recipes, None);
|
||||
let first = shown.version;
|
||||
|
||||
let tabs = vec![stack("minecraft:crafting_table")];
|
||||
let page = RecipePage::new("Oak Planks", tabs.clone(), 0, vec![view()]);
|
||||
panel.show_recipes("recipes", page);
|
||||
let shown = panel.shown(|_| true).expect("a panel");
|
||||
assert_ne!(shown.version, first, "the game converts each change");
|
||||
assert_eq!(shown.items, items);
|
||||
let page = shown.recipes.as_ref().expect("a recipe page");
|
||||
assert_eq!(
|
||||
(page.title.as_str(), &page.tabs, page.tab),
|
||||
("Oak Planks", &tabs, 0)
|
||||
);
|
||||
// Cut as `set-recipes` cuts it: a 3 x 3 grid at most, 9 slots.
|
||||
let r = &page.recipes[0];
|
||||
assert_eq!((r.width, r.height, r.slots.len()), (3, 1, 9));
|
||||
assert_eq!(r.results, [stack("minecraft:oak_planks")]);
|
||||
|
||||
// Another mod clearing its own (empty) panel leaves this one.
|
||||
panel.show_items("other", Vec::new());
|
||||
let owner = panel.shown(|_| true).map(|s| s.owner.clone());
|
||||
assert_eq!(owner, Some(Owner::Native("recipes".into())));
|
||||
// A mod the player turned off shows nothing.
|
||||
assert!(panel.shown(|id| id != "recipes").is_none());
|
||||
|
||||
// No recipes: the items again; then no items: no panel.
|
||||
panel.show_recipes("recipes", RecipePage::new("", Vec::new(), 0, Vec::new()));
|
||||
let shown = panel.shown(|_| true).expect("a panel");
|
||||
assert_eq!((shown.items.len(), shown.recipes.is_none()), (2, true));
|
||||
panel.show_items("recipes", Vec::new());
|
||||
assert!(panel.shown(|_| true).is_none());
|
||||
}
|
||||
|
||||
/// A native mod's panel wins over a WebAssembly mod's, and events go
|
||||
/// back to a WebAssembly owner in the runtime's form.
|
||||
#[cfg(feature = "mods")]
|
||||
#[test]
|
||||
fn native_owner_wins_over_webassembly() {
|
||||
use super::{BrowserEvent, Shown};
|
||||
use crate::game::mod_panel as wasm;
|
||||
let mut panel = Panel {
|
||||
wasm: Some(Shown {
|
||||
owner: Owner::Wasm(0),
|
||||
version: 7,
|
||||
items: vec![stack("minecraft:stone")],
|
||||
recipes: None,
|
||||
}),
|
||||
..Panel::default()
|
||||
};
|
||||
let owner = |p: &Panel| p.shown(|_| true).map(|s| s.owner.clone());
|
||||
assert_eq!(owner(&panel), Some(Owner::Wasm(0)));
|
||||
panel.show_items("recipes", vec![stack("minecraft:dirt")]);
|
||||
assert_eq!(owner(&panel), Some(Owner::Native("recipes".into())));
|
||||
panel.show_items("recipes", Vec::new());
|
||||
assert_eq!(owner(&panel), Some(Owner::Wasm(0)));
|
||||
|
||||
let event = BrowserEvent::SlotClicked {
|
||||
recipe: 2,
|
||||
slot: 9,
|
||||
stack: stack("minecraft:oak_planks"),
|
||||
right: true,
|
||||
};
|
||||
let horizon_mods::BrowserEvent::SlotClicked(c) = wasm::event(event) else {
|
||||
panic!("a slot click");
|
||||
};
|
||||
assert_eq!((c.recipe, c.slot, c.right), (2, 9, true));
|
||||
assert_eq!(c.stack.item, "minecraft:oak_planks");
|
||||
}
|
||||
}
|
||||
+72
-27
@@ -1,17 +1,20 @@
|
||||
//! `browser.set-recipes` (docs/mods.md): a mod's recipes shown in its item
|
||||
//! panel's place, laid out by the game with its own slots, arrows and
|
||||
//! A mod's recipes shown in its item panel's place (`native_panel_recipes`,
|
||||
//! or `browser.set-recipes` of a WebAssembly mod, docs/mods.md), laid out by the game with its own slots, arrows and
|
||||
//! buttons, paged to the panel's height, under a title and a back button.
|
||||
//! The mod hears clicks on their slots, their action buttons, the back
|
||||
//! button and page turns.
|
||||
use super::{
|
||||
Game,
|
||||
mod_browser::{Layout, browser_stack, inside},
|
||||
native_hooks::{
|
||||
NativePanelEvent as BrowserEvent, NativeRecipeLayout as BrowserLayout,
|
||||
NativeRecipeView as BrowserRecipe, NativeStack as BrowserStack,
|
||||
},
|
||||
native_panel::{Layout, Owner, browser_stack, inside},
|
||||
};
|
||||
use horizon_client::{
|
||||
hud::{HudBatch, rgba},
|
||||
items::ItemStack,
|
||||
};
|
||||
use horizon_mods::{BrowserEvent, BrowserLayout, BrowserSlotClick, BrowserStack, RecipePage};
|
||||
use std::time::Instant;
|
||||
|
||||
const SLOT: f32 = 18.0;
|
||||
@@ -21,6 +24,54 @@ const TITLE: f32 = 18.0;
|
||||
const TAB: f32 = 20.0;
|
||||
const GAP: f32 = 6.0;
|
||||
|
||||
/// Recipes a page may show, slots in one, stacks a slot cycles, and tabs.
|
||||
const RECIPES: usize = 256;
|
||||
const RECIPE_SLOTS: usize = 9;
|
||||
const SLOT_STACKS: usize = 64;
|
||||
const TABS: usize = 16;
|
||||
|
||||
/// What the panel shows instead of its items: a title, tabs (`tab` the
|
||||
/// selected one) and the recipes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct RecipePage {
|
||||
pub(super) title: String,
|
||||
pub(super) tabs: Vec<BrowserStack>,
|
||||
pub(super) tab: u32,
|
||||
pub(super) recipes: Vec<BrowserRecipe>,
|
||||
}
|
||||
impl RecipePage {
|
||||
/// `native_panel_recipes`' page, cut as `set-recipes` cuts one; none
|
||||
/// without recipes (the items again).
|
||||
pub(super) fn new(
|
||||
title: &str,
|
||||
tabs: Vec<BrowserStack>,
|
||||
tab: u32,
|
||||
recipes: Vec<BrowserRecipe>,
|
||||
) -> Option<Self> {
|
||||
let cycle = |mut list: Vec<BrowserStack>| {
|
||||
list.truncate(SLOT_STACKS);
|
||||
list
|
||||
};
|
||||
let recipes: Vec<BrowserRecipe> = recipes
|
||||
.into_iter()
|
||||
.take(RECIPES)
|
||||
.map(|r| BrowserRecipe {
|
||||
width: r.width.clamp(1, 3),
|
||||
height: r.height.clamp(1, 3),
|
||||
slots: r.slots.into_iter().take(RECIPE_SLOTS).map(cycle).collect(),
|
||||
results: cycle(r.results),
|
||||
..r
|
||||
})
|
||||
.collect();
|
||||
(!recipes.is_empty()).then(|| Self {
|
||||
title: title.to_owned(),
|
||||
tabs: tabs.into_iter().take(TABS).collect(),
|
||||
tab,
|
||||
recipes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One recipe as the game draws it.
|
||||
pub(super) struct Shown {
|
||||
layout: BrowserLayout,
|
||||
@@ -180,7 +231,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// The recipes on the open page, each with its corner.
|
||||
fn recipes_shown(&self, layout: &Layout) -> Vec<(usize, (f32, f32))> {
|
||||
let Some(pages) = &self.mods.browser.recipes else {
|
||||
let Some(pages) = &self.panel.recipes else {
|
||||
return Vec::new();
|
||||
};
|
||||
let starts = Self::recipe_pages(pages, layout);
|
||||
@@ -202,7 +253,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// What a slot of a shown recipe holds now: its stacks cycle once a second.
|
||||
fn recipe_slot_now(&self, recipe: usize, slot: usize) -> Option<&(BrowserStack, ItemStack)> {
|
||||
let pages = self.mods.browser.recipes.as_ref()?;
|
||||
let pages = self.panel.recipes.as_ref()?;
|
||||
let stacks = pages.recipes.get(recipe)?.slots.get(slot)?;
|
||||
let n = pages.since.elapsed().as_secs() as usize;
|
||||
stacks.get(n % stacks.len().max(1))
|
||||
@@ -212,7 +263,7 @@ impl Game<'_> {
|
||||
&self,
|
||||
layout: &Layout,
|
||||
) -> Option<(usize, BrowserStack, ItemStack)> {
|
||||
let pages = self.mods.browser.recipes.as_ref()?;
|
||||
let pages = self.panel.recipes.as_ref()?;
|
||||
let places = tab_places(pages, layout);
|
||||
places.iter().enumerate().find_map(|(i, &(x, y))| {
|
||||
if !inside(self.cursor, [x, y, SLOT, SLOT]) {
|
||||
@@ -227,7 +278,7 @@ impl Game<'_> {
|
||||
&self,
|
||||
layout: &Layout,
|
||||
) -> Option<(usize, usize, BrowserStack, ItemStack)> {
|
||||
let pages = self.mods.browser.recipes.as_ref()?;
|
||||
let pages = self.panel.recipes.as_ref()?;
|
||||
self.recipes_shown(layout)
|
||||
.into_iter()
|
||||
.find_map(|(i, (x, y))| {
|
||||
@@ -244,7 +295,7 @@ impl Game<'_> {
|
||||
|
||||
/// Draws the title row, the page's recipes and the page arrows.
|
||||
pub(super) fn draw_browser_recipes(&mut self, batch: &mut HudBatch, layout: &Layout) {
|
||||
let Some(pages) = &self.mods.browser.recipes else {
|
||||
let Some(pages) = &self.panel.recipes else {
|
||||
return;
|
||||
};
|
||||
let cursor = self.cursor;
|
||||
@@ -324,7 +375,7 @@ impl Game<'_> {
|
||||
batch.text(atlas, &text, x, layout.arrows_y + 5.0, u32::MAX, true);
|
||||
}
|
||||
for (i, (x, y)) in self.recipes_shown(layout) {
|
||||
let Some(r) = self.mods.browser.recipes.as_ref().map(|p| &p.recipes[i]) else {
|
||||
let Some(r) = self.panel.recipes.as_ref().map(|p| &p.recipes[i]) else {
|
||||
break;
|
||||
};
|
||||
let (at, arrow, action) = r.places(layout.width());
|
||||
@@ -405,7 +456,7 @@ impl Game<'_> {
|
||||
|
||||
/// The tabs, on slots; the selected one framed.
|
||||
fn draw_recipe_tabs(&mut self, batch: &mut HudBatch, layout: &Layout) {
|
||||
let Some(pages) = &self.mods.browser.recipes else {
|
||||
let Some(pages) = &self.panel.recipes else {
|
||||
return;
|
||||
};
|
||||
let places = tab_places(pages, layout);
|
||||
@@ -451,10 +502,10 @@ impl Game<'_> {
|
||||
pub(super) fn browser_recipes_press(
|
||||
&mut self,
|
||||
layout: &Layout,
|
||||
owner: usize,
|
||||
owner: &Owner,
|
||||
right: bool,
|
||||
) -> bool {
|
||||
let Some(pages) = &self.mods.browser.recipes else {
|
||||
let Some(pages) = &self.panel.recipes else {
|
||||
return false;
|
||||
};
|
||||
let cursor = self.cursor;
|
||||
@@ -469,38 +520,34 @@ impl Game<'_> {
|
||||
None
|
||||
};
|
||||
if let Some(to) = turn {
|
||||
if let Some(p) = self.mods.browser.recipes.as_mut() {
|
||||
if let Some(p) = self.panel.recipes.as_mut() {
|
||||
p.page = to;
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
self.mods
|
||||
.mods
|
||||
.browser_event(owner, BrowserEvent::Page(starts[to] as u32));
|
||||
self.panel_event(owner, BrowserEvent::Page(starts[to] as u32));
|
||||
return true;
|
||||
}
|
||||
if let Some((tab, _, _)) = self.recipe_tab_hovered(layout) {
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
self.mods
|
||||
.mods
|
||||
.browser_event(owner, BrowserEvent::Tab(tab as u32));
|
||||
self.panel_event(owner, BrowserEvent::Tab(tab as u32));
|
||||
return true;
|
||||
}
|
||||
if inside(cursor, self.back_button(layout).0) {
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
self.mods.mods.browser_event(owner, BrowserEvent::Back);
|
||||
self.panel_event(owner, BrowserEvent::Back);
|
||||
return true;
|
||||
}
|
||||
if let Some((recipe, slot, stack, _)) = self.recipe_slot_hovered(layout) {
|
||||
let event = BrowserEvent::SlotClicked(BrowserSlotClick {
|
||||
let event = BrowserEvent::SlotClicked {
|
||||
recipe: recipe as u32,
|
||||
slot: slot as u32,
|
||||
stack,
|
||||
right,
|
||||
});
|
||||
self.mods.mods.browser_event(owner, event);
|
||||
};
|
||||
self.panel_event(owner, event);
|
||||
return true;
|
||||
}
|
||||
let action = self.recipes_shown(layout).into_iter().find(|&(i, (x, y))| {
|
||||
@@ -512,9 +559,7 @@ impl Game<'_> {
|
||||
if pages.recipes[i].action == Some(true) {
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
self.mods
|
||||
.mods
|
||||
.browser_event(owner, BrowserEvent::Action(i as u32));
|
||||
self.panel_event(owner, BrowserEvent::Action(i as u32));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
+140
-17
@@ -1,18 +1,33 @@
|
||||
//! Capability `recipes` (docs/mods.md): every item and recipe the game
|
||||
//! knows, made once for the mods that read them, and the recipe book's
|
||||
//! known displays. The recipes are the window's own data: the world's in
|
||||
//! single player, vanilla's on a server (a client is sent no others), while
|
||||
//! the known displays follow the server's book as the recipe book does.
|
||||
use super::Game;
|
||||
//! Every item and recipe the game knows, made once for the mods that read
|
||||
//! them (native mods' `native_recipes`, capability `recipes` of WebAssembly
|
||||
//! mods, docs/mods.md), and the recipe book's known displays; placing a
|
||||
//! recipe and creative gifts. The recipes are the window's own data: the
|
||||
//! world's in single player, vanilla's on a server (a client is sent no
|
||||
//! others), while the known displays follow the server's book as the recipe
|
||||
//! book does.
|
||||
use super::{
|
||||
Game,
|
||||
native_hooks::{
|
||||
NativeItem as RecipeItem, NativeRecipe as Recipe, NativeRecipeKind as RecipeKind,
|
||||
NativeRecipeStack as RecipeStack, NativeRecipes as Recipes,
|
||||
},
|
||||
};
|
||||
use horizon_client::{
|
||||
crafting::Ingredient,
|
||||
items::ItemStack,
|
||||
recipe_book::{Category, Display},
|
||||
stations::Smithing,
|
||||
};
|
||||
use horizon_mods::{Recipe, RecipeItem, RecipeKind, RecipeStack, Recipes};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// The tables, made once, and the recipe book's known displays with the
|
||||
/// book's version they are of.
|
||||
#[derive(Default)]
|
||||
pub(super) struct RecipeTables {
|
||||
tables: Option<Arc<Recipes>>,
|
||||
known: (Option<u64>, Arc<[i32]>),
|
||||
}
|
||||
|
||||
/// Item id -> its index in [`Recipes::items`].
|
||||
struct Index(HashMap<Arc<str>, u32>);
|
||||
impl Index {
|
||||
@@ -70,6 +85,34 @@ fn recipe(id: String, kind: RecipeKind) -> Recipe {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `native_give`: `count` of `item`, refused outside a world, unless
|
||||
/// creative and for an item the game doesn't have.
|
||||
pub(super) fn recipe_give(&mut self, item: &str, count: u32) -> Result<(), String> {
|
||||
if self.player.is_none() || self.res.rest.get().is_none() {
|
||||
return Err("not in a world".into());
|
||||
}
|
||||
if !self.creative {
|
||||
return Err("only in creative mode".into());
|
||||
}
|
||||
if self.res.items().get(item).is_none() {
|
||||
return Err(format!("no item {item}"));
|
||||
}
|
||||
self.mod_give(item, count.clamp(1, 99), "");
|
||||
Ok(())
|
||||
}
|
||||
/// `native_place_recipe`: refused as `recipes.place` refuses it.
|
||||
pub(super) fn recipe_place(&mut self, book_id: i32, all: bool) -> Result<(), String> {
|
||||
if !self.mod_recipe_screen() {
|
||||
return Err("no screen that takes recipes is open".into());
|
||||
}
|
||||
if !self.known_displays().contains(&book_id) {
|
||||
return Err(format!(
|
||||
"recipe {book_id} is not in the player's recipe book"
|
||||
));
|
||||
}
|
||||
self.mod_place_recipe(book_id, all);
|
||||
Ok(())
|
||||
}
|
||||
/// `recipes.give`: a creative player takes the stack as from the
|
||||
/// creative inventory; a server hears it as `SetCreativeModeSlot`
|
||||
/// through the inventory's sync, and refuses it unless creative.
|
||||
@@ -92,20 +135,25 @@ impl Game<'_> {
|
||||
extra,
|
||||
});
|
||||
}
|
||||
/// Capability `recipes`'s tables and known displays for this tick's
|
||||
/// snapshot; nothing while no running mod reads them.
|
||||
pub(super) fn mod_recipes(&mut self) -> (Option<Arc<Recipes>>, Arc<[i32]>) {
|
||||
if !self.mods.mods.wants_recipes() {
|
||||
return (None, Arc::from([]));
|
||||
/// The tables, made on the first call; none while the world resources
|
||||
/// they come from are still loading behind the title on a slow start.
|
||||
pub(super) fn made_recipes(&mut self) -> Option<Arc<Recipes>> {
|
||||
self.res.rest.get()?;
|
||||
if self.recipe_tables.tables.is_none() {
|
||||
self.recipe_tables.tables = Some(Arc::new(self.make_mod_recipes()));
|
||||
}
|
||||
if self.mods.recipes.is_none() {
|
||||
self.mods.recipes = Some(Arc::new(self.make_mod_recipes()));
|
||||
self.recipe_tables.tables.clone()
|
||||
}
|
||||
/// The `book_id`s the player's recipe book holds now.
|
||||
pub(super) fn known_displays(&mut self) -> Arc<[i32]> {
|
||||
if self.res.rest.get().is_none() {
|
||||
return Arc::from([]);
|
||||
}
|
||||
let (version, known) = self.recipe_book_displays();
|
||||
if self.mods.known_recipes.0 != Some(version) {
|
||||
self.mods.known_recipes = (Some(version), known.into());
|
||||
if self.recipe_tables.known.0 != Some(version) {
|
||||
self.recipe_tables.known = (Some(version), known.into());
|
||||
}
|
||||
(self.mods.recipes.clone(), self.mods.known_recipes.1.clone())
|
||||
self.recipe_tables.known.1.clone()
|
||||
}
|
||||
|
||||
fn make_mod_recipes(&mut self) -> Recipes {
|
||||
@@ -272,3 +320,78 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Capability `recipes` of WebAssembly mods: the same tables in the
|
||||
/// runtime's types.
|
||||
#[cfg(feature = "mods")]
|
||||
impl Game<'_> {
|
||||
/// Capability `recipes`'s tables and known displays for this tick's
|
||||
/// snapshot; nothing while no running mod reads them.
|
||||
pub(super) fn mod_recipes(&mut self) -> (Option<Arc<horizon_mods::Recipes>>, Arc<[i32]>) {
|
||||
if !self.mods.mods.wants_recipes() || self.res.rest.get().is_none() {
|
||||
return (None, Arc::from([]));
|
||||
}
|
||||
if self.mods.recipes.is_none() {
|
||||
self.mods.recipes = self.made_recipes().map(|t| Arc::new(wasm::recipes(&t)));
|
||||
}
|
||||
(self.mods.recipes.clone(), self.known_displays())
|
||||
}
|
||||
}
|
||||
|
||||
/// The native tables as the WebAssembly runtime's.
|
||||
#[cfg(feature = "mods")]
|
||||
mod wasm {
|
||||
use super::{Recipe, RecipeItem, RecipeKind, RecipeStack, Recipes};
|
||||
use horizon_mods as m;
|
||||
|
||||
fn kind(k: RecipeKind) -> m::RecipeKind {
|
||||
match k {
|
||||
RecipeKind::CraftingShaped => m::RecipeKind::CraftingShaped,
|
||||
RecipeKind::CraftingShapeless => m::RecipeKind::CraftingShapeless,
|
||||
RecipeKind::Smelting => m::RecipeKind::Smelting,
|
||||
RecipeKind::Blasting => m::RecipeKind::Blasting,
|
||||
RecipeKind::Smoking => m::RecipeKind::Smoking,
|
||||
RecipeKind::Campfire => m::RecipeKind::Campfire,
|
||||
RecipeKind::Stonecutting => m::RecipeKind::Stonecutting,
|
||||
RecipeKind::Smithing => m::RecipeKind::Smithing,
|
||||
RecipeKind::Brewing => m::RecipeKind::Brewing,
|
||||
}
|
||||
}
|
||||
fn stacks(list: &[RecipeStack]) -> Vec<m::RecipeStack> {
|
||||
list.iter()
|
||||
.map(|s| m::RecipeStack {
|
||||
item: s.item,
|
||||
count: s.count,
|
||||
components: s.components.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn recipe(r: &Recipe) -> m::Recipe {
|
||||
m::Recipe {
|
||||
id: r.id.clone(),
|
||||
kind: kind(r.kind),
|
||||
category: r.category.clone(),
|
||||
group: r.group.clone(),
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
slots: r.slots.iter().map(|s| stacks(s)).collect(),
|
||||
results: stacks(&r.results),
|
||||
cooking_time: r.cooking_time,
|
||||
experience: r.experience,
|
||||
book_id: r.book_id,
|
||||
}
|
||||
}
|
||||
fn item(i: &RecipeItem) -> m::RecipeItem {
|
||||
m::RecipeItem {
|
||||
id: i.id.clone(),
|
||||
name: i.name.clone(),
|
||||
tags: i.tags.clone(),
|
||||
}
|
||||
}
|
||||
pub(super) fn recipes(t: &Recipes) -> m::Recipes {
|
||||
m::Recipes {
|
||||
items: t.items.iter().map(item).collect(),
|
||||
recipes: t.recipes.iter().map(recipe).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,15 @@ use horizon_core::{
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Reused buffers for one pass over the spawning chunks: the candidate chunks
|
||||
/// and the set that keeps each one once. The buffers keep their capacity
|
||||
/// between ticks.
|
||||
#[derive(Default)]
|
||||
pub(super) struct SpawnScratch {
|
||||
pub(super) candidates: Vec<(i32, i32)>,
|
||||
pub(super) seen: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
|
||||
}
|
||||
|
||||
/// A player in the level in place, as spawning and despawning see it.
|
||||
pub(super) struct LevelPlayer {
|
||||
pub(super) position: Vec3,
|
||||
@@ -445,9 +454,17 @@ impl<'a> Game<'a> {
|
||||
/// chunks (within 8 chunks of a player, `getSpawnCandidateChunks`) that
|
||||
/// are ticking and within 128 blocks of a non-spectator. Also returns how
|
||||
/// many chunks the tracker holds (`getNaturalSpawnChunkCount`).
|
||||
pub(super) fn spawning_chunks(&self, players: &[LevelPlayer]) -> (i32, Vec<(i32, i32)>) {
|
||||
let mut candidates: Vec<(i32, i32)> = Vec::with_capacity(289 * players.len());
|
||||
let mut seen = horizon_client::fast_hash::FxHashSet::default();
|
||||
pub(super) fn spawning_chunks(
|
||||
&self,
|
||||
players: &[LevelPlayer],
|
||||
scratch: &mut SpawnScratch,
|
||||
) -> i32 {
|
||||
let candidates = &mut scratch.candidates;
|
||||
candidates.clear();
|
||||
candidates.reserve(289 * players.len());
|
||||
let seen = &mut scratch.seen;
|
||||
seen.clear();
|
||||
seen.reserve(289 * players.len());
|
||||
for p in players {
|
||||
let (pcx, pcz) = (
|
||||
p.position.x.floor() as i32 >> 4,
|
||||
@@ -471,7 +488,7 @@ impl<'a> Game<'a> {
|
||||
.iter()
|
||||
.any(|p| close_enough_for_spawning(p, (cx, cz)))
|
||||
});
|
||||
(count, candidates)
|
||||
count
|
||||
}
|
||||
/// `ServerChunkCache.tickChunks`: the spawn state over the loaded mobs,
|
||||
/// the categories under their global caps (creatures every 400 ticks),
|
||||
@@ -482,8 +499,9 @@ impl<'a> Game<'a> {
|
||||
if players.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (spawnable, mut chunks) = self.spawning_chunks(&players);
|
||||
for &key in &chunks {
|
||||
let mut scratch = std::mem::take(&mut self.spawn_scratch);
|
||||
let spawnable = self.spawning_chunks(&players, &mut scratch);
|
||||
for &key in &scratch.candidates {
|
||||
*self.world.inhabited.entry(key).or_insert(0) += 1;
|
||||
}
|
||||
let spawn_mobs = self.ui.rules.bool("spawn_mobs");
|
||||
@@ -524,12 +542,12 @@ impl<'a> Game<'a> {
|
||||
Vec::new()
|
||||
};
|
||||
// Util.shuffle.
|
||||
for i in (1..chunks.len()).rev() {
|
||||
for i in (1..scratch.candidates.len()).rev() {
|
||||
let j = random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
|
||||
chunks.swap(i, j);
|
||||
scratch.candidates.swap(i, j);
|
||||
}
|
||||
if !categories.is_empty() {
|
||||
for chunk in chunks {
|
||||
for chunk in scratch.candidates.drain(..) {
|
||||
let mut spawn = |l: &GameLevel, r: &SpawnRequest| {
|
||||
let spawned = l.game.finalize_spawned(r, &mut pending);
|
||||
if let Some(s) = &spawned {
|
||||
@@ -551,6 +569,7 @@ impl<'a> Game<'a> {
|
||||
}
|
||||
self.world.random = random;
|
||||
self.add_spawned(pending, false);
|
||||
self.spawn_scratch = scratch;
|
||||
}
|
||||
/// `NoiseBasedChunkGenerator.spawnOriginalMobs`: creature packs for a
|
||||
/// freshly generated chunk, from the biome at its corner column's top,
|
||||
|
||||
@@ -44,7 +44,7 @@ pub(super) struct Plan {
|
||||
height: f32,
|
||||
/// Through an End portal: `approximate` is the End's spawn point, or the
|
||||
/// Overworld's shared spawn.
|
||||
end: bool,
|
||||
pub(super) end: bool,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
@@ -91,6 +91,10 @@ pub(super) struct Portals {
|
||||
/// Positions before this tick's moves, by entity id.
|
||||
before: HashMap<u32, Vec3>,
|
||||
arrivals: Vec<Arrival>,
|
||||
/// The vehicles server players ride that went through a portal this
|
||||
/// tick, in their level, with their entry: they carry their riders
|
||||
/// (`tick_server_travel`).
|
||||
carrying: Vec<(Dim, u32, BlockPos)>,
|
||||
loaded: bool,
|
||||
dirty: bool,
|
||||
/// Per-chunk portal palette masks, kept across ticks (portal_sections).
|
||||
@@ -628,7 +632,7 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.map(|b| b.id)
|
||||
.chain(self.carts.iter().map(|c| c.id))
|
||||
.map(|id| (id, !self.vehicle_occupied(id))),
|
||||
.map(|id| (id, self.vehicle_can_use_portal(id))),
|
||||
)
|
||||
.collect();
|
||||
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
|
||||
@@ -645,7 +649,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
for (id, entry) in leaving {
|
||||
self.send_through_portal(id, entry);
|
||||
// `Entity.teleportCrossDimension` carries the passengers: a
|
||||
// vehicle server players ride takes them along.
|
||||
if self.guest_rides(id) {
|
||||
self.portals.carrying.push((self.dim, id, entry));
|
||||
} else {
|
||||
self.send_through_portal(id, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Entity.load`: the saved `PortalCooldown` (an entity that just came
|
||||
@@ -681,8 +691,51 @@ impl Game<'_> {
|
||||
m.alive()
|
||||
&& m.extra.mount.is_none()
|
||||
&& self.riding != Some(m.id)
|
||||
&& (!self.guest_rides(m.id) || self.carries_riders())
|
||||
&& m.species() != Species::Wither
|
||||
}
|
||||
/// `canUsePortal(false)` of a boat or minecart: empty, or ridden by
|
||||
/// server players only, whom it carries along.
|
||||
fn vehicle_can_use_portal(&self, id: u32) -> bool {
|
||||
if !self.vehicle_occupied(id) {
|
||||
return true;
|
||||
}
|
||||
self.carries_riders()
|
||||
&& self.riding != Some(id)
|
||||
&& !self.mobs.iter().any(|m| m.extra.mount == Some(id))
|
||||
}
|
||||
/// Whether a vehicle can take the server players riding it through a
|
||||
/// portal: only a server moves players between its levels. Elsewhere a
|
||||
/// ridden vehicle stays, rather than leave its rider behind.
|
||||
fn carries_riders(&self) -> bool {
|
||||
self.options.dedicated.is_some()
|
||||
}
|
||||
/// The vehicles that went through a portal in `dim` with server players
|
||||
/// on them.
|
||||
pub(super) fn take_carrying(&mut self, dim: Dim) -> Vec<(u32, BlockPos)> {
|
||||
let (here, other) = std::mem::take(&mut self.portals.carrying)
|
||||
.into_iter()
|
||||
.partition(|(d, ..)| *d == dim);
|
||||
self.portals.carrying = other;
|
||||
here.into_iter().map(|(_, id, entry)| (id, entry)).collect()
|
||||
}
|
||||
/// The plan of vehicle `id` through the portal at `entry`: from its own
|
||||
/// position and size (`getPortalDestination(level, vehicle)`).
|
||||
pub(super) fn vehicle_plan(&self, id: u32, entry: BlockPos) -> Option<Plan> {
|
||||
if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
|
||||
return Some(self.entity_plan(entry, m.position, m.width(), m.height(), true));
|
||||
}
|
||||
if let Some(b) = self.boats.iter().find(|b| b.id == id) {
|
||||
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
|
||||
return Some(self.entity_plan(entry, b.position, width, height, false));
|
||||
}
|
||||
let c = self.carts.iter().find(|c| c.id == id)?;
|
||||
let (width, height) = (
|
||||
super::minecarts::WIDTH as f32,
|
||||
super::minecarts::HEIGHT as f32,
|
||||
);
|
||||
Some(self.entity_plan(entry, c.position, width, height, false))
|
||||
}
|
||||
/// Whether the player, a server player or a mob rides vehicle `id`.
|
||||
fn vehicle_occupied(&self, id: u32) -> bool {
|
||||
self.riding == Some(id)
|
||||
@@ -721,7 +774,7 @@ impl Game<'_> {
|
||||
super::minecarts::HEIGHT,
|
||||
)
|
||||
}))
|
||||
.filter(|(id, ..)| !self.vehicle_occupied(*id))
|
||||
.filter(|(id, ..)| self.vehicle_can_use_portal(*id))
|
||||
.map(|(id, at, w, h)| (id, at, w as f32, h as f32))
|
||||
.collect();
|
||||
let mut portal_sections = std::mem::take(&mut self.portals.sections);
|
||||
@@ -920,6 +973,18 @@ impl Game<'_> {
|
||||
self.portals.dirty = true;
|
||||
}
|
||||
}
|
||||
/// The chunks of the arrivals still waiting to land in `dim`, for the
|
||||
/// streaming `extra` areas that light them: vanilla's portal ticket
|
||||
/// (`Entity.teleport` adds `TicketType.PORTAL` at the destination) keeps
|
||||
/// the area loaded without a player.
|
||||
pub(super) fn arrival_chunks(&self, dim: Dim) -> Vec<(i32, i32)> {
|
||||
self.portals
|
||||
.arrivals
|
||||
.iter()
|
||||
.filter(|a| a.plan.dim == dim)
|
||||
.map(|a| (a.plan.approximate.x >> 4, a.plan.approximate.z >> 4))
|
||||
.collect()
|
||||
}
|
||||
/// Entities waiting for the loaded dimension whose exit area is loaded:
|
||||
/// their exit portal (found or built) or End destination, position,
|
||||
/// turned yaw and motion, and portal cooldown; they join the world with the chunk entities.
|
||||
|
||||
@@ -246,6 +246,7 @@ impl Game<'_> {
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::ModSettings(i) => self.open_mod_screen(i, true),
|
||||
Event::NativeButton(i, b) => self.native_settings_event(i, b),
|
||||
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
|
||||
}
|
||||
}
|
||||
@@ -273,6 +274,7 @@ impl Game<'_> {
|
||||
s.horizon.hdr,
|
||||
s.horizon.hdr_paper_white as f32,
|
||||
s.horizon.hdr_peak as f32,
|
||||
&Self::options_path().with_file_name(horizon_client::graphics::HDR_MARKER),
|
||||
);
|
||||
}
|
||||
// Window.updateFullscreenIfChanged: only a changed option asks the
|
||||
|
||||
@@ -45,6 +45,14 @@ pub fn open_folder(dir: &Path) {
|
||||
}
|
||||
/// `Util.getPlatform().openUri`: the system browser on the link.
|
||||
pub fn open_uri(uri: &str) {
|
||||
// URLs need Windows' registered protocol handler, not Explorer's path
|
||||
// handling. ShellExecute also preserves the URL as one Unicode value,
|
||||
// without interpolating its query string into a command interpreter.
|
||||
#[cfg(windows)]
|
||||
if let Err(error) = open::that_detached(uri) {
|
||||
log::warn!("Horizon: could not open URI {uri}: {error}");
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
open_folder(Path::new(uri));
|
||||
}
|
||||
/// The resource root to load from, given the vanilla root, the selection and
|
||||
|
||||
Loaded 100 of 287 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user