mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
295
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9a103852c | ||
|
|
a15faef940 | ||
|
|
58cd73ee0b | ||
|
|
e2a3e92424 | ||
|
|
6f8c7a0e5d | ||
|
|
04cbda1b14 | ||
|
|
2de8bbd84b | ||
|
|
a7177cfbd7 | ||
|
|
b558a845a0 | ||
|
|
9f1a998403 | ||
|
|
527cd7225c | ||
|
|
f64fdeaa75 | ||
|
|
a0c79c8b26 | ||
|
|
0284d3bb16 | ||
|
|
72b4d8ecbb | ||
|
|
1ed31e54eb | ||
|
|
b3aaa04129 | ||
|
|
bb38695695 | ||
|
|
6bda112225 | ||
|
|
77fa9fb89f | ||
|
|
153d18a102 | ||
|
|
e470f3a0c9 | ||
|
|
f93c28a230 | ||
|
|
b9721d816d | ||
|
|
2034db1df6 | ||
|
|
6edc4e950d | ||
|
|
d4120a9ef0 | ||
|
|
795a0db7e4 | ||
|
|
75b8a176d4 | ||
|
|
21a0656530 | ||
|
|
071580e716 | ||
|
|
ae01d872fb | ||
|
|
d2f5a0d678 | ||
|
|
d6ee462614 | ||
|
|
430e5e4c92 | ||
|
|
f3d8535fcd | ||
|
|
559ecb02f6 | ||
|
|
11b8757bc7 | ||
|
|
b44da42ca4 | ||
|
|
9669600c38 | ||
|
|
528c322d16 | ||
|
|
a9f8e64331 | ||
|
|
01d0f45c2d | ||
|
|
228af35f2a | ||
|
|
abd1755f95 | ||
|
|
81f339814b | ||
|
|
0590a85d16 | ||
|
|
be6beb6ec7 | ||
|
|
9cdfc5bd44 | ||
|
|
26af55000a | ||
|
|
3782473f52 | ||
|
|
9cbc96ee7b | ||
|
|
af0fef9196 | ||
|
|
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 | ||
|
|
0281365907 | ||
|
|
6f6229c2d4 | ||
|
|
4d477067c3 | ||
|
|
998c94bee3 | ||
|
|
4a18a4bde4 | ||
|
|
f6b467eba9 | ||
|
|
23d2146066 | ||
|
|
5f8afb17fc | ||
|
|
f7bb1ceb79 | ||
|
|
b9494af976 | ||
|
|
d1ead6a5fa | ||
|
|
3511d556ef | ||
|
|
4ff7118db7 | ||
|
|
da9950c75b | ||
|
|
626068da3a | ||
|
|
4bf633e8a9 | ||
|
|
f4d6f9c0ed | ||
|
|
93e61ef936 | ||
|
|
e8cb6ae83b | ||
|
|
99008baad9 | ||
|
|
e1bc2cbea3 | ||
|
|
c66554126d | ||
|
|
4a1c5fc54c | ||
|
|
7b93b310a0 | ||
|
|
a1b32304a3 | ||
|
|
7934a0b180 | ||
|
|
992c63eefb | ||
|
|
a967ca4340 | ||
|
|
0112a6feac | ||
|
|
c0b560527f | ||
|
|
e06134f71a | ||
|
|
330314327a | ||
|
|
9d32bebad3 | ||
|
|
012a3cf908 | ||
|
|
0e03cac368 | ||
|
|
e24394ca3a | ||
|
|
a5ee1e588c | ||
|
|
a48fcf0d16 | ||
|
|
2eb3de2748 | ||
|
|
67511d9749 | ||
|
|
df90f62d35 | ||
|
|
752fc2170a | ||
|
|
356b69aea7 | ||
|
|
efa8df83b3 | ||
|
|
58422bcc19 | ||
|
|
fdefcfeac0 | ||
|
|
5c14d18141 | ||
|
|
0f8e3a9b09 | ||
|
|
4ce1eb6b0a | ||
|
|
47d249e7dd | ||
|
|
ea6ebae264 | ||
|
|
d878f3bed2 | ||
|
|
ec5fc9d5e4 | ||
|
|
99948fdf3f | ||
|
|
73ad5f1dbb | ||
|
|
6e26990ad3 | ||
|
|
ffb6594600 | ||
|
|
1d693cb3c3 | ||
|
|
23e3cf465c | ||
|
|
0d3ca74bd0 | ||
|
|
f69088012b | ||
|
|
154aaa2051 | ||
|
|
1ab273641e | ||
|
|
785d579f47 | ||
|
|
3fe8aa76b2 | ||
|
|
dc75e77eb1 | ||
|
|
94e8c85fd0 | ||
|
|
28c30923f4 | ||
|
|
2b74cfae4a | ||
|
|
60a4edaef9 | ||
|
|
e9eaa66542 | ||
|
|
2c2aa41619 | ||
|
|
f164427cba | ||
|
|
9fcfdaa1ea | ||
|
|
b77301b6eb | ||
|
|
3be828d012 | ||
|
|
e08adcd31c | ||
|
|
830a9d3d84 | ||
|
|
6f0d56512d | ||
|
|
482444afc4 | ||
|
|
0d7f842b09 | ||
|
|
a1dfcbf2b3 | ||
|
|
5703fdfdc4 | ||
|
|
57494e6871 | ||
|
|
ad8ef0b7e8 | ||
|
|
f9fca5688d | ||
|
|
4156a6ac93 | ||
|
|
6c3defa821 | ||
|
|
8057d27511 | ||
|
|
4e814a152a | ||
|
|
3f378dda96 | ||
|
|
70068076f2 | ||
|
|
59b67af6a8 | ||
|
|
1b469a542f | ||
|
|
567e92e2a2 | ||
|
|
5388064d88 | ||
|
|
20ffd8e538 | ||
|
|
c3e19973cf | ||
|
|
7aaa4b433b | ||
|
|
3cf601f760 | ||
|
|
39327fca33 | ||
|
|
6489cdf829 | ||
|
|
1727f2acf1 | ||
|
|
079963f27e | ||
|
|
3c72bf2d79 | ||
|
|
16cda1637b | ||
|
|
513aed64c5 | ||
|
|
7ab2b20bd5 | ||
|
|
dddcc99726 | ||
|
|
1deb3bd36c | ||
|
|
2ad7b68710 | ||
|
|
203fb2db89 | ||
|
|
2f7edd1524 | ||
|
|
b8f0cde900 | ||
|
|
c4348be8d2 | ||
|
|
32a0a11fed | ||
|
|
22cab3eeb3 | ||
|
|
e7f5201c1f | ||
|
|
0580626784 | ||
|
|
f507a067cd | ||
|
|
28f906c37d | ||
|
|
f8f2f904bc | ||
|
|
65bca4ce98 | ||
|
|
2812cf201d | ||
|
|
ce848af067 | ||
|
|
d3be8dbf05 | ||
|
|
9bbc54bd4c | ||
|
|
152334eee3 | ||
|
|
4fdc93257f | ||
|
|
67e0d54062 | ||
|
|
53cb3a19d1 | ||
|
|
84390f6dc1 | ||
|
|
9d191c28f2 | ||
|
|
5d42108a98 | ||
|
|
1972ef3bef | ||
|
|
e8145b07fa | ||
|
|
5e7295a631 | ||
|
|
761a770174 | ||
|
|
7f0a61954b | ||
|
|
a8903cba07 | ||
|
|
00df6c646f | ||
|
|
1f040d06eb | ||
|
|
d13b478ca2 | ||
|
|
b38fe43b09 | ||
|
|
91de744250 | ||
|
|
1654cb51db | ||
|
|
35581f52e2 | ||
|
|
f105583803 | ||
|
|
c216561daa | ||
|
|
618d20bd08 | ||
|
|
985ece811b | ||
|
|
c734c72fc9 | ||
|
|
fa940d5b6b | ||
|
|
bcdbc252fe | ||
|
|
186198afe6 | ||
|
|
fcc7a818b4 | ||
|
|
4c4fcedf40 | ||
|
|
de8dd66ac2 | ||
|
|
6fc2006447 | ||
|
|
7e68f1ed32 | ||
|
|
9ebccd6a59 | ||
|
|
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
|
||||
@@ -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
|
||||
@@ -56,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:
|
||||
@@ -71,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 }}
|
||||
@@ -96,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"
|
||||
@@ -180,6 +192,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
issues: read # the open release-blocker issues (gate)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -194,9 +207,17 @@ 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:
|
||||
@@ -208,7 +229,8 @@ jobs:
|
||||
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)" \
|
||||
@@ -249,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"
|
||||
Generated
+37
@@ -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"
|
||||
|
||||
+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" }
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -1326,8 +1339,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
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -597,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(
|
||||
@@ -757,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 {
|
||||
@@ -768,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"),
|
||||
@@ -898,6 +936,7 @@ impl BlockEntityRenderer {
|
||||
closed_chests,
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
}
|
||||
}
|
||||
fn kind(&self, state: u32) -> &Kind {
|
||||
@@ -932,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(
|
||||
@@ -1168,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),
|
||||
@@ -1954,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.
|
||||
@@ -2154,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 };
|
||||
@@ -2214,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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -122,6 +122,7 @@ mod moved_extras;
|
||||
mod moving_pistons;
|
||||
mod music;
|
||||
mod names;
|
||||
mod native_hooks;
|
||||
mod native_shaders;
|
||||
mod natural_spawning;
|
||||
mod nautili;
|
||||
@@ -274,6 +275,7 @@ use std::{
|
||||
};
|
||||
use winit::{
|
||||
application::ApplicationHandler,
|
||||
error::EventLoopError,
|
||||
event::{DeviceEvent, DeviceId, ElementState, MouseButton, MouseScrollDelta, WindowEvent},
|
||||
event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
@@ -1407,6 +1409,8 @@ struct Game<'a> {
|
||||
/// The link a chat click asks to open (`ConfirmLinkScreen`).
|
||||
link_prompt: Option<String>,
|
||||
dialogs: dialogs::DialogState,
|
||||
/// Single player's server while it loads its world (`Minecraft.doWorldLoad`).
|
||||
integrated_starting: Option<integrated::Starting>,
|
||||
world_renderer_ready: bool,
|
||||
collision: &'a BlockCollisionRegistry<'a>,
|
||||
motion: &'a MotionResources,
|
||||
@@ -1792,6 +1796,8 @@ struct Game<'a> {
|
||||
creaking: creaking::CreakingState,
|
||||
/// Monster spawners' display mobs (`game/spawner.rs`).
|
||||
spawners: spawner::SpawnerState,
|
||||
/// Reused candidate buffers for natural spawning.
|
||||
spawn_scratch: natural_spawning::SpawnScratch,
|
||||
trident_model: Option<horizon_client::mobs::Part>,
|
||||
arrow_model: Option<horizon_client::mobs::Part>,
|
||||
tnts: Vec<tnt::PrimedTnt>,
|
||||
@@ -2330,12 +2336,19 @@ impl Game<'_> {
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.map_or(64, |i| i.max_stack);
|
||||
// Prefer the selected slot, then the hotbar, then the main inventory.
|
||||
// getSlotWithRemainingSpace: the selected slot, the off hand, then
|
||||
// the hotbar and the main inventory.
|
||||
let order: Vec<usize> = std::iter::once(self.selected)
|
||||
.chain(std::iter::once(shield::OFFHAND_SLOT))
|
||||
.chain((0..36).filter(|&i| i != self.selected))
|
||||
.collect();
|
||||
for &i in &order {
|
||||
if let Some(slot) = &mut self.inventory[i]
|
||||
let slot = if i == shield::OFFHAND_SLOT {
|
||||
&mut self.offhand
|
||||
} else {
|
||||
&mut self.inventory[i]
|
||||
};
|
||||
if let Some(slot) = slot
|
||||
&& slot.same_item_same_components(&stack)
|
||||
&& slot.count < max
|
||||
{
|
||||
@@ -3553,6 +3566,9 @@ impl Game<'_> {
|
||||
state,
|
||||
kind: "dispense",
|
||||
} => self.dispense_from(pos, state)?,
|
||||
LevelEvent::BlockEvent {
|
||||
pos, kind: "golem", ..
|
||||
} => self.check_golem_spawn([pos.x, pos.y, pos.z])?,
|
||||
LevelEvent::BlockEvent {
|
||||
pos,
|
||||
kind: "command_block",
|
||||
@@ -3686,6 +3702,7 @@ impl Game<'_> {
|
||||
self.open_ender_chest(key);
|
||||
// EnderChestBlock.useWithoutItem
|
||||
self.stats.custom("open_enderchest", 1);
|
||||
self.anger_piglins(true);
|
||||
if self.container_opener_event(key, true) {
|
||||
self.container_edge_sound(key, true);
|
||||
}
|
||||
@@ -4013,12 +4030,36 @@ impl Game<'_> {
|
||||
// library: the block entity's side effects (containers spill), neighbour
|
||||
// and shape updates, and the destroy event (particles, sound).
|
||||
let creative = self.creative;
|
||||
// TripWireBlock.playerWillDestroy: shears disarm the string first (flags
|
||||
// 260), so taking it down does not trip the hook.
|
||||
if &*facts.name == "minecraft:tripwire"
|
||||
&& self.held().is_some_and(|s| &*s.item == "minecraft:shears")
|
||||
&& let Some(disarmed) = self.with_props(state, &[("disarmed", "true")])
|
||||
{
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, disarmed, 260));
|
||||
self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None);
|
||||
}
|
||||
// BeehiveBlock.playerWillDestroy: a creative break still drops the hive
|
||||
// with its bees and honey level.
|
||||
if creative
|
||||
&& self.ui.rules.bool("block_drops")
|
||||
&& let Some((h, (honey, _))) = &hive
|
||||
&& (!h.bees.is_empty() || honey.is_some_and(|l| l > 0))
|
||||
&& let Some(stack) = self.picked_stack(pos, true)
|
||||
{
|
||||
let at = pos.map(f64::from);
|
||||
self.spawn_item(stack, at, dispenser::new_item_motion(), 10);
|
||||
}
|
||||
let correct_tool =
|
||||
!facts.requires_tool || self.res.items().correct_for_drops(self.held(), &facts.name);
|
||||
let removed = self.with_level(|b, level, ticks| {
|
||||
b.player_destroy_block(
|
||||
level,
|
||||
ticks,
|
||||
BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
creative,
|
||||
correct_tool,
|
||||
)
|
||||
});
|
||||
if removed {
|
||||
@@ -4047,11 +4088,14 @@ impl Game<'_> {
|
||||
self.pop_resource(pos, d);
|
||||
}
|
||||
}
|
||||
// BeehiveBlock.playerDestroy: silk touch keeps the bees in.
|
||||
if let Some((hive, (honey, facing))) = hive
|
||||
&& !silk
|
||||
{
|
||||
self.hive_broken_by_player(pos, hive, honey, facing);
|
||||
// BeehiveBlock.playerDestroy: silk touch keeps the bees in, so
|
||||
// the trigger counts them; otherwise they are out already.
|
||||
if let Some((hive, (honey, facing))) = hive {
|
||||
let bees = if silk { hive.bees.len() as i64 } else { 0 };
|
||||
if !silk {
|
||||
self.hive_broken_by_player(pos, hive, honey, facing);
|
||||
}
|
||||
self.trigger_bee_nest_destroyed(broken, held.as_ref(), bees);
|
||||
}
|
||||
// InfestedBlock.spawnAfterBreak: its silverfish comes out unless the
|
||||
// tool has silk touch (#prevents_infested_spawns) or blocks don't drop.
|
||||
@@ -4245,6 +4289,10 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if self.use_redstone_ore(hit.pos, held.as_ref())? {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
match self.use_respawn_anchor(hit.pos, held.as_ref())? {
|
||||
respawn_anchor::AnchorUse::Charged => {
|
||||
self.swing_arm();
|
||||
@@ -4254,9 +4302,30 @@ impl Game<'_> {
|
||||
respawn_anchor::AnchorUse::Pass => anchor_passed = true,
|
||||
respawn_anchor::AnchorUse::Other => {}
|
||||
}
|
||||
// CeilingHangingSignBlock.useItemOn: a hanging sign aimed at the
|
||||
// underside passes, so the next sign chains below instead of
|
||||
// opening the editor (signs here carry no click commands).
|
||||
let chains_sign = hit.face == [0, -1, 0]
|
||||
&& held
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.item.ends_with("_hanging_sign"))
|
||||
&& {
|
||||
let name = &self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
|
||||
.name;
|
||||
name.ends_with("_hanging_sign") && !name.contains("_wall_")
|
||||
}
|
||||
&& matches!(
|
||||
self.world
|
||||
.block_entities
|
||||
.get(&(hit.pos[0], hit.pos[1], hit.pos[2])),
|
||||
Some(BlockEntity::Sign(_))
|
||||
);
|
||||
// TRY_WITH_EMPTY_HAND: only the main hand's turn uses the block
|
||||
// itself (useWithoutItem).
|
||||
if !anchor_passed && self.hand == Hand::Main {
|
||||
if !anchor_passed && !chains_sign && self.hand == Hand::Main {
|
||||
let face = match hit.face {
|
||||
[0, 1, 0] => Direction::Up,
|
||||
[0, -1, 0] => Direction::Down,
|
||||
@@ -4987,6 +5056,7 @@ impl Game<'_> {
|
||||
};
|
||||
let mut player = PlayerMotion::new(self.motion, feet)?;
|
||||
player.yaw = self.yaw;
|
||||
let entering = self.restore_player_state;
|
||||
player.load_saved_state_once(&mut self.restore_player_state, &self.level)?;
|
||||
let spectator = self.mode == horizon_client::command::args::GameMode::Spectator;
|
||||
player.may_fly = self.creative || spectator;
|
||||
@@ -5011,6 +5081,11 @@ impl Game<'_> {
|
||||
self.capture(false);
|
||||
}
|
||||
self.previous_position = feet;
|
||||
if entering {
|
||||
// The world's first player: a server's client gets it with its
|
||||
// level (Minecraft.setLevel), which stops every sound.
|
||||
self.stop_sounds_for_new_level();
|
||||
}
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
self.sway = Default::default();
|
||||
@@ -5083,6 +5158,7 @@ impl Game<'_> {
|
||||
self.update_totem_ready();
|
||||
self.tick_command_extras();
|
||||
self.tick_mods();
|
||||
self.native_tick();
|
||||
self.tick_server_mods();
|
||||
let camera_eye = self.camera_eye_height();
|
||||
let stick = self.pad_move();
|
||||
@@ -5625,6 +5701,16 @@ impl Game<'_> {
|
||||
rotation.turn(xo * zoom, yo * zoom);
|
||||
self.yaw = rotation.y_rot;
|
||||
self.pitch = rotation.x_rot;
|
||||
// Entity.turn ends in AbstractBoat.onPassengerTurned: clampRotation
|
||||
// keeps the view within 105 degrees of the boat at every turn, not
|
||||
// only at the tick, so it never swings past and back.
|
||||
if let Some(b) = self
|
||||
.riding
|
||||
.and_then(|id| self.boats.iter().find(|b| b.id == id))
|
||||
{
|
||||
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
self.yaw += delta.clamp(-105.0, 105.0) - delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Minecraft.pause`: a single-player game with a pausing screen open.
|
||||
@@ -5687,6 +5773,8 @@ impl Game<'_> {
|
||||
let warp = |window: &Window| {
|
||||
let _ = window.set_cursor_position(centre);
|
||||
};
|
||||
let scale = HudBatch::new(size).scale;
|
||||
let was_grabbed = self.captured;
|
||||
if capture {
|
||||
self.mouse.set_ignore_first_move();
|
||||
// macOS warps by re-associating the mouse with the pointer,
|
||||
@@ -5717,13 +5805,23 @@ impl Game<'_> {
|
||||
if self.focused {
|
||||
warp(window);
|
||||
}
|
||||
let scale = HudBatch::new(size).scale;
|
||||
self.cursor = (size[0] as f32 / 2.0 / scale, size[1] as f32 / 2.0 / scale);
|
||||
} else {
|
||||
let _ = window.set_cursor_grab(CursorGrabMode::None);
|
||||
}
|
||||
self.captured = false;
|
||||
}
|
||||
// MouseHandler.grabMouse and 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
|
||||
// and the game starts with it away from the screen's last spot.
|
||||
if let Some(pos) = horizon_client::mouse::mouse_position_after_grab_change(
|
||||
capture,
|
||||
was_grabbed,
|
||||
size,
|
||||
scale,
|
||||
) {
|
||||
self.cursor = pos;
|
||||
}
|
||||
window.set_cursor_visible(!self.captured);
|
||||
}
|
||||
/// `GuiGraphicsExtractor.applyCursor` -> `Window.selectCursor` at the end
|
||||
@@ -5767,30 +5865,31 @@ impl Game<'_> {
|
||||
/// highlight (its count yellow when the slot caps it), and hides while
|
||||
/// it is the drag's only slot.
|
||||
fn draw_slot_item(
|
||||
&mut self,
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
drag: Option<&DragView>,
|
||||
slot: usize,
|
||||
stack: Option<ItemStack>,
|
||||
stack: &Option<ItemStack>,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
let carried;
|
||||
let (stack, capped) = match drag.and_then(|d| d.slots.get(&slot)) {
|
||||
Some(None) => return,
|
||||
Some(Some((count, capped))) => {
|
||||
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
|
||||
let carried = self
|
||||
carried = self
|
||||
.carried
|
||||
.clone()
|
||||
.map(|c| ItemStack { count: *count, ..c });
|
||||
(carried, *capped)
|
||||
(carried.as_ref(), *capped)
|
||||
}
|
||||
None => (stack, false),
|
||||
None => (stack.as_ref(), false),
|
||||
};
|
||||
if let Some(stack) = stack {
|
||||
self.draw_item_model(batch, &stack, x, y);
|
||||
self.draw_item_model(batch, stack, x, y);
|
||||
let text = capped.then(|| format!("\u{a7}e{}", stack.count));
|
||||
self.draw_item_decorations(batch, &stack, x, y, text);
|
||||
self.draw_item_decorations(batch, stack, x, y, text);
|
||||
}
|
||||
}
|
||||
/// `extractCarriedItem`: the carried stack at the cursor, showing what a
|
||||
@@ -6408,7 +6507,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
let stack = self.slot_view(i);
|
||||
self.draw_slot_item(batch, drag.as_ref(), i, stack, ox + sx, oy + sy);
|
||||
self.draw_slot_item(batch, drag.as_ref(), i, &stack, ox + sx, oy + sy);
|
||||
}
|
||||
}
|
||||
fn draw_inventory(&mut self, batch: &mut HudBatch) {
|
||||
@@ -8017,7 +8116,12 @@ impl Game<'_> {
|
||||
.then(|| self.gui.atlas.sprites.get("misc/shadow").copied())
|
||||
.flatten();
|
||||
if let Some(sprite) = shadow_sprite {
|
||||
let ambient = if self.dim == Dim::Nether { 0.1 } else { 0.0 };
|
||||
// DimensionType.ambientLight: the_nether 0.1, the_end 0.25.
|
||||
let ambient = match self.dim {
|
||||
Dim::Nether => 0.1,
|
||||
Dim::End => 0.25,
|
||||
Dim::Overworld => 0.0,
|
||||
};
|
||||
let darken = self.sky_darken();
|
||||
let a = alpha;
|
||||
let lerp = |p: Vec3, q: Vec3| {
|
||||
@@ -8081,6 +8185,7 @@ impl Game<'_> {
|
||||
banner: &|p| self.banner_layers_at(p),
|
||||
skull: &|p| self.skull_skin_at(p),
|
||||
glyph: &|c| self.gui.atlas.glyph_cell(c),
|
||||
glyphs: self.gui.atlas.font.epoch(),
|
||||
};
|
||||
// Block entities outside the view frustum are skipped (with a
|
||||
// block of margin for models larger than their block).
|
||||
@@ -8402,7 +8507,9 @@ impl Game<'_> {
|
||||
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
for (i, m) in self.mobs.iter().enumerate() {
|
||||
if !m.alive() {
|
||||
// ProjectileUtil.getEntityHitResult: the mob the player rides
|
||||
// shares its root vehicle and is never the hit.
|
||||
if !m.alive() || Some(m.id) == self.riding {
|
||||
continue;
|
||||
}
|
||||
if let Some(t) = ray_box(eye, dir, reach, m.bounds())
|
||||
@@ -10957,7 +11064,11 @@ impl Game<'_> {
|
||||
// Creative players skip the monster check (`!this.isCreative()`).
|
||||
let monsters = !self.creative
|
||||
&& self.mobs.iter().any(|m| {
|
||||
m.hostile()
|
||||
// `Monster.class` entities that `isPreventingPlayerRest`: a
|
||||
// zombified piglin only while it is angry.
|
||||
horizon_client::mobs::ai::is_monster(m.species())
|
||||
&& (m.species() != horizon_client::mobs::Species::ZombifiedPiglin
|
||||
|| (m.extra.anger > 0 && m.ai.target.is_some()))
|
||||
&& m.alive()
|
||||
&& (m.position.x - position.x).abs() < 8.0
|
||||
&& (m.position.y - position.y).abs() < 5.0
|
||||
@@ -11067,7 +11178,7 @@ impl Game<'_> {
|
||||
let selected = self.creative_screen.selected;
|
||||
// Only what is drawn of the tabs: their stacks stay where they are.
|
||||
let tabs = self.creative_screen.tab_headers();
|
||||
let tab = tabs[selected.min(tabs.len() - 1)].clone();
|
||||
let tab = &tabs[selected.min(tabs.len() - 1)];
|
||||
let tab_sprite = |t: &horizon_client::creative::Tab, on: bool| {
|
||||
format!(
|
||||
"gui/sprites/container/creative_inventory/tab_{}_{}_{}",
|
||||
@@ -11103,8 +11214,8 @@ impl Game<'_> {
|
||||
r.v1 = r.v0 + (r.v1 - r.v0) * Creative::HEIGHT / r.h as f32;
|
||||
batch.rect_uv(ox, oy, Creative::WIDTH, Creative::HEIGHT, r, u32::MAX);
|
||||
}
|
||||
let (tx, ty) = Creative::tab_position(&tab);
|
||||
batch.sprite(&self.gui.atlas, &tab_sprite(&tab, true), ox + tx, oy + ty);
|
||||
let (tx, ty) = Creative::tab_position(tab);
|
||||
batch.sprite(&self.gui.atlas, &tab_sprite(tab, true), ox + tx, oy + ty);
|
||||
for t in &tabs {
|
||||
let (tx, ty) = Creative::tab_position(t);
|
||||
let stack = ItemStack {
|
||||
@@ -11122,7 +11233,7 @@ impl Game<'_> {
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
if !tab.inventory {
|
||||
let title = self.creative_tab_title(&tab);
|
||||
let title = self.creative_tab_title(tab);
|
||||
batch.text(
|
||||
atlas,
|
||||
&title,
|
||||
@@ -11201,15 +11312,14 @@ impl Game<'_> {
|
||||
let drag = self.drag_view();
|
||||
for c in 0..9 {
|
||||
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0);
|
||||
let stack = self.inventory[c].clone();
|
||||
self.draw_slot_item(batch, drag.as_ref(), c, stack.clone(), x, y);
|
||||
self.draw_slot_item(batch, drag.as_ref(), c, &self.inventory[c], x, y);
|
||||
if self.cursor.0 >= x
|
||||
&& self.cursor.0 < x + 16.0
|
||||
&& self.cursor.1 >= y
|
||||
&& self.cursor.1 < y + 16.0
|
||||
{
|
||||
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
|
||||
hovered = stack;
|
||||
hovered = self.inventory[c].clone();
|
||||
}
|
||||
}
|
||||
let scrollable = self.creative_screen.rows(&self.pack.names) > 5;
|
||||
@@ -11473,6 +11583,27 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
fn render(&mut self) -> Result<bool> {
|
||||
// Minecraft.doWorldLoad: the LevelLoadingScreen while single player's
|
||||
// server starts. Its chunk map and progress are the server's, which
|
||||
// the window doesn't see yet: the title alone, over the menu background.
|
||||
if self.integrated_starting.is_some() {
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
let (w, h) = (hud.width, hud.height);
|
||||
horizon_client::ui::menu_background(
|
||||
&mut hud,
|
||||
&self.gui.atlas,
|
||||
0.0,
|
||||
0.0,
|
||||
w,
|
||||
h,
|
||||
false,
|
||||
);
|
||||
horizon_client::level_loading::draw(&mut hud, &self.gui.atlas, None, None);
|
||||
return renderer.render(&loading_frame(&hud));
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(loader) = &self.world_loader {
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
@@ -11693,7 +11824,8 @@ impl Game<'_> {
|
||||
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
|
||||
// LevelLoadingScreen (Reason.OTHER) draws the title's panorama.
|
||||
let title = self.title.is_some()
|
||||
|| self.loading_reason() == Some(horizon_client::level_loading::Reason::Other);
|
||||
|| self.loading_reason() == Some(horizon_client::level_loading::Reason::Other)
|
||||
|| self.remote_loading();
|
||||
let (yaw, pitch) = if title {
|
||||
// PanoramaRenderer: the title cube spins at 0.1 degrees per tick, pitched 10 degrees.
|
||||
(
|
||||
@@ -12003,7 +12135,8 @@ impl Game<'_> {
|
||||
/// `Screen.extractBlurredBackground` over the panorama or the world and
|
||||
/// HUD, when `menuBackgroundBlurriness` is at least 1.
|
||||
fn menu_blur(&self) -> Option<f32> {
|
||||
let loading = self.loading_reason() == Some(horizon_client::level_loading::Reason::Other);
|
||||
let loading = self.loading_reason() == Some(horizon_client::level_loading::Reason::Other)
|
||||
|| self.remote_loading();
|
||||
let blurred = loading
|
||||
|| self.title.as_ref().is_some_and(|t| {
|
||||
matches!(self.screen, Screen::Options | Screen::Win)
|
||||
@@ -12132,7 +12265,8 @@ impl Game<'_> {
|
||||
KeyCode::KeyL if self.screen == Screen::Playing => self.open_advancements(false),
|
||||
KeyCode::KeyL if self.screen == Screen::Advancements => self.close_advancements(),
|
||||
KeyCode::Slash if self.screen == Screen::Playing => self.open_chat("/"),
|
||||
KeyCode::F1 => self.toggle_hide_gui(),
|
||||
// key.toggleGui is a KeyMapping: it clicks only with no screen open.
|
||||
KeyCode::F1 if self.screen == Screen::Playing => self.toggle_hide_gui(),
|
||||
KeyCode::F11 => self.toggle_fullscreen(),
|
||||
KeyCode::F2 => self.take_screenshot(),
|
||||
KeyCode::F5 if self.screen == Screen::Playing => self.cycle_camera(),
|
||||
@@ -12661,7 +12795,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.perf.cancel_world_opening();
|
||||
return;
|
||||
}
|
||||
if (self.pending_entry.is_some() || self.world_loader.is_some())
|
||||
if (self.pending_entry.is_some()
|
||||
|| self.world_loader.is_some()
|
||||
|| self.integrated_starting.is_some())
|
||||
&& !matches!(
|
||||
&event,
|
||||
WindowEvent::CloseRequested
|
||||
@@ -12808,6 +12944,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
if self.screen == Screen::Playing
|
||||
&& self.sleeping.is_none()
|
||||
&& self.player.is_some()
|
||||
&& !self.native_wheel_event(d)
|
||||
&& !self.mod_wheel(d)
|
||||
&& let Some((wheel, (_, vertical_steps))) =
|
||||
self.mouse
|
||||
@@ -12880,6 +13017,12 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.dialog_click()?;
|
||||
}
|
||||
}
|
||||
// ConnectScreen's Cancel, under the prompts and dialogs above.
|
||||
WindowEvent::MouseInput { state, button, .. } if self.remote_loading() => {
|
||||
if state == ElementState::Pressed && button == MouseButton::Left {
|
||||
self.connect_screen_click()?;
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
|
||||
if self.options_waiting_for_key() && state == ElementState::Pressed {
|
||||
@@ -12996,6 +13139,12 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
self.extras_mouse(button, pressed);
|
||||
self.mod_mouse_key(button, pressed);
|
||||
if pressed
|
||||
&& self.settings.keys.get("key.smoothCamera")
|
||||
== horizon_client::keybinds::mouse_name(button)
|
||||
{
|
||||
self.smooth_camera = !self.smooth_camera;
|
||||
}
|
||||
for mapped in self.settings.keys.translate_mouse_all(button) {
|
||||
match mapped {
|
||||
Err(b) => self.play_mouse(b, pressed)?,
|
||||
@@ -13274,6 +13423,11 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
self.save_title_options();
|
||||
}
|
||||
PhysicalKey::Code(KeyCode::F5) => {
|
||||
if let Some(t) = self.title.as_mut() {
|
||||
t.refresh_servers();
|
||||
}
|
||||
}
|
||||
_ => self.title_edit(event.text.as_deref(), false),
|
||||
}
|
||||
}
|
||||
@@ -13283,6 +13437,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
return Ok(());
|
||||
};
|
||||
self.mod_key(key, event.state == ElementState::Pressed, event.repeat);
|
||||
self.native_key_event(key, event.state == ElementState::Pressed, event.repeat);
|
||||
// key.smoothCamera toggles the cinematic camera.
|
||||
if self.screen == Screen::Playing
|
||||
&& event.state == ElementState::Pressed
|
||||
@@ -13653,6 +13808,7 @@ impl<'a> Game<'a> {
|
||||
server_packs: Default::default(),
|
||||
link_prompt: None,
|
||||
dialogs: Default::default(),
|
||||
integrated_starting: None,
|
||||
world_renderer_ready: false,
|
||||
collision: res.collision(),
|
||||
motion: res.motion(),
|
||||
@@ -13907,6 +14063,7 @@ impl<'a> Game<'a> {
|
||||
ominous_spawners: Vec::new(),
|
||||
creaking: Default::default(),
|
||||
spawners: Default::default(),
|
||||
spawn_scratch: Default::default(),
|
||||
trident_model: prepared.trident_model,
|
||||
arrow_model: prepared.arrow_model,
|
||||
tnts: Vec::new(),
|
||||
@@ -14126,6 +14283,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
fn resume_world_entry(&mut self) -> Result<()> {
|
||||
self.poll_integrated_start()?;
|
||||
self.poll_world_rest()?;
|
||||
if let Some(loader) = &self.world_loader {
|
||||
if !loader.ready() {
|
||||
@@ -14940,8 +15098,26 @@ fn write_report(game: &Game<'_>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The window system ended the event loop with a failure (on Linux, a compositor dropping the
|
||||
/// connection after a protocol error) while fullscreen was on: the next start opens windowed, so a
|
||||
/// fullscreen the system can't show doesn't crash every launch (#2904).
|
||||
fn leave_fullscreen_after(
|
||||
result: &std::result::Result<(), EventLoopError>,
|
||||
settings: &mut Settings,
|
||||
) {
|
||||
if matches!(result, Err(EventLoopError::ExitFailure(_))) && settings.fullscreen {
|
||||
settings.fullscreen = false;
|
||||
settings.save(&Game::options_path());
|
||||
log::warn!(
|
||||
"the window system closed the game in fullscreen; the next start opens windowed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_event_loop(mut game: Game<'_>) -> Result<()> {
|
||||
EventLoop::new()?.run_app(&mut game)?;
|
||||
let result = EventLoop::new()?.run_app(&mut game);
|
||||
leave_fullscreen_after(&result, &mut game.settings);
|
||||
result?;
|
||||
horizon_client::discord_presence::stop();
|
||||
write_report(&game)?;
|
||||
if let Some(e) = game.error.take() {
|
||||
@@ -15298,7 +15474,12 @@ fn run_loading(
|
||||
error: None,
|
||||
};
|
||||
let mut app = App::Loading(Box::new(loading));
|
||||
EventLoop::new()?.run_app(&mut app)?;
|
||||
let result = EventLoop::new()?.run_app(&mut app);
|
||||
match &mut app {
|
||||
App::Ready(game) => leave_fullscreen_after(&result, &mut game.settings),
|
||||
App::Loading(loading) => leave_fullscreen_after(&result, &mut loading.settings),
|
||||
}
|
||||
result?;
|
||||
horizon_client::discord_presence::stop();
|
||||
match app {
|
||||
App::Ready(mut game) => {
|
||||
|
||||
@@ -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));
|
||||
@@ -1043,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();
|
||||
|
||||
@@ -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>,
|
||||
@@ -179,9 +194,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
|
||||
@@ -695,9 +708,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 +1004,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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -24,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)
|
||||
|
||||
@@ -151,6 +151,9 @@ impl Game<'_> {
|
||||
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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -861,7 +873,7 @@ impl Game<'_> {
|
||||
/// parse; here the server answers it.)
|
||||
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,
|
||||
@@ -208,6 +213,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() {
|
||||
@@ -2573,6 +2573,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.
|
||||
|
||||
@@ -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",
|
||||
@@ -2700,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(()),
|
||||
@@ -3269,7 +3393,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);
|
||||
}
|
||||
@@ -3843,6 +3976,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 {
|
||||
@@ -3886,6 +4024,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 {
|
||||
@@ -4060,7 +4201,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.
|
||||
@@ -4075,15 +4216,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();
|
||||
@@ -4121,7 +4258,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();
|
||||
@@ -4439,6 +4576,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>> =
|
||||
@@ -4561,6 +4755,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();
|
||||
|
||||
@@ -1083,7 +1083,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.
|
||||
@@ -2230,12 +2230,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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2040,6 +2040,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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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| {
|
||||
@@ -1384,6 +1417,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());
|
||||
@@ -3041,10 +3077,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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> {
|
||||
|
||||
@@ -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([(
|
||||
|
||||
@@ -1459,6 +1459,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()
|
||||
|
||||
@@ -267,6 +267,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 +303,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 +356,7 @@ impl Game<'_> {
|
||||
problem,
|
||||
dev: false,
|
||||
has_settings: false,
|
||||
native: None,
|
||||
};
|
||||
};
|
||||
// What it may do first: the row cuts long descriptions.
|
||||
@@ -365,9 +373,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,401 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! 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};
|
||||
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, 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 = 1;
|
||||
|
||||
/// 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]>,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -960,7 +960,8 @@ impl Game<'_> {
|
||||
let amount =
|
||||
|base: i32| (0.5 * f64::from(base << amp) + 0.5) as i32 as f32;
|
||||
if (*id == "instant_damage") != inverted {
|
||||
m.hurt(amount(6), Some(at), &mut self.world.random);
|
||||
let harm = m.potion_harm(amount(6));
|
||||
m.hurt(harm, Some(at), &mut self.world.random);
|
||||
} else {
|
||||
m.health = (m.health + amount(4)).min(m.kind.max_health);
|
||||
}
|
||||
@@ -1228,11 +1229,8 @@ impl Game<'_> {
|
||||
};
|
||||
let m = &mut self.mobs[i];
|
||||
if harm {
|
||||
m.hurt(
|
||||
(intensity * f64::from(6 << amp) + 0.5) as i32 as f32,
|
||||
Some([at.x, at.y, at.z]),
|
||||
&mut self.world.random,
|
||||
);
|
||||
let harm = m.potion_harm((intensity * f64::from(6 << amp) + 0.5) as i32 as f32);
|
||||
m.hurt(harm, Some([at.x, at.y, at.z]), &mut self.world.random);
|
||||
} else {
|
||||
m.health = (m.health + (intensity * f64::from(4 << amp) + 0.5) as i32 as f32)
|
||||
.min(m.kind.max_health);
|
||||
|
||||
@@ -14,7 +14,7 @@ use horizon_client::{
|
||||
chunk::Chunk,
|
||||
containers::{BlockEntity, Furnace, FurnaceKind},
|
||||
entities::{Experience, ExperienceOrb, ItemEntity},
|
||||
hud::{HudBatch, rgba},
|
||||
hud::HudBatch,
|
||||
items::ItemStack,
|
||||
menu::Input,
|
||||
mobs::{Mob, MobType, Who},
|
||||
@@ -115,6 +115,9 @@ pub(super) struct Remote {
|
||||
lang: Arc<Language>,
|
||||
pub(super) registries: Arc<Dynamic>,
|
||||
joined: bool,
|
||||
/// `handleLogin` ran: vanilla's `Minecraft.player` exists from then on,
|
||||
/// while here the player is made at the first teleport.
|
||||
pub(super) logged_in: bool,
|
||||
status: String,
|
||||
pub(super) entity_id: i32,
|
||||
/// The item the player's use began with (`LivingEntity.useItem`).
|
||||
@@ -303,6 +306,7 @@ impl Game<'_> {
|
||||
name: &str,
|
||||
cookies: Option<horizon_client::net::Cookies>,
|
||||
) -> Result<()> {
|
||||
super::world_loading::preload_server_loot(&self.options.resources);
|
||||
if self.defer_world_entry(super::PendingWorldEntry::Server {
|
||||
address: address.to_owned(),
|
||||
name: name.to_owned(),
|
||||
@@ -453,6 +457,7 @@ impl Game<'_> {
|
||||
lang,
|
||||
registries: Arc::new(Dynamic::default()),
|
||||
joined: false,
|
||||
logged_in: false,
|
||||
status: "Connecting to the server...".into(),
|
||||
entity_id: 0,
|
||||
use_item: None,
|
||||
@@ -1046,6 +1051,8 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.map(|d| (d.dimension.clone(), d.pos));
|
||||
if !same {
|
||||
// A new ClientLevel: Minecraft.setLevel stops every sound.
|
||||
self.stop_sounds_for_new_level();
|
||||
let mut streaming = Streaming::new(
|
||||
Arc::new(NoTerrain),
|
||||
self.pack.context.clone(),
|
||||
@@ -1148,6 +1155,23 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// A decorated pot is no block entity here: its tag is kept with the chunk.
|
||||
if name == "minecraft:decorated_pot" {
|
||||
if let Tag::Compound(fields) = tag
|
||||
&& !fields.is_empty()
|
||||
{
|
||||
let mut full = fields.clone();
|
||||
full.insert("id".into(), Tag::String(name.into()));
|
||||
for (k, v) in ["x", "y", "z"].into_iter().zip(pos) {
|
||||
full.insert(k.into(), Tag::Int(v));
|
||||
}
|
||||
super::spill::put_saved_entity(&mut self.world, pos, Tag::Compound(full));
|
||||
// The section is drawn again with the pot's sherds.
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let _ = self.world.set(pos[0], pos[1], pos[2], state);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(entity) = self.world.block_entities.get(&key) else {
|
||||
return;
|
||||
};
|
||||
@@ -1162,7 +1186,22 @@ impl Game<'_> {
|
||||
for (k, v) in ["x", "y", "z"].into_iter().zip(pos) {
|
||||
full.insert(k.into(), Tag::Int(v));
|
||||
}
|
||||
if let Some((_, loaded)) = horizon_client::save::decode_block_entity(&Tag::Compound(full)) {
|
||||
if let Some((_, mut loaded)) =
|
||||
horizon_client::save::decode_block_entity(&Tag::Compound(full))
|
||||
{
|
||||
// The turn of a vault's item and a trial spawner's mob is the client's
|
||||
// own, not in the tag: an update keeps it (VaultClientData).
|
||||
match (self.world.block_entities.get(&key), &mut loaded) {
|
||||
(Some(BlockEntity::Vault(old)), BlockEntity::Vault(new)) => {
|
||||
new.spin = old.spin;
|
||||
new.o_spin = old.o_spin;
|
||||
}
|
||||
(Some(BlockEntity::TrialSpawner(old)), BlockEntity::TrialSpawner(new)) => {
|
||||
new.spin = old.spin;
|
||||
new.o_spin = old.o_spin;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.world.block_entities.insert(key, loaded);
|
||||
// The section is drawn again with what the block entity shows.
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
@@ -1733,6 +1772,7 @@ impl Game<'_> {
|
||||
r.carried = Some(0);
|
||||
r.online_mode = p.online_mode;
|
||||
r.entity_id = p.player_id;
|
||||
r.logged_in = true;
|
||||
r.view_distance = p.chunk_radius;
|
||||
r.show_death_screen = p.show_death_screen;
|
||||
}
|
||||
@@ -1753,7 +1793,12 @@ impl Game<'_> {
|
||||
// again) and with no item in use.
|
||||
self.potions.effects.clear();
|
||||
self.using = None;
|
||||
let dimension_changed = self.dim != Dim::from_id(&p.spawn.dimension);
|
||||
self.enter_remote_dimension(&p.spawn, true);
|
||||
if dimension_changed {
|
||||
let (music, _) = self.situational_music();
|
||||
self.music.stop_playing(music.as_ref(), &mut self.audio);
|
||||
}
|
||||
self.screen = Screen::Playing;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.death_message = None;
|
||||
@@ -2234,21 +2279,30 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
C::TakeItemEntity(p) => {
|
||||
// An orb's sound comes from the server, at the orb.
|
||||
// ClientPacketListener.handleTakeItemEntity: the pickup sound at
|
||||
// the item, whoever collects it (the server sends none). An
|
||||
// orb's sound comes from the server, at the orb; an arrow this
|
||||
// client does not track as an item sounds at this player.
|
||||
let orb = self.orbs.iter().any(|o| o.id == p.item as u32);
|
||||
if !orb
|
||||
&& self
|
||||
let item = self.dropped.iter().find(|d| d.id == p.item as u32);
|
||||
let at = match item {
|
||||
Some(d) => Some([d.position.x, d.position.y, d.position.z]),
|
||||
None if self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.entity_id == p.player)
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
.is_some_and(|r| r.entity_id == p.player) =>
|
||||
{
|
||||
self.eye_position()
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if !orb && let Some(at) = at {
|
||||
let pitch = ((self.world.random.next_float() - self.world.random.next_float())
|
||||
* 0.7
|
||||
+ 1.0)
|
||||
* 2.0;
|
||||
self.audio
|
||||
.play("minecraft:entity.item.pickup", Some(eye), 0.2, pitch);
|
||||
.play("minecraft:entity.item.pickup", Some(at), 0.2, pitch);
|
||||
}
|
||||
// ItemPickupParticle toward the collector (this player, another one or a mob).
|
||||
let collector = if self
|
||||
@@ -2349,6 +2403,17 @@ impl Game<'_> {
|
||||
{
|
||||
m.health = 0.0;
|
||||
}
|
||||
// LivingEntity.handleEntityEvent 3 on this player: its death
|
||||
// sound, which Player.playSound plays for this client alone
|
||||
// (the server's goes to everyone else).
|
||||
if p.event == 3
|
||||
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
|
||||
&& let Some(pl) = self.player.as_ref()
|
||||
{
|
||||
let at = [pl.position.x, pl.position.y, pl.position.z];
|
||||
self.audio
|
||||
.play("minecraft:entity.player.death", Some(at), 1.0, 1.0);
|
||||
}
|
||||
// The events that start a mob's animation (the warden's).
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
horizon_client::mobs::sync::entity_event(m, p.event as u8);
|
||||
@@ -3540,6 +3605,7 @@ impl Game<'_> {
|
||||
self.tick_remote_level()?;
|
||||
// Client mods tick on any server, single player's own included.
|
||||
self.tick_mods();
|
||||
self.native_tick();
|
||||
// Minecraft.tick sends this even when the player cannot tick yet,
|
||||
// including while waiting for chunks. Keep it outside early returns.
|
||||
if self.remote.as_ref().is_some_and(|r| r.joined) {
|
||||
@@ -4619,6 +4685,7 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.level_players.contains_key(&id));
|
||||
if !player && self.remote_mob_takes(i, hand) {
|
||||
self.remote_feed_particles(i, hand);
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
return true;
|
||||
}
|
||||
@@ -4626,9 +4693,26 @@ impl Game<'_> {
|
||||
false
|
||||
}
|
||||
|
||||
/// `Minecraft.startUseItem`'s block turn for `hand`: a use that swung drops
|
||||
/// the hand (`LocalPlayer.itemUsed`) when it spent the held item or the
|
||||
/// player has infinite materials.
|
||||
fn remote_use_block(&mut self, hand: Hand) -> bool {
|
||||
let before = self.with_hand(hand, |g| g.held().map(|s| s.count));
|
||||
let swings = self.swings_started;
|
||||
let done = self.remote_use_item_on(hand);
|
||||
if done
|
||||
&& self.swings_started != swings
|
||||
&& let Some(after) = self.with_hand(hand, |g| g.held().map(|s| s.count))
|
||||
&& (Some(after) != before || self.creative)
|
||||
{
|
||||
self.heights.item_used(hand);
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
/// MultiPlayerGameMode.useItemOn for `hand`: whether the click ends there
|
||||
/// (the block reacts, or a block item is placed).
|
||||
fn remote_use_block(&mut self, hand: Hand) -> bool {
|
||||
fn remote_use_item_on(&mut self, hand: Hand) -> bool {
|
||||
let held = self.with_hand(hand, |g| g.held().cloned());
|
||||
let held_block = held
|
||||
.as_ref()
|
||||
@@ -4800,6 +4884,9 @@ impl Game<'_> {
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) && hands_full {
|
||||
return (false, DryUse::default());
|
||||
}
|
||||
if self.jukebox_takes(hit.pos, held) {
|
||||
return (true, DryUse::default());
|
||||
}
|
||||
// BeehiveBlock.useItemOn: shears or a glass bottle on a full hive
|
||||
// (honey level 5) is SUCCESS, so the click ends after UseItemOn and
|
||||
// no UseItem follows (the server does the harvest, see `use_beehive`).
|
||||
@@ -5002,7 +5089,9 @@ impl Game<'_> {
|
||||
/// the main hand's item, then the off hand's turn (its block, its item).
|
||||
/// The server does each packet's part with that packet's hand only.
|
||||
fn remote_use_hands(&mut self) {
|
||||
// A successful Item.use always drops the hand (LocalPlayer.itemUsed).
|
||||
if self.remote_use_item(Hand::Main) {
|
||||
self.heights.item_used(Hand::Main);
|
||||
return;
|
||||
}
|
||||
// The off hand's turn: the entity again, or the block.
|
||||
@@ -5014,7 +5103,9 @@ impl Game<'_> {
|
||||
if off_target {
|
||||
return;
|
||||
}
|
||||
self.remote_use_item(Hand::Off);
|
||||
if self.remote_use_item(Hand::Off) {
|
||||
self.heights.item_used(Hand::Off);
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.updatingUsingItem` on a server client: an item held in
|
||||
/// use (`held_until_released`), and a consumable's eating animation and
|
||||
@@ -5913,26 +6004,42 @@ impl Game<'_> {
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, rgba(20, 20, 20, 255));
|
||||
let shown = horizon_client::net::shown_address(&r.session.address);
|
||||
for (i, line) in [r.status.as_str(), shown.as_str()].iter().enumerate() {
|
||||
let tw = HudBatch::text_width(atlas, line);
|
||||
batch.text(
|
||||
atlas,
|
||||
line,
|
||||
(w - tw) / 2.0,
|
||||
h / 2.0 - 12.0 + i as f32 * 12.0,
|
||||
if i == 0 {
|
||||
u32::MAX
|
||||
} else {
|
||||
rgba(160, 160, 160, 255)
|
||||
},
|
||||
true,
|
||||
);
|
||||
if r.joined {
|
||||
// LevelLoadingScreen: the world's terrain comes in.
|
||||
horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
|
||||
horizon_client::level_loading::draw(batch, atlas, None, None);
|
||||
return true;
|
||||
}
|
||||
let _ = r.joined;
|
||||
let cancel = self.connect_cancel((w, h)).is_some();
|
||||
let status = horizon_client::text::Text::literal(r.status.as_str());
|
||||
horizon_client::level_loading::draw_connecting(batch, atlas, &status, self.cursor, cancel);
|
||||
true
|
||||
}
|
||||
/// Connected or connecting with no player yet: `ConnectScreen`, then
|
||||
/// `LevelLoadingScreen`, over the panorama with its blur.
|
||||
pub(super) fn remote_loading(&self) -> bool {
|
||||
self.remote.is_some() && self.player.is_none()
|
||||
}
|
||||
/// `ConnectScreen`'s Cancel: a server's (single player's server isn't
|
||||
/// joined through `ConnectScreen`), until the login is done.
|
||||
fn connect_cancel(&self, (w, h): (f32, f32)) -> Option<(f32, f32)> {
|
||||
let r = self.remote.as_ref()?;
|
||||
(self.player.is_none() && !r.joined && self.integrated.is_none())
|
||||
.then(|| horizon_client::level_loading::connect_cancel(w, h))
|
||||
}
|
||||
/// A click on `ConnectScreen`: Cancel aborts the connection
|
||||
/// (`connect.aborted`) and goes back. Escape does nothing there
|
||||
/// (`shouldCloseOnEsc` is false).
|
||||
pub(super) fn connect_screen_click(&mut self) -> Result<()> {
|
||||
let Some((x, y)) = self.connect_cancel(self.gui_size()) else {
|
||||
return Ok(());
|
||||
};
|
||||
if horizon_client::ui::hovered(self.cursor, x, y, 200.0, 20.0) {
|
||||
self.audio.play_button_click();
|
||||
self.leave_server(None)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The server's death message; chat uses the shared ChatComponent.
|
||||
pub(super) fn remote_hud(&self, batch: &mut HudBatch) {
|
||||
let Some(r) = &self.remote else { return };
|
||||
|
||||
@@ -93,4 +93,45 @@ impl Game<'_> {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// `AbstractHorse.handleEating` (`Llama`'s and `Camel`'s too) on this
|
||||
/// client's copy: a baby fed something that ages it draws one happy
|
||||
/// villager particle. The server draws it on its own level only, so no
|
||||
/// packet brings it.
|
||||
pub(super) fn remote_feed_particles(&mut self, i: usize, hand: Hand) {
|
||||
let Some(m) = self.mobs.get(i).filter(|m| m.alive() && m.is_baby()) else {
|
||||
return;
|
||||
};
|
||||
let stack = match hand {
|
||||
Hand::Main => self.stack_in(self.selected),
|
||||
Hand::Off => self.offhand.as_ref(),
|
||||
};
|
||||
let item = stack.map_or("", |s| &*s.item);
|
||||
let ages = match m.species() {
|
||||
// Llama.handleEating: wheat 10, hay 90.
|
||||
s if llama::is_llama(s) => matches!(item, "minecraft:wheat" | "minecraft:hay_block"),
|
||||
// Camel.handleEating: its food ages it by 10.
|
||||
Species::Camel => m.kind.food.iter().any(|f| f.as_str() == item),
|
||||
// AbstractHorse.handleEating: every food but the red mushroom.
|
||||
Species::Horse | Species::Donkey | Species::Mule => matches!(
|
||||
item,
|
||||
"minecraft:wheat"
|
||||
| "minecraft:sugar"
|
||||
| "minecraft:hay_block"
|
||||
| "minecraft:apple"
|
||||
| "minecraft:carrot"
|
||||
| "minecraft:golden_carrot"
|
||||
| "minecraft:golden_apple"
|
||||
| "minecraft:enchanted_golden_apple"
|
||||
),
|
||||
_ => false,
|
||||
};
|
||||
if ages {
|
||||
let id = m.id;
|
||||
self.particles_around_mob(
|
||||
id,
|
||||
"happy_villager",
|
||||
horizon_client::particles::Burst::HORSE_EATING,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -623,6 +623,18 @@ impl Game<'_> {
|
||||
SculkKind::Shrieker => sculk::shrieker_can_listen(event),
|
||||
_ => sculk::is_vibration(event),
|
||||
};
|
||||
// VibrationSystem.User.isValidVibration: a player sneaking past a
|
||||
// sensor, whose step it ignores, is AVOID_VIBRATION (sensors only;
|
||||
// a shrieker cannot trigger it).
|
||||
if listens
|
||||
&& matches!(kind, SculkKind::Sensor | SculkKind::Calibrated)
|
||||
&& ctx.source == Some(Who::Player)
|
||||
&& !ctx.spectator
|
||||
&& ctx.stepping_carefully
|
||||
&& sculk::ignored_when_sneaking(event)
|
||||
{
|
||||
self.trigger_player("minecraft:avoid_vibration");
|
||||
}
|
||||
if !sculk::valid_vibration(listens, event, ctx) {
|
||||
return;
|
||||
}
|
||||
@@ -693,12 +705,15 @@ impl Game<'_> {
|
||||
fn warden_listen(&mut self, index: usize, event: &'static str, pos: [f64; 3], ctx: &Context) {
|
||||
let now = self.world.game_time;
|
||||
let (occludes, world) = (&self.sculk.tables.occludes, &self.world);
|
||||
warden::listen(&mut self.mobs[index], now, event, pos, ctx, |a, b| {
|
||||
let avoided = warden::listen(&mut self.mobs[index], now, event, pos, ctx, |a, b| {
|
||||
sculk::is_occluded(a, b, |p| {
|
||||
let state = world.block(p.x, p.y, p.z);
|
||||
occludes.get(state.0 as usize).copied().unwrap_or(false)
|
||||
})
|
||||
});
|
||||
if avoided {
|
||||
self.trigger_player("minecraft:avoid_vibration");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ticking ----
|
||||
@@ -1577,6 +1592,30 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `RedStoneOreBlock.useItemOn`: a right-click lights the ore whatever the
|
||||
/// hand holds. False (PASS) when the hand holds a block item, which goes on
|
||||
/// to be placed; true (SUCCESS) otherwise.
|
||||
pub(super) fn use_redstone_ore(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
held: Option<&horizon_client::items::ItemStack>,
|
||||
) -> Result<bool> {
|
||||
if !matches!(
|
||||
&*self.block_name(pos),
|
||||
"minecraft:redstone_ore" | "minecraft:deepslate_redstone_ore"
|
||||
) {
|
||||
return Ok(false);
|
||||
}
|
||||
self.attack_block(pos)?;
|
||||
let block_item = held.is_some_and(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.get(&s.item)
|
||||
.is_some_and(|i| i.block.is_some())
|
||||
});
|
||||
Ok(!block_item)
|
||||
}
|
||||
|
||||
fn tick_mob_sculk_events(&mut self) {
|
||||
let snapshot: Vec<(u32, [f64; 3], bool, bool, Species, Option<i64>)> = self
|
||||
.mobs
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
use super::end::GatewayJob;
|
||||
use super::nether_portal::{PLAYER_DELAY, Plan};
|
||||
use super::respawn_anchor::RespawnConfig;
|
||||
use super::{Dim, Game, lan::LanHost};
|
||||
use super::{Dim, Game, Mob, boats, lan::LanHost, minecarts};
|
||||
use horizon_client::{lan::hub::TickCtx, text::Text, world_spawn::PlayerSpawnFinder};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
@@ -20,11 +20,17 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
pub(super) struct Trip {
|
||||
conn: u32,
|
||||
dim: Dim,
|
||||
/// The level the player came from: the vehicle it rides waits there
|
||||
/// until the exit is found.
|
||||
source: Dim,
|
||||
target: Vec3,
|
||||
/// Through an End portal.
|
||||
end: bool,
|
||||
/// The Nether portal plan, whose exit is found or built on arrival.
|
||||
plan: Option<Plan>,
|
||||
/// The vehicle the player rides, which travels with it
|
||||
/// (`Entity.teleportCrossDimension` carries the passengers).
|
||||
vehicle: Option<u32>,
|
||||
/// Through an End gateway, within the level: its exit, found or built
|
||||
/// on arrival.
|
||||
gateway: Option<GatewayJob>,
|
||||
@@ -40,6 +46,66 @@ pub(super) struct Trip {
|
||||
placed: bool,
|
||||
}
|
||||
|
||||
/// A vehicle a player rides: an animal, a boat or a minecart. It travels
|
||||
/// with the player through a portal (`Entity.teleportCrossDimension` carries
|
||||
/// the passengers, and they ride the vehicle that is recreated in the other
|
||||
/// dimension).
|
||||
trait Rideable {
|
||||
fn id(&self) -> u32;
|
||||
/// The vehicle stands where its rider arrives, with no motion.
|
||||
fn arrive(&mut self, at: Vec3, yaw: f32);
|
||||
}
|
||||
|
||||
impl Rideable for Mob {
|
||||
fn id(&self) -> u32 {
|
||||
self.id
|
||||
}
|
||||
fn arrive(&mut self, at: Vec3, yaw: f32) {
|
||||
self.position = at;
|
||||
self.previous = at;
|
||||
self.velocity = Vec3::ZERO;
|
||||
self.yaw = yaw;
|
||||
self.previous_yaw = yaw;
|
||||
self.fall_distance = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl Rideable for boats::Boat {
|
||||
fn id(&self) -> u32 {
|
||||
self.id
|
||||
}
|
||||
fn arrive(&mut self, at: Vec3, yaw: f32) {
|
||||
self.position = at;
|
||||
self.previous = at;
|
||||
self.velocity = Vec3::ZERO;
|
||||
self.yaw = yaw;
|
||||
self.previous_yaw = yaw;
|
||||
self.delta_rotation = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl Rideable for minecarts::Minecart {
|
||||
fn id(&self) -> u32 {
|
||||
self.id
|
||||
}
|
||||
fn arrive(&mut self, at: Vec3, yaw: f32) {
|
||||
self.position = at;
|
||||
self.previous = at;
|
||||
self.velocity = Vec3::ZERO;
|
||||
self.yaw = yaw;
|
||||
self.previous_yaw = yaw;
|
||||
}
|
||||
}
|
||||
|
||||
/// The vehicle `id` leaves the level it is in, at the point its rider
|
||||
/// arrives. It keeps its entity id, so the ride is never broken.
|
||||
fn take_vehicle<T: Rideable>(list: &mut Vec<T>, id: u32, at: Vec3, yaw: f32) -> Option<T> {
|
||||
let i = list.iter().position(|v| v.id() == id)?;
|
||||
let mut v = list.remove(i);
|
||||
v.arrive(at, yaw);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// `TeleportCommand` into another level for a server's player: where, and
|
||||
/// the rotation it sets (none keeps the player's).
|
||||
pub(super) struct LevelTeleport {
|
||||
@@ -66,6 +132,14 @@ impl Game<'_> {
|
||||
self.server_trips.iter().any(|t| t.conn == conn)
|
||||
}
|
||||
|
||||
/// Whether a vehicle waits in `dim` for its rider to arrive elsewhere:
|
||||
/// that level stays open until it has moved.
|
||||
pub(super) fn vehicle_waits_in(&self, dim: Dim) -> bool {
|
||||
self.server_trips
|
||||
.iter()
|
||||
.any(|t| t.vehicle.is_some() && t.source == dim)
|
||||
}
|
||||
|
||||
/// Per server tick in each level, after the guests' own: the arrivals of
|
||||
/// the trips into this level, then `Entity.handlePortal` for each player
|
||||
/// in it (Nether portals count up their transition time, End portals
|
||||
@@ -76,6 +150,7 @@ impl Game<'_> {
|
||||
if self.options.dedicated.is_none() {
|
||||
return;
|
||||
}
|
||||
self.carry_riders_through_portals(host);
|
||||
let conns: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
@@ -101,13 +176,16 @@ impl Game<'_> {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let (at, invulnerable, sleeping) = (
|
||||
let (at, invulnerable, sleeping, riding) = (
|
||||
Vec3::new(g.position[0], g.position[1], g.position[2]),
|
||||
g.creative(),
|
||||
g.sleeping,
|
||||
g.vehicle.is_some(),
|
||||
);
|
||||
// Entity.checkInsideBlocks: the Nether portal blocks the box is in.
|
||||
if let Some(pos) = self.nether_portal_inside(at) {
|
||||
// `canUsePortal(false)` is false for a passenger: its vehicle goes
|
||||
// through and carries it (`carry_riders_through_portals`).
|
||||
if !riding && let Some(pos) = self.nether_portal_inside(at) {
|
||||
self.portals.player.set_inside(pos, PLAYER_DELAY);
|
||||
}
|
||||
// `ServerPlayer.showEndCredits`: the player stays out of the world while the
|
||||
@@ -122,7 +200,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let (end_portal, gateway) = self.inside_end_portals(at, WIDTH, HEIGHT);
|
||||
if end_portal {
|
||||
if end_portal && !riding {
|
||||
// EndPortalBlock.entityInside: the first exit from the End shows the credits.
|
||||
if self.dim == Dim::End && !host.hub.guest(conn).is_some_and(|g| g.seen_credits) {
|
||||
if let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn) {
|
||||
@@ -184,6 +262,44 @@ impl Game<'_> {
|
||||
self.start_server_trip(host, conn, plan.dim, Some(plan));
|
||||
}
|
||||
|
||||
/// `Entity.handlePortal` of a vehicle server players ride, once through
|
||||
/// a portal (a vehicle's transition time is 0): the riders travel with
|
||||
/// it and ride it again on arrival (`Entity.teleportCrossDimension`), so
|
||||
/// a rider never waits for its own portal delay.
|
||||
fn carry_riders_through_portals(&mut self, host: &mut LanHost) {
|
||||
for (vehicle, entry) in self.take_carrying(self.dim) {
|
||||
let Some(plan) = self.vehicle_plan(vehicle, entry) else {
|
||||
continue;
|
||||
};
|
||||
if !self.may_enter(plan.dim) {
|
||||
continue;
|
||||
}
|
||||
let riders: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.vehicle == Some(vehicle) && !self.on_server_trip(g.conn))
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
// Entity.canTeleport: out of the End, every rider has seen the
|
||||
// credits.
|
||||
if plan.end
|
||||
&& self.dim == Dim::End
|
||||
&& riders
|
||||
.iter()
|
||||
.any(|c| !host.hub.guest(*c).is_some_and(|g| g.seen_credits))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// An End portal's exit is found as a player's (its own respawn
|
||||
// point out of the End).
|
||||
let (target, plan) = (plan.dim, (!plan.end).then_some(plan));
|
||||
for conn in riders {
|
||||
self.start_server_trip(host, conn, target, plan);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Nether portal block a player's box is in.
|
||||
fn nether_portal_inside(&self, at: Vec3) -> Option<BlockPos> {
|
||||
let w = WIDTH / 2.0 - 1.0e-5;
|
||||
@@ -217,6 +333,15 @@ impl Game<'_> {
|
||||
let g = &host.hub.guests[i];
|
||||
let from = Vec3::new(g.position[0], g.position[1], g.position[2]);
|
||||
let yaw = g.yaw;
|
||||
// The vehicle the player rides travels with it: the player is its
|
||||
// passenger, and the vehicle is recreated in the other dimension,
|
||||
// where they ride it again (`Entity.teleportCrossDimension`). It
|
||||
// keeps its entity id, so the ride is never broken.
|
||||
let vehicle = g.vehicle.filter(|id| {
|
||||
self.mobs.iter().any(|m| m.id == *id)
|
||||
|| self.boats.iter().any(|b| b.id == *id)
|
||||
|| self.carts.iter().any(|c| c.id == *id)
|
||||
});
|
||||
let source = self.dim;
|
||||
let end = plan.is_none();
|
||||
// Coordinates scale by 8 between the Overworld and the Nether; an
|
||||
@@ -254,9 +379,11 @@ impl Game<'_> {
|
||||
self.server_trips.push(Trip {
|
||||
conn,
|
||||
dim: target,
|
||||
source,
|
||||
target: destination,
|
||||
end,
|
||||
plan,
|
||||
vehicle,
|
||||
gateway: None,
|
||||
respawn: None,
|
||||
world_spawn: false,
|
||||
@@ -275,9 +402,11 @@ impl Game<'_> {
|
||||
self.server_trips.push(Trip {
|
||||
conn,
|
||||
dim: config.dim,
|
||||
source: self.dim,
|
||||
target: Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5),
|
||||
end: false,
|
||||
plan: None,
|
||||
vehicle: None,
|
||||
gateway: None,
|
||||
respawn: Some(config),
|
||||
world_spawn: false,
|
||||
@@ -325,9 +454,11 @@ impl Game<'_> {
|
||||
self.server_trips.push(Trip {
|
||||
conn,
|
||||
dim: Dim::Overworld,
|
||||
source: self.dim,
|
||||
target: Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5),
|
||||
end: false,
|
||||
plan: None,
|
||||
vehicle: None,
|
||||
gateway: None,
|
||||
respawn: None,
|
||||
world_spawn: true,
|
||||
@@ -371,9 +502,11 @@ impl Game<'_> {
|
||||
self.server_trips.push(Trip {
|
||||
conn,
|
||||
dim: self.dim,
|
||||
source: self.dim,
|
||||
target: destination,
|
||||
end: false,
|
||||
plan: None,
|
||||
vehicle: None,
|
||||
gateway: Some(job),
|
||||
respawn: None,
|
||||
world_spawn: false,
|
||||
@@ -474,6 +607,12 @@ impl Game<'_> {
|
||||
_ => (trip.target, yaw),
|
||||
}
|
||||
};
|
||||
// The vehicle the player rode comes with it, and waits for it
|
||||
// where it arrives (`Entity.teleportCrossDimension` carries the
|
||||
// passengers).
|
||||
if let Some(vehicle) = trip.vehicle {
|
||||
self.arrive_vehicle(trip.source, vehicle, position, yaw);
|
||||
}
|
||||
// Player.getDimensionChangingDelay.
|
||||
host.play.entry(trip.conn).or_default().portal.cooldown = 10;
|
||||
let g = &mut host.hub.guests[i];
|
||||
@@ -485,6 +624,31 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The vehicle the player rode arrives with it, at the point the player
|
||||
/// arrives: it keeps its entity id, so the ride is never broken
|
||||
/// (`Entity.teleportCrossDimension` recreates the vehicle in the other
|
||||
/// dimension, and its passengers ride it again).
|
||||
fn arrive_vehicle(&mut self, source: Dim, id: u32, at: Vec3, yaw: f32) {
|
||||
let Some(parked) = self.levels.parked_mut(source) else {
|
||||
return;
|
||||
};
|
||||
if let Some(b) = take_vehicle(&mut parked.boats, id, at, yaw) {
|
||||
self.boats.push(b);
|
||||
} else if let Some(c) = take_vehicle(&mut parked.carts, id, at, yaw) {
|
||||
self.carts.push(c);
|
||||
} else if let Some(m) = take_vehicle(&mut parked.mobs, id, at, yaw) {
|
||||
self.mobs.push(m);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
// Its memory (its UUID and the compound it was loaded with) moves
|
||||
// with it, so it is saved in this level as it was in the other.
|
||||
self.move_entity_memory(source, id);
|
||||
// The vehicle that came through a portal does not go back through it
|
||||
// for a while (`Entity.setPortalCooldown`).
|
||||
self.set_portal_cooldown(id);
|
||||
}
|
||||
|
||||
/// The players the command just run sends to another level
|
||||
/// (`ServerPlayer.teleport` from TeleportCommand): a Respawn packet
|
||||
/// keeping all data and the teleport, with no portal sound. The server
|
||||
@@ -511,9 +675,13 @@ impl Game<'_> {
|
||||
self.server_trips.push(Trip {
|
||||
conn: t.conn,
|
||||
dim: t.dim,
|
||||
source,
|
||||
target: Vec3::new(t.at[0], t.at[1], t.at[2]),
|
||||
end: false,
|
||||
plan: None,
|
||||
// A command teleports the player alone: it is not a
|
||||
// passenger any more (`ServerPlayer.teleport`).
|
||||
vehicle: None,
|
||||
gateway: None,
|
||||
respawn: None,
|
||||
world_spawn: false,
|
||||
@@ -547,3 +715,42 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The vehicle a player rides travels with it through a portal: it
|
||||
/// leaves the level the player came from, keeps its entity id (so the
|
||||
/// ride is never broken) and stands where its rider arrives
|
||||
/// (`Entity.teleportCrossDimension` carries the passengers, and they
|
||||
/// ride the vehicle that is recreated in the other dimension).
|
||||
#[test]
|
||||
fn the_vehicle_a_player_rides_travels_with_it() {
|
||||
let mut boats = vec![boats::Boat::new(
|
||||
7,
|
||||
Arc::from("oak"),
|
||||
Vec3::new(0.0, 64.0, 0.0),
|
||||
0.0,
|
||||
)];
|
||||
let mut carts: Vec<minecarts::Minecart> = Vec::new();
|
||||
let mut mobs: Vec<Mob> = Vec::new();
|
||||
let at = Vec3::new(1.5, 65.0, -2.5);
|
||||
let boat = take_vehicle(&mut boats, 7, at, 90.0)
|
||||
.expect("the vehicle the player rides travels with it");
|
||||
assert!(
|
||||
boats.is_empty(),
|
||||
"the vehicle left the level the player came from"
|
||||
);
|
||||
assert_eq!(boat.id, 7, "the vehicle keeps its entity id");
|
||||
assert_eq!(boat.position, at, "it stands where its rider arrives");
|
||||
assert_eq!(boat.yaw, 90.0, "it looks where its rider looks");
|
||||
assert_eq!(boat.velocity, Vec3::ZERO, "it comes with no motion");
|
||||
// A vehicle that is not in the level does not travel.
|
||||
assert!(take_vehicle(&mut carts, 9, at, 0.0).is_none());
|
||||
assert!(carts.is_empty());
|
||||
assert!(take_vehicle(&mut mobs, 9, at, 0.0).is_none());
|
||||
assert!(mobs.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -65,14 +65,13 @@ impl Game<'_> {
|
||||
.unwrap_or_default();
|
||||
self.spawners.entity_types = Some(types);
|
||||
}
|
||||
/// The server tick of the spawner at `key`, if its block is still a
|
||||
/// spawner. Its client tick comes after every block entity's
|
||||
/// (`client_tick_spawners`).
|
||||
/// The server tick of the spawner at `key`. Its client tick comes after
|
||||
/// every block entity's (`client_tick_spawners`).
|
||||
///
|
||||
/// The caller only reaches this for a `BlockEntity::Spawner`, and changing
|
||||
/// the block removes or replaces that entity, so the block is not read
|
||||
/// again here.
|
||||
pub(super) fn tick_spawner(&mut self, key: Pos) -> Result<()> {
|
||||
let state = self.world.block(key.0, key.1, key.2);
|
||||
if &*self.res.facts().get(state).name != "minecraft:spawner" {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(BlockEntity::Spawner(mut spawner)) = self.world.block_entities.take(&key) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@@ -44,8 +44,16 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// `ServerPlayer.getKnownMovement` of the acting player: what they moved
|
||||
/// in the last tick. A guest's comes with their moves.
|
||||
/// in the last tick. A guest's comes with their moves. A rider's is its
|
||||
/// mount's move (`getKnownSpeed`: the vehicle's, or the move the driver's
|
||||
/// client sent for it), so a charge on horseback carries the horse's speed.
|
||||
fn known_player_movement(&self) -> Vec3 {
|
||||
if let Some(i) = self
|
||||
.riding
|
||||
.and_then(|v| self.mobs.iter().position(|m| m.id == v))
|
||||
{
|
||||
return self.mob_speed(i).scale(1.0 / 20.0);
|
||||
}
|
||||
if self.lan_acting.is_some() {
|
||||
return self.known_movement;
|
||||
}
|
||||
@@ -193,6 +201,9 @@ impl Game<'_> {
|
||||
}
|
||||
let who = actor.map_or(OWN, |conn| conn as i32);
|
||||
self.kinetic_hit_feedback(who, &kinetic, [feet.x, feet.y, feet.z]);
|
||||
// SPEAR_MOBS_TRIGGER: the living entities stabbed in this use.
|
||||
let stabbed = self.stabbed.keys().filter(|(who, _)| *who == actor).count();
|
||||
self.trigger_spear_mobs(stabbed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +250,8 @@ impl Game<'_> {
|
||||
self.mobs[index].knockback(0.4, xd, zd);
|
||||
let (strength, _) = self.extra_knockback(&kind, 0.0);
|
||||
self.mobs[index].knockback(f64::from(strength), xd, zd);
|
||||
// ...and the attacker stops sprinting.
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
let dismounted = dismounts && self.mobs[index].extra.mount.is_some();
|
||||
if dismounted {
|
||||
|
||||
@@ -167,6 +167,7 @@ impl StartupMenu {
|
||||
}
|
||||
match event.physical_key {
|
||||
PhysicalKey::Code(KeyCode::Escape) => title.escape(),
|
||||
PhysicalKey::Code(KeyCode::F5) => title.refresh_servers(),
|
||||
PhysicalKey::Code(KeyCode::Tab) => title.tab(),
|
||||
PhysicalKey::Code(KeyCode::Enter | KeyCode::NumpadEnter) => title.confirm_edit(),
|
||||
PhysicalKey::Code(
|
||||
@@ -392,7 +393,7 @@ impl Loading {
|
||||
}
|
||||
}
|
||||
// Mods' settings pages open only in a running game.
|
||||
Event::ModSettings(_) => {}
|
||||
Event::ModSettings(_) | Event::NativeButton(..) => {}
|
||||
// The startup menu runs before the game opens a control socket.
|
||||
Event::ButtonClick | Event::CopyAgentDetails => {}
|
||||
}
|
||||
|
||||
@@ -1643,7 +1643,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
let stack = self.slot_mut(i).clone();
|
||||
self.draw_slot_item(batch, drag.as_ref(), i, stack, ox + sx, oy + sy);
|
||||
self.draw_slot_item(batch, drag.as_ref(), i, &stack, ox + sx, oy + sy);
|
||||
}
|
||||
if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) {
|
||||
let (sx, sy) = self.slot_position(slot);
|
||||
|
||||
@@ -137,7 +137,9 @@ impl Game<'_> {
|
||||
horizon_world::nbt::Tag::Int(horizon_world::DATA_VERSION),
|
||||
),
|
||||
]);
|
||||
let _ = horizon_client::save::write_dat(&self.random_sequences_path(), tag);
|
||||
if let Err(e) = horizon_client::save::write_dat(&self.random_sequences_path(), tag) {
|
||||
log::warn!("the villager random sequences were not written: {e}");
|
||||
}
|
||||
}
|
||||
/// `AbstractVillager.addOffersFromTradeSet` with the world's random
|
||||
/// sequence of the set, at the merchant's position.
|
||||
@@ -783,7 +785,7 @@ impl Game<'_> {
|
||||
for i in slots {
|
||||
let (sx, sy) = self.slot_position(i);
|
||||
let stack = self.slot_mut(i).clone();
|
||||
self.draw_slot_item(batch, drag.as_ref(), i, stack, ox + sx, oy + sy);
|
||||
self.draw_slot_item(batch, drag.as_ref(), i, &stack, ox + sx, oy + sy);
|
||||
}
|
||||
if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) {
|
||||
let (sx, sy) = self.slot_position(slot);
|
||||
|
||||
@@ -225,7 +225,7 @@ impl Game<'_> {
|
||||
/// The `summon_entity` and `play_sound` effects of `channeling.json`: in a
|
||||
/// thunderstorm (`weather_check`) a spot that sees the sky (`can_see_sky`)
|
||||
/// gets a lightning bolt and the thunder sound.
|
||||
fn channel_lightning(&mut self, at: [f64; 3]) {
|
||||
fn channel_lightning(&mut self, at: [f64; 3], guest: Option<u32>) {
|
||||
// Level.isThundering: the thunder level, in dimensions with weather.
|
||||
if self.dim != super::Dim::Overworld || self.world.weather.levels(1.0).1 <= 0.9 {
|
||||
return;
|
||||
@@ -240,13 +240,17 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
self.spawn_bolt(at, false);
|
||||
// The summon effect's owner is the thrower: LightningBolt.setCause.
|
||||
if let Some(bolt) = self.bolts.last_mut() {
|
||||
bolt.cause = Some(guest);
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:item.trident.thunder", Some(at), 5.0, 1.0);
|
||||
}
|
||||
/// `minecraft:hit_block` of Channeling (`AbstractArrow.hitBlockEnchantmentEffects`):
|
||||
/// the block the trident's path first hits, if it is a lightning rod, with
|
||||
/// the hit location clamped into the block (`BlockPos.clampLocationWithin`).
|
||||
fn channel_rod_hit(&mut self, from: Vec3, velocity: Vec3) {
|
||||
fn channel_rod_hit(&mut self, from: Vec3, velocity: Vec3, guest: Option<u32>) {
|
||||
let to = Vec3::new(
|
||||
from.x + velocity.x,
|
||||
from.y + velocity.y,
|
||||
@@ -279,11 +283,14 @@ impl Game<'_> {
|
||||
let edge = f64::from(1.0e-5f32);
|
||||
let clamp =
|
||||
|v: f64, origin: i32| v.clamp(f64::from(origin) + edge, f64::from(origin) + 1.0 - edge);
|
||||
self.channel_lightning([
|
||||
clamp(hit.location.x, hit.block.x),
|
||||
clamp(hit.location.y, hit.block.y),
|
||||
clamp(hit.location.z, hit.block.z),
|
||||
]);
|
||||
self.channel_lightning(
|
||||
[
|
||||
clamp(hit.location.x, hit.block.x),
|
||||
clamp(hit.location.y, hit.block.y),
|
||||
clamp(hit.location.z, hit.block.z),
|
||||
],
|
||||
guest,
|
||||
);
|
||||
}
|
||||
/// `TridentItem.releaseUsing`.
|
||||
pub(super) fn release_trident(&mut self, ticks: u32) {
|
||||
@@ -514,7 +521,7 @@ impl Game<'_> {
|
||||
if let Some(at) = hurt_at
|
||||
&& t.stack.enchantment_level("minecraft:channeling") > 0
|
||||
{
|
||||
self.channel_lightning(at);
|
||||
self.channel_lightning(at, t.guest);
|
||||
}
|
||||
if hurt_at.is_some()
|
||||
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
|
||||
@@ -558,7 +565,7 @@ impl Game<'_> {
|
||||
);
|
||||
if blocked {
|
||||
if t.stack.enchantment_level("minecraft:channeling") > 0 {
|
||||
self.channel_rod_hit(from, flight);
|
||||
self.channel_rod_hit(from, flight, t.guest);
|
||||
}
|
||||
t.in_ground = true;
|
||||
t.velocity = Vec3::ZERO;
|
||||
|
||||
@@ -240,6 +240,67 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `NearestItemSensor.doTick` for the villagers whose sensor is due: the
|
||||
/// nearest dropped item within 32 blocks that `Villager.wantsToPickUp`
|
||||
/// accepts and the villager sees, or none.
|
||||
pub(super) fn villager_item_sense(&mut self) {
|
||||
if self.dropped.is_empty() {
|
||||
for m in &mut self.mobs {
|
||||
if m.species() == Species::Villager && villager_brain::take_item_scan(m) {
|
||||
villager_brain::set_wanted_item(m, None);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let picks_up = self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:villager_picks_up")
|
||||
.cloned();
|
||||
let mut due = Vec::new();
|
||||
for (i, m) in self.mobs.iter_mut().enumerate() {
|
||||
if m.species() == Species::Villager && villager_brain::take_item_scan(m) {
|
||||
due.push(i);
|
||||
}
|
||||
}
|
||||
if due.is_empty() {
|
||||
return;
|
||||
}
|
||||
let w = self.mob_world_base(&[]);
|
||||
let mut found = Vec::new();
|
||||
for i in due {
|
||||
let m = &self.mobs[i];
|
||||
let area = m.bounds().inflate(Vec3::new(32.0, 16.0, 32.0));
|
||||
let requested = villager::requested_items(m);
|
||||
let eye = Vec3::new(m.position.x, m.eye_y(), m.position.z);
|
||||
let mut best: Option<(f64, Vec3)> = None;
|
||||
for e in &self.dropped {
|
||||
let d = e.position.distance_squared(m.position);
|
||||
if best.is_some_and(|(b, _)| d >= b)
|
||||
|| !e.bounds().intersects(area)
|
||||
|| d >= 32.0 * 32.0
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let item = &e.stack.item;
|
||||
let wants = villager::food_points(item) > 0
|
||||
|| picks_up.as_ref().is_some_and(|t| t.contains(&**item))
|
||||
|| requested.contains(&&**item);
|
||||
let fits =
|
||||
villager::ext(m).is_none_or(|x| x.can_add(&e.stack, self.item_max_stack(item)));
|
||||
// ItemEntity's eye height is 0.2125.
|
||||
let at = Vec3::new(e.position.x, e.position.y + 0.2125, e.position.z);
|
||||
if wants && fits && w.line_of_sight(eye, at) {
|
||||
best = Some((d, e.position));
|
||||
}
|
||||
}
|
||||
found.push((i, best.map(|(_, p)| [p.x, p.y, p.z])));
|
||||
}
|
||||
for (i, at) in found {
|
||||
villager_brain::set_wanted_item(&mut self.mobs[i], at);
|
||||
}
|
||||
}
|
||||
/// `Mob.aiStep` item pickup for villagers (`InventoryCarrier`): food,
|
||||
/// seeds and what their profession wants, from within a block.
|
||||
pub(super) fn villager_pickups(&mut self) {
|
||||
|
||||
@@ -121,6 +121,13 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
// An ender dragon part takes the point of damage too; a crystal is
|
||||
// not hit (`AbstractWindCharge.canHitEntity`).
|
||||
Who::Mob(id) if id >= super::dragon::SPECIAL => {
|
||||
if id < super::dragon::SPECIAL + 8 {
|
||||
self.hit_special(id, 1.0, p.owner == Some(Who::Player), false);
|
||||
}
|
||||
}
|
||||
Who::Mob(id) => {
|
||||
let source = Damage {
|
||||
kind: DamageKind::Thrown,
|
||||
@@ -570,11 +577,15 @@ impl Game<'_> {
|
||||
poses.yrot("wind", age * 16.0_f32.to_radians());
|
||||
let base = glam::Mat4::from_translation(at)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0));
|
||||
horizon_client::mobs::render::draw(
|
||||
horizon_client::mobs::render::draw_scrolled(
|
||||
model,
|
||||
base,
|
||||
&poses,
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
horizon_client::mobs::render::scroll_offset(
|
||||
age,
|
||||
horizon_client::mobs::render::WIND_CHARGE_SCROLL,
|
||||
),
|
||||
0xFFFF_FFFF,
|
||||
light,
|
||||
0,
|
||||
|
||||
@@ -120,7 +120,8 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
/// `WitherSkull.onHitEntity` / `onHit`: 8 damage (healing the wither 5
|
||||
/// when it kills), withering for 10 seconds, then a power 1 blast.
|
||||
/// when it kills), withering on a hit (10 seconds on Normal, 40 on Hard,
|
||||
/// none on Easy), then a power 1 blast.
|
||||
pub(super) fn wither_skull_hit(
|
||||
&mut self,
|
||||
p: &Projectile,
|
||||
@@ -132,17 +133,24 @@ impl Game<'_> {
|
||||
_ => None,
|
||||
};
|
||||
let amount = if owner.is_some() { 8.0 } else { 5.0 };
|
||||
let wither_ticks = match self.difficulty_id() {
|
||||
2 => 200,
|
||||
3 => 800,
|
||||
_ => 0,
|
||||
};
|
||||
match who {
|
||||
Some(Who::Player) => {
|
||||
if !self.creative {
|
||||
// WitherSkull.onHitEntity's source: the wither launched it.
|
||||
self.combat.projectile_owner = owner;
|
||||
self.mob_hits_player_kind(
|
||||
let hit = self.mob_hits_player(
|
||||
amount,
|
||||
[p.previous.x, p.previous.y, p.previous.z],
|
||||
"minecraft:wither_skull",
|
||||
);
|
||||
self.give_player_effect("minecraft:wither", 200, 1);
|
||||
if hit && wither_ticks > 0 {
|
||||
self.give_player_effect("minecraft:wither", wither_ticks, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Who::Mob(id)) => {
|
||||
@@ -154,8 +162,8 @@ impl Game<'_> {
|
||||
let killed = match self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
Some(m) => {
|
||||
let hit = m.damage(amount, source);
|
||||
if hit && m.alive() && m.species() != Species::Wither {
|
||||
m.add_effect("minecraft:wither", 200, 1);
|
||||
if hit && m.alive() && m.species() != Species::Wither && wither_ticks > 0 {
|
||||
m.add_effect("minecraft:wither", wither_ticks, 1);
|
||||
}
|
||||
hit && !m.alive()
|
||||
}
|
||||
|
||||
@@ -246,10 +246,14 @@ pub(super) fn preload_world_data(
|
||||
.spawn(move || {
|
||||
horizon_threads::behind_server();
|
||||
// Errors are left for the world opening to report.
|
||||
let _ = horizon_client::vanillagen::shared_data(
|
||||
if let Ok(data) = horizon_client::vanillagen::shared_data(
|
||||
&resources.join("worldgen"),
|
||||
registry,
|
||||
);
|
||||
) {
|
||||
// The Overworld's feature decoration, which every
|
||||
// seed shares: about 140 ms off opening the world.
|
||||
let _ = data.prepare_decoration("minecraft:normal", "minecraft:overworld");
|
||||
}
|
||||
let _ = horizon_client::structure_blocks::loot_data(&resources);
|
||||
let _ = super::lan::sync_data(&resources);
|
||||
});
|
||||
@@ -279,6 +283,27 @@ pub(super) fn world_data_used() {
|
||||
*PRELOAD.lock().unwrap_or_else(|e| e.into_inner()) = Preload::Used;
|
||||
}
|
||||
|
||||
/// A server's world opens: the loot tables its first placed block or broken
|
||||
/// container needs (`loot_data`) are read on a helper thread while the
|
||||
/// connection is made, so the game thread never waits on them (~0.6 s on a
|
||||
/// phone). Kept from then on, as a local world keeps them.
|
||||
pub(super) fn preload_server_loot(resources: &std::path::Path) {
|
||||
world_data_used();
|
||||
if horizon_client::structure_blocks::loaded_loot_data().is_some() {
|
||||
return;
|
||||
}
|
||||
let resources = resources.to_owned();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Loot preload".into())
|
||||
.spawn(move || {
|
||||
horizon_threads::behind_server();
|
||||
let _ = horizon_client::structure_blocks::loot_data(&resources);
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("Horizon: loot preload: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn make_streaming(
|
||||
res: &Resources,
|
||||
pack: &PackResources,
|
||||
|
||||
@@ -539,6 +539,9 @@ pub struct Renderer {
|
||||
hdr: Option<hdr::Hdr>,
|
||||
/// The HDR output the window's surface can present.
|
||||
hdr_output: Option<hdr::Output>,
|
||||
/// The marker file of an HDR output that has not presented a frame yet
|
||||
/// (`graphics::HDR_MARKER`).
|
||||
hdr_marker: Option<std::path::PathBuf>,
|
||||
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
|
||||
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
|
||||
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
|
||||
@@ -1966,7 +1969,7 @@ impl Renderer {
|
||||
// ulps covers that spread at any resolution while staying far below
|
||||
// the 0.001 a model's feet sit higher, so shadows never bleed onto
|
||||
// the feet.
|
||||
let shadow_blob_pipeline = entity_pipeline(
|
||||
let shadow_blob_pipeline = entity_pipeline_with(
|
||||
"Entity shadows",
|
||||
None,
|
||||
false,
|
||||
@@ -1978,6 +1981,8 @@ impl Renderer {
|
||||
},
|
||||
alpha_blend,
|
||||
false,
|
||||
"fs_entity_shadow",
|
||||
wgpu::ColorWrites::ALL,
|
||||
);
|
||||
// RenderPipelines.ITEM_TRANSLUCENT: blended, depth tested. Drawn before
|
||||
// translucent terrain, it cannot write depth: the water behind a
|
||||
@@ -2644,6 +2649,7 @@ impl Renderer {
|
||||
world_post,
|
||||
hdr: None,
|
||||
hdr_output,
|
||||
hdr_marker: None,
|
||||
pack_post: None,
|
||||
native: None,
|
||||
pass_timing: pack_timing::PassTiming::logging(),
|
||||
@@ -3202,9 +3208,17 @@ impl Renderer {
|
||||
}
|
||||
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
|
||||
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
|
||||
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32) {
|
||||
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32, marker: &std::path::Path) {
|
||||
let output = self.hdr_output.filter(|_| on && self.surface.is_some());
|
||||
if output.is_some() != self.hdr.is_some() {
|
||||
// If the game dies before HDR output shows a frame, the marker is still there at
|
||||
// the next start, which then comes up with HDR off.
|
||||
if output.is_some() {
|
||||
let _ = std::fs::write(marker, "HDR output was being switched on\n");
|
||||
self.hdr_marker = Some(marker.to_owned());
|
||||
} else if let Some(marker) = self.hdr_marker.take() {
|
||||
let _ = std::fs::remove_file(marker);
|
||||
}
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
|
||||
self.configure_surface();
|
||||
@@ -5231,6 +5245,12 @@ impl Renderer {
|
||||
startup_step(format_args!("presenting the first frame"));
|
||||
}
|
||||
self.queue.present(surface_frame);
|
||||
if self.hdr.is_some()
|
||||
&& let Some(marker) = self.hdr_marker.take()
|
||||
{
|
||||
let _ = std::fs::remove_file(marker);
|
||||
log::info!("Horizon renderer: HDR output presented its first frame");
|
||||
}
|
||||
if reconfigure && let Some(surface) = &self.surface {
|
||||
Self::configure(
|
||||
&self.device,
|
||||
|
||||
@@ -25,12 +25,26 @@ pub const TRANSLUCENT_ITEM_OVERLAY: u32 = 32;
|
||||
/// A translucent item with an enchantment glint.
|
||||
pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
|
||||
|
||||
/// The material of a quad by its overlay (`MATERIALS` order).
|
||||
fn material(layer: u32) -> usize {
|
||||
match layer {
|
||||
11 => 1,
|
||||
12 => 2,
|
||||
WATER_MASK_OVERLAY => 3,
|
||||
ENERGY_SWIRL_OVERLAY => 4,
|
||||
SHADOW_OVERLAY => 5,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY => 6,
|
||||
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 7,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct EntityBatch {
|
||||
// Quad indices avoid copying vertices twice while grouping materials.
|
||||
// Keep initialized high-water storage; local counts delimit active entries.
|
||||
// Each bucket can hold all quads, removing per-quad Vec growth checks.
|
||||
buckets: [Vec<usize>; MATERIALS],
|
||||
// Runs of consecutive quads of one material (first quad, quad count): a
|
||||
// model's quads share one, so whole runs are copied at once. Cleared each
|
||||
// frame, keeping their storage.
|
||||
runs: [Vec<(usize, usize)>; MATERIALS],
|
||||
pub(super) combined: Vec<Vertex>,
|
||||
}
|
||||
|
||||
@@ -43,50 +57,29 @@ impl EntityBatch {
|
||||
see_through: &[Vertex],
|
||||
) -> [u32; MATERIALS] {
|
||||
let quads = entities.as_chunks::<4>().0;
|
||||
for bucket in &mut self.buckets {
|
||||
if bucket.len() < quads.len() {
|
||||
bucket.resize(quads.len(), 0);
|
||||
}
|
||||
for runs in &mut self.runs {
|
||||
runs.clear();
|
||||
}
|
||||
let mut counts = [0usize; MATERIALS];
|
||||
for (index, quad) in quads.iter().enumerate() {
|
||||
let material = match quad[0].layer {
|
||||
11 => 1,
|
||||
12 => 2,
|
||||
WATER_MASK_OVERLAY => 3,
|
||||
ENERGY_SWIRL_OVERLAY => 4,
|
||||
SHADOW_OVERLAY => 5,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY => 6,
|
||||
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 7,
|
||||
_ => 0,
|
||||
};
|
||||
self.buckets[material][counts[material]] = index;
|
||||
counts[material] += 1;
|
||||
let mut start = 0;
|
||||
while start < quads.len() {
|
||||
let kind = material(quads[start][0].layer);
|
||||
let end = quads[start + 1..]
|
||||
.iter()
|
||||
.position(|quad| material(quad[0].layer) != kind)
|
||||
.map_or(quads.len(), |n| start + 1 + n);
|
||||
self.runs[kind].push((start, end - start));
|
||||
counts[kind] += end - start;
|
||||
start = end;
|
||||
}
|
||||
let entity_vertices = quads.len() * 4;
|
||||
self.combined.reserve(
|
||||
(entity_vertices + additive.len() + hand.len() + see_through.len())
|
||||
.saturating_sub(self.combined.len()),
|
||||
);
|
||||
self.combined.clear();
|
||||
self.combined
|
||||
.resize(entity_vertices, bytemuck::Zeroable::zeroed());
|
||||
if counts.contains(&quads.len()) {
|
||||
// Uniform materials are already ordered; copy the whole batch.
|
||||
self.combined.copy_from_slice(&entities[..entity_vertices]);
|
||||
} else {
|
||||
let mut offset = 0;
|
||||
for (bucket, &count) in self.buckets.iter().zip(&counts) {
|
||||
let bucket = &bucket[..count];
|
||||
let end = offset + count * 4;
|
||||
for (output, &index) in self.combined[offset..end]
|
||||
.as_chunks_mut::<4>()
|
||||
.0
|
||||
.iter_mut()
|
||||
.zip(bucket)
|
||||
{
|
||||
*output = quads[index];
|
||||
}
|
||||
offset = end;
|
||||
.reserve(entity_vertices + additive.len() + hand.len() + see_through.len());
|
||||
for runs in &self.runs {
|
||||
for &(first, count) in runs {
|
||||
self.combined
|
||||
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
|
||||
}
|
||||
}
|
||||
self.combined.extend_from_slice(additive);
|
||||
@@ -174,12 +167,12 @@ mod tests {
|
||||
let hand = geometry(2, false);
|
||||
// Warm every bucket and the combined buffer to their maximum sizes.
|
||||
equivalent(&mut batch, &entities, &additive, &hand);
|
||||
let capacities = batch.buckets.each_ref().map(|b| b.capacity());
|
||||
let capacities = batch.runs.each_ref().map(|b| b.capacity());
|
||||
let capacity = batch.combined.capacity();
|
||||
let pointer = batch.combined.as_ptr();
|
||||
for length in [0, 1, 3, 4, 511, entities.len(), 17, 0, entities.len()] {
|
||||
equivalent(&mut batch, &entities[..length], &additive[..5], &hand[..3]);
|
||||
assert_eq!(batch.buckets.each_ref().map(|b| b.capacity()), capacities);
|
||||
assert_eq!(batch.runs.each_ref().map(|b| b.capacity()), capacities);
|
||||
assert_eq!(batch.combined.capacity(), capacity);
|
||||
assert_eq!(batch.combined.as_ptr(), pointer);
|
||||
}
|
||||
@@ -188,11 +181,11 @@ mod tests {
|
||||
// Grow again, then confirm empty/small frames retain the new storage.
|
||||
let larger = geometry(8192, true);
|
||||
equivalent(&mut batch, &larger, &additive, &hand);
|
||||
let capacities = batch.buckets.each_ref().map(|b| b.capacity());
|
||||
let capacities = batch.runs.each_ref().map(|b| b.capacity());
|
||||
let capacity = batch.combined.capacity();
|
||||
for entities in [&larger[..7], &larger[..], &[]] {
|
||||
equivalent(&mut batch, entities, &[], &[]);
|
||||
assert_eq!(batch.buckets.each_ref().map(|b| b.capacity()), capacities);
|
||||
assert_eq!(batch.runs.each_ref().map(|b| b.capacity()), capacities);
|
||||
assert_eq!(batch.combined.capacity(), capacity);
|
||||
}
|
||||
}
|
||||
@@ -255,9 +248,9 @@ mod tests {
|
||||
before[7] as f64 / after[7] as f64,
|
||||
batch.combined.capacity() * std::mem::size_of::<Vertex>()
|
||||
+ batch
|
||||
.buckets
|
||||
.runs
|
||||
.iter()
|
||||
.map(|b| b.capacity() * std::mem::size_of::<usize>())
|
||||
.map(|b| b.capacity() * std::mem::size_of::<(usize, usize)>())
|
||||
.sum::<usize>()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
use std::{fs, path::Path};
|
||||
|
||||
pub const FILE: &str = "horizon-graphics.txt";
|
||||
/// Written beside it before HDR output is switched on and removed once the first HDR frame is
|
||||
/// presented. Found at the next start, it means HDR output took the game down, so HDR starts off.
|
||||
pub const HDR_MARKER: &str = "horizon-hdr-pending";
|
||||
|
||||
/// Simple-level presets. `Custom` leaves every option as the player set it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
@@ -310,9 +313,17 @@ fn bool_text(v: bool) -> &'static str {
|
||||
}
|
||||
impl Graphics {
|
||||
pub fn load(dir: &Path) -> Self {
|
||||
fs::read_to_string(dir.join(FILE))
|
||||
let mut g: Self = fs::read_to_string(dir.join(FILE))
|
||||
.map(|t| Self::parse(&t))
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr {
|
||||
g.hdr = false;
|
||||
g.save(dir);
|
||||
log::warn!(
|
||||
"the last start ended before HDR output showed a frame; HDR is off now (graphics option hdr, in {FILE})"
|
||||
);
|
||||
}
|
||||
g
|
||||
}
|
||||
pub fn parse(text: &str) -> Self {
|
||||
let mut g = Self::default();
|
||||
|
||||
@@ -801,6 +801,8 @@ impl HealthHud {
|
||||
/// nausea blits (their blending needs the screen-effect pipeline).
|
||||
pub fn paint(batch: &mut crate::hud::HudBatch, atlas: &crate::hud::HudAtlas, draws: &[GuiDraw]) {
|
||||
let mut stack = vec![IDENTITY];
|
||||
// One buffer for the sprite keys, instead of a string per drawn sprite.
|
||||
let mut name = String::new();
|
||||
for d in draws {
|
||||
let start = batch.vertices.len();
|
||||
let pose = *stack.last().unwrap_or(&IDENTITY);
|
||||
@@ -817,12 +819,14 @@ pub fn paint(batch: &mut crate::hud::HudBatch, atlas: &crate::hud::HudAtlas, dra
|
||||
} => {
|
||||
// Effect sprites belong to the mob_effect atlas directory;
|
||||
// the other blitSprite ids refer to GUI sprites.
|
||||
let name = if sprite.starts_with("mob_effect/") {
|
||||
sprite.to_string()
|
||||
name.clear();
|
||||
if sprite.starts_with("mob_effect/") {
|
||||
name.push_str(sprite);
|
||||
} else {
|
||||
format!("gui/sprites/{sprite}")
|
||||
};
|
||||
let Some(mut r) = atlas.sprites.get(&name).copied() else {
|
||||
name.push_str("gui/sprites/");
|
||||
name.push_str(sprite);
|
||||
}
|
||||
let Some(mut r) = atlas.sprites.get(name.as_str()).copied() else {
|
||||
continue;
|
||||
};
|
||||
if let Some([sw, sh, u, v]) = *region {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! `EffectsInInventory`: active effect panels beside survival and creative
|
||||
//! inventories, with compact icons when there is little room on the right.
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::{
|
||||
effects::EffectInstance,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
@@ -123,17 +125,19 @@ pub fn paint(
|
||||
cursor: [i32; 2],
|
||||
) -> Option<[(String, u32); 2]> {
|
||||
let mut tooltip = None;
|
||||
let mut icon = String::new();
|
||||
for panel in panels {
|
||||
background(batch, atlas, panel);
|
||||
let text_width = panel.width - 39;
|
||||
let clipped = atlas.font.width(&panel.name).ceil() as i32 > text_width;
|
||||
if text_width > 0 {
|
||||
let shown = if clipped {
|
||||
let shown: Cow<'_, str> = if clipped {
|
||||
let available = text_width as f32 - atlas.font.width("...");
|
||||
let count = atlas.font.fit(&panel.name, available);
|
||||
format!("{}...", panel.name.chars().take(count).collect::<String>())
|
||||
let text: String = panel.name.chars().take(count).collect();
|
||||
Cow::Owned(format!("{text}..."))
|
||||
} else {
|
||||
panel.name.clone()
|
||||
Cow::Borrowed(panel.name.as_str())
|
||||
};
|
||||
batch.text(
|
||||
atlas,
|
||||
@@ -152,9 +156,12 @@ pub fn paint(
|
||||
true,
|
||||
);
|
||||
}
|
||||
icon.clear();
|
||||
icon.push_str("mob_effect/");
|
||||
icon.push_str(&panel.icon.id);
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
&format!("mob_effect/{}", panel.icon.id),
|
||||
&icon,
|
||||
(panel.x + 7) as f32,
|
||||
(panel.y + 7) as f32,
|
||||
18.0,
|
||||
|
||||
@@ -28,6 +28,14 @@ pub fn from_network(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> ItemStack
|
||||
out
|
||||
}
|
||||
|
||||
/// `DyeColor.byId`: an unknown id is white (`OutOfBoundsStrategy.ZERO`).
|
||||
fn dye_name(id: i32) -> &'static str {
|
||||
usize::try_from(id)
|
||||
.ok()
|
||||
.and_then(|i| crate::block_entity_render::DYES.get(i))
|
||||
.map_or("white", |d| d.0)
|
||||
}
|
||||
|
||||
fn item_name(builtin: &Builtin, id: i32) -> Arc<str> {
|
||||
Arc::from(
|
||||
builtin
|
||||
@@ -61,6 +69,25 @@ fn components_tag(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> Option<Tag>
|
||||
("pattern", Tag::String(pattern.into())),
|
||||
])
|
||||
}
|
||||
// BannerPatternLayers: each layer's pattern is a synced registry entry.
|
||||
Component::BannerPatterns(layers) => {
|
||||
let layers: Option<Vec<Tag>> = layers
|
||||
.iter()
|
||||
.map(|(pattern, color)| {
|
||||
let pattern = synced.name("minecraft:banner_pattern", *pattern)?;
|
||||
Some(Tag::compound([
|
||||
("pattern", Tag::String(pattern.into())),
|
||||
("color", Tag::String(dye_name(*color).into())),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
let Some(layers) = layers else {
|
||||
continue;
|
||||
};
|
||||
Tag::List(layers)
|
||||
}
|
||||
// A shield's `base_color`: the dye's id.
|
||||
Component::Int(v) if *kind == component::BASE_COLOR => Tag::String(dye_name(*v).into()),
|
||||
Component::Int(v) if *kind == component::RARITY => {
|
||||
let rarity = ["common", "uncommon", "rare", "epic"];
|
||||
Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into())
|
||||
|
||||
@@ -824,6 +824,7 @@ impl ChunkEncoder {
|
||||
tag: block_entities
|
||||
.get(&pos)
|
||||
.and_then(entity_tag)
|
||||
.or_else(|| pot_tag(chunk, pos))
|
||||
.unwrap_or(Tag::End),
|
||||
});
|
||||
}
|
||||
@@ -938,6 +939,28 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
|
||||
match entity {
|
||||
BlockEntity::Sign(sign) => Some(Tag::compound(sign.encode())),
|
||||
BlockEntity::Spawner(s) => Some(s.update_tag()),
|
||||
// BannerBlockEntity.getUpdateTag: saveWithoutMetadata, so the patterns,
|
||||
// the custom name and the components.
|
||||
BlockEntity::Banner(b) => {
|
||||
let mut entries = Vec::new();
|
||||
if !b.layers.is_empty() {
|
||||
entries.push(("patterns", crate::banner::layers_to_nbt(&b.layers)));
|
||||
}
|
||||
if let Some(name) = &b.name {
|
||||
entries.push(("CustomName", Tag::String(name.as_str().into())));
|
||||
}
|
||||
entries.push(("components", Tag::Compound(b.components.clone())));
|
||||
Some(Tag::compound(entries))
|
||||
}
|
||||
// VaultBlockEntity.getUpdateTag: the shared data, which holds the display item.
|
||||
BlockEntity::Vault(v) => Some(v.update_tag()),
|
||||
// TrialSpawnerStateData.getUpdateTag: the spawn data the mob is drawn from.
|
||||
// next_mob_spawns_at (ACTIVE only) is left out: nothing in the client
|
||||
// reads it, and the block state it depends on is not known here.
|
||||
BlockEntity::TrialSpawner(t) => t
|
||||
.next_spawn_data
|
||||
.as_ref()
|
||||
.map(|d| Tag::compound([("spawn_data", d.encode())])),
|
||||
// SkullBlockEntity: the profile is the whole reason a head differs.
|
||||
BlockEntity::Skull(s) => {
|
||||
let mut entries = Vec::new();
|
||||
@@ -962,6 +985,22 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The update tag of the decorated pot at `pos` (DecoratedPotBlockEntity.getUpdateTag,
|
||||
/// saveCustomOnly). A pot is not a block entity here: its tag is the chunk's saved one.
|
||||
pub fn pot_tag(chunk: &Chunk, pos: (i32, i32, i32)) -> Option<Tag> {
|
||||
let at = chunk.saved.as_ref()?.block_entities.iter().find(|t| {
|
||||
t.get("id") == Some(&Tag::String("minecraft:decorated_pot".into()))
|
||||
&& t.get("x") == Some(&Tag::Int(pos.0))
|
||||
&& t.get("y") == Some(&Tag::Int(pos.1))
|
||||
&& t.get("z") == Some(&Tag::Int(pos.2))
|
||||
})?;
|
||||
let entries: Vec<_> = ["sherds", "LootTable", "LootTableSeed", "item"]
|
||||
.into_iter()
|
||||
.filter_map(|k| Some((k, at.get(k)?.clone())))
|
||||
.collect();
|
||||
(!entries.is_empty()).then(|| Tag::compound(entries))
|
||||
}
|
||||
|
||||
/// Light sections run from one below the world to one above it. A layer
|
||||
/// exists where vanilla's light storage keeps one: within one section of a
|
||||
/// non-air section of this column or a neighbouring one (`around`, bit i for
|
||||
|
||||
@@ -381,7 +381,7 @@ fn serve_legacy(mut stream: TcpStream, shared: &Shared) -> Result<(), String> {
|
||||
let body = if data.len() == 1 {
|
||||
format!("{motd}\u{a7}{online}\u{a7}{max}")
|
||||
} else {
|
||||
if data[1] != 1 {
|
||||
if data.get(1) != Some(&1) {
|
||||
return Ok(());
|
||||
}
|
||||
if data.len() > 2 {
|
||||
|
||||
@@ -459,6 +459,22 @@ impl Guest {
|
||||
let range = if self.creative() { 5.0 } else { 4.5 };
|
||||
within_block_range(eye, range + buffer, pos)
|
||||
}
|
||||
/// `Player.isWithinBlockInteractionRange(pos, 1.0)`, which the server
|
||||
/// checks a block action against (ServerPlayerGameMode.handleBlockBreakAction,
|
||||
/// ServerGamePacketListenerImpl.handleUseItemOn): from the eyes of the
|
||||
/// guest's body in its pose (crouching, swimming, gliding).
|
||||
pub fn may_reach_block(&self, pos: [i32; 3]) -> bool {
|
||||
let eye = self
|
||||
.motion
|
||||
.as_ref()
|
||||
.map_or_else(|| self.eye_height(), |m| f64::from(m.dimensions.eye_height));
|
||||
let range = if self.creative() { 5.0 } else { 4.5 };
|
||||
within_block_range(
|
||||
[self.position[0], self.position[1] + eye, self.position[2]],
|
||||
range + 1.0,
|
||||
pos,
|
||||
)
|
||||
}
|
||||
/// The inventory menu's 46 slots (ServerPlayer.inventoryMenu): 0 crafting
|
||||
/// result, 1..=4 crafting grid, 5..=8 armor, 9..=35 main, 36..=44 hotbar,
|
||||
/// 45 off hand.
|
||||
@@ -523,6 +539,14 @@ pub fn firework_components(
|
||||
out
|
||||
}
|
||||
|
||||
/// `DyeColor.getId` of a dye's name.
|
||||
fn dye_id(name: &str) -> Option<i32> {
|
||||
crate::block_entity_render::DYES
|
||||
.iter()
|
||||
.position(|d| d.0 == name)
|
||||
.map(|i| i as i32)
|
||||
}
|
||||
|
||||
/// The off hand slot is conventionally 40 in saves and 45 in the menu.
|
||||
/// `synced_id` names an entry of a synced registry (enchantments, trim
|
||||
/// materials and patterns) by its id on this connection.
|
||||
@@ -595,6 +619,36 @@ fn network_stack(
|
||||
));
|
||||
}
|
||||
}
|
||||
// BannerPatternLayers: each pattern a reference into the synced registry;
|
||||
// a layer that has none leaves the stack without the component.
|
||||
let layers: Option<Vec<(i32, i32)>> = d
|
||||
.banner_patterns
|
||||
.iter()
|
||||
.map(|layer| {
|
||||
let name = if layer.pattern.contains(':') {
|
||||
layer.pattern.to_string()
|
||||
} else {
|
||||
format!("minecraft:{}", layer.pattern)
|
||||
};
|
||||
Some((
|
||||
synced_id("minecraft:banner_pattern", &name)?,
|
||||
dye_id(&layer.color)?,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
if let Some(layers) = layers.filter(|layers| !layers.is_empty()) {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::BANNER_PATTERNS,
|
||||
horizon_protocol::item::Component::BannerPatterns(layers),
|
||||
));
|
||||
}
|
||||
// A shield's `base_color`.
|
||||
if let Some(id) = d.base_color.as_deref().and_then(dye_id) {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::BASE_COLOR,
|
||||
horizon_protocol::item::Component::Int(id),
|
||||
));
|
||||
}
|
||||
if let Some(id) = d.map_id {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::MAP_ID,
|
||||
@@ -771,4 +825,43 @@ mod tests {
|
||||
let stack = stack_to_network(|_| 7, synced_id, &Some(chestplate)).unwrap();
|
||||
assert_eq!(stack.component(component::TRIM), None);
|
||||
}
|
||||
|
||||
fn guest_at(position: [f64; 3]) -> Guest {
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
Guest::new(
|
||||
Joined {
|
||||
conn: 0,
|
||||
uuid: 0,
|
||||
name: "guest".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(8),
|
||||
brand: String::new(),
|
||||
sender: PlayerSender::new(tx, Arc::new(std::sync::atomic::AtomicUsize::new(0))),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:1".parse().unwrap(),
|
||||
},
|
||||
1,
|
||||
position,
|
||||
10,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_action_must_be_within_reach() {
|
||||
let mut g = guest_at([0.5, 64.0, 0.5]);
|
||||
// Survival (4.5 + 1 from the eyes): five blocks off horizontally,
|
||||
// not one further.
|
||||
assert!(g.may_reach_block([5, 64, 0]));
|
||||
assert!(!g.may_reach_block([6, 64, 0]));
|
||||
// Creative's 5.0 reaches one block further.
|
||||
g.game_mode = CREATIVE;
|
||||
assert!(g.may_reach_block([6, 64, 0]));
|
||||
assert!(!g.may_reach_block([7, 64, 0]));
|
||||
// With no body yet the sneak flag gives the eye height: a block at
|
||||
// the edge above is gone once crouched.
|
||||
g.game_mode = SURVIVAL;
|
||||
assert!(g.may_reach_block([0, 71, 0]));
|
||||
g.sneaking = true;
|
||||
assert!(!g.may_reach_block([0, 71, 0]));
|
||||
}
|
||||
}
|
||||
@@ -1566,10 +1566,15 @@ impl Hub {
|
||||
}
|
||||
// Block entities with data the client shows.
|
||||
for &(x, y, z) in &positions {
|
||||
let Some(entity) = world.block_entities.get(&(x, y, z)) else {
|
||||
continue;
|
||||
// A decorated pot is no block entity: its tag is the chunk's saved one.
|
||||
let tag = match world.block_entities.get(&(x, y, z)) {
|
||||
Some(entity) => super::chunks::entity_tag(entity),
|
||||
None => world
|
||||
.chunks
|
||||
.get(&(x >> 4, z >> 4))
|
||||
.and_then(|c| super::chunks::pot_tag(c, (x, y, z))),
|
||||
};
|
||||
let Some(tag) = super::chunks::entity_tag(entity) else {
|
||||
let Some(tag) = tag else {
|
||||
continue;
|
||||
};
|
||||
let state = world.block(x, y, z).0;
|
||||
@@ -2216,13 +2221,16 @@ impl Hub {
|
||||
// game's, run after this pass. A move queued behind it is checked
|
||||
// against the block the player just broke and falls through, so it
|
||||
// would be sent back; it waits for the next tick, when the block is
|
||||
// gone, as vanilla handles the two packets in order.
|
||||
// gone, as vanilla handles the packets in order. So does everything
|
||||
// queued before that move: the arm swing (Punch) comes between the dig
|
||||
// and the move.
|
||||
S::PlayerAction(p) if p.action <= 3 => {
|
||||
events.push(HostEvent::Packet(conn, Box::new(S::PlayerAction(p))));
|
||||
if matches!(
|
||||
self.guests[i].inbox.front(),
|
||||
Some(S::MovePlayerPos(_) | S::MovePlayerPosRot(_))
|
||||
) {
|
||||
if self.guests[i]
|
||||
.inbox
|
||||
.iter()
|
||||
.any(|p| matches!(p, S::MovePlayerPos(_) | S::MovePlayerPosRot(_)))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,35 @@ pub fn draw_progress(batch: &mut HudBatch, atlas: &HudAtlas, line: &str, progres
|
||||
batch.fill(atlas, left, top, done, 2.0, opaque(0x00FF00));
|
||||
}
|
||||
|
||||
/// `ConnectScreen`'s Cancel button corner on a GUI `w` x `h`:
|
||||
/// (width / 2 - 100, height / 4 + 120 + 12), 200 x 20.
|
||||
pub fn connect_cancel(w: f32, h: f32) -> (f32, f32) {
|
||||
((w / 2.0).trunc() - 100.0, (h / 4.0).trunc() + 132.0)
|
||||
}
|
||||
|
||||
/// `ConnectScreen.extractRenderState` over the menu background: the status
|
||||
/// centred at (width / 2, height / 2 - 50) and, while the connection can
|
||||
/// still be cancelled, Cancel.
|
||||
pub fn draw_connecting(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
status: &Text,
|
||||
cursor: (f32, f32),
|
||||
cancel: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
|
||||
let tw = atlas.font.width_text(status);
|
||||
let (x, y) = (((w - tw) / 2.0).trunc(), (h / 2.0).trunc() - 50.0);
|
||||
batch.text_styled(atlas, status, x, y, u32::MAX, true);
|
||||
if cancel {
|
||||
let (x, y) = connect_cancel(w, h);
|
||||
let hover = crate::ui::hovered(cursor, x, y, 200.0, 20.0);
|
||||
let label = Text::translate("gui.cancel", &[]);
|
||||
crate::ui::button(batch, atlas, x, y, 200.0, 20.0, &label, true, hover);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -538,6 +538,8 @@ pub enum DamageKind {
|
||||
Freeze,
|
||||
/// `fell_out_of_world`: below the world's floor.
|
||||
OutOfWorld,
|
||||
/// `cramming`: too many entities in one place (`maxEntityCramming`).
|
||||
Cramming,
|
||||
}
|
||||
/// A damage source: its type, the attacking entity and where it came from.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -616,6 +618,7 @@ impl Damage {
|
||||
| DamageKind::SonicBoom
|
||||
| DamageKind::Freeze
|
||||
| DamageKind::OutOfWorld
|
||||
| DamageKind::Cramming
|
||||
)
|
||||
}
|
||||
pub fn is_fire(&self) -> bool {
|
||||
@@ -642,6 +645,7 @@ impl Damage {
|
||||
| DamageKind::InWall
|
||||
| DamageKind::Freeze
|
||||
| DamageKind::OutOfWorld
|
||||
| DamageKind::Cramming
|
||||
)
|
||||
}
|
||||
pub fn is_projectile(&self) -> bool {
|
||||
@@ -980,6 +984,17 @@ pub enum MobEvent {
|
||||
count: u32,
|
||||
},
|
||||
Sound(&'static str, [f64; 3], f32, f32),
|
||||
/// `UseBonemeal`: the villager's bone meal grows the crop at `pos`
|
||||
/// (`BoneMealItem.growCrop`, then level event 1505).
|
||||
Bonemeal {
|
||||
pos: [i32; 3],
|
||||
},
|
||||
/// `RingBell`: the villager rings the meeting-point bell
|
||||
/// (`BellBlock.attemptToRing`: the bell shakes, the sound and the game
|
||||
/// event follow).
|
||||
RingBell {
|
||||
bell: [i32; 3],
|
||||
},
|
||||
/// `ChestBlockEntity.startOpen` / `stopOpen` by a copper golem: the
|
||||
/// chest's opener count goes up or down (interaction range 3).
|
||||
ChestOpen {
|
||||
@@ -1108,10 +1123,12 @@ pub enum MobEvent {
|
||||
/// The mob the `Explode` right after this comes from
|
||||
/// (`ServerExplosion.source`), and whether it is a charged creeper: the
|
||||
/// exploding creeper is already out of the mob list when the game applies
|
||||
/// the blast, so its victims' loot cannot look it up.
|
||||
/// the blast, so its victims' loot cannot look it up, nor the death
|
||||
/// message its name (`name`, `getDisplayName`).
|
||||
ExplosionSource {
|
||||
mob: u32,
|
||||
charged: bool,
|
||||
name: crate::text::Text,
|
||||
},
|
||||
/// The centre, the radius and the kind of the mob blowing up (it may be
|
||||
/// gone from the list by the time the blast is handled).
|
||||
@@ -1811,6 +1828,8 @@ pub struct MobWorld<'a> {
|
||||
pub mob_griefing: bool,
|
||||
/// The `mob_drops` game rule (`shouldDropLoot`).
|
||||
pub mob_drops: bool,
|
||||
/// The `max_entity_cramming` game rule.
|
||||
pub max_entity_cramming: i32,
|
||||
/// `getCurrentDifficultyAt` at the mob being ticked (near the player
|
||||
/// outside the mob tick).
|
||||
pub difficulty: spawning::LocalDifficulty,
|
||||
@@ -2088,6 +2107,18 @@ impl Mob {
|
||||
if epf > 0.0 {
|
||||
amount *= 1.0 - epf / 25.0;
|
||||
}
|
||||
// Witch.getDamageAfterMagicAbsorb: none from its own doing, else
|
||||
// 85% less from `#witch_resistant_to`.
|
||||
if self.species() == Species::Witch {
|
||||
if source.attacker == Some(Who::Mob(self.id)) {
|
||||
amount = 0.0;
|
||||
} else if matches!(
|
||||
source.kind,
|
||||
DamageKind::Magic | DamageKind::SonicBoom | DamageKind::Thorns
|
||||
) {
|
||||
amount *= 0.15;
|
||||
}
|
||||
}
|
||||
// Wolf.actuallyHurt: wolf armor takes the damage instead of the wolf.
|
||||
let absorbed = self.wolf_armor_absorbs(&source);
|
||||
let mut knock = true;
|
||||
@@ -2170,6 +2201,15 @@ impl Mob {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// A splash or cloud's harming effect on this mob: the potion's
|
||||
/// `indirect_magic` is in `#witch_resistant_to`, so a witch takes 15%.
|
||||
pub fn potion_harm(&self, amount: f32) -> f32 {
|
||||
if self.species() == Species::Witch {
|
||||
amount * 0.15
|
||||
} else {
|
||||
amount
|
||||
}
|
||||
}
|
||||
/// `Wolf.canArmorAbsorb`: it wears wolf armor and the damage type is not
|
||||
/// in `#bypasses_wolf_armor` (drowning, magic, thorns, in a wall, out of
|
||||
/// the world and the untyped kills).
|
||||
@@ -2183,6 +2223,7 @@ impl Mob {
|
||||
| DamageKind::InWall
|
||||
| DamageKind::Thorns
|
||||
| DamageKind::OutOfWorld
|
||||
| DamageKind::Cramming
|
||||
| DamageKind::Generic
|
||||
)
|
||||
}
|
||||
|
||||
@@ -533,9 +533,9 @@ pub fn tick(
|
||||
true
|
||||
}
|
||||
/// `LivingEntity.aiStep` -> `Entity.applyEffectsFromBlocks` for the blocks
|
||||
/// that hurt or hold a mob: the cactus (`CactusBlock.entityInside`, 1) and the
|
||||
/// sweet berry bush (`SweetBerryBushBlock.entityInside`: stuck at 0.8, 0.75,
|
||||
/// 0.8, and a grown bush pricks for 1 when the mob moved at least 0.003 on X
|
||||
/// that hurt or hold a mob: the cactus (`CactusBlock.entityInside`, 1), the
|
||||
/// cobweb (stuck at 0.25, 0.05, 0.25) and the sweet berry bush
|
||||
/// (`SweetBerryBushBlock.entityInside`: stuck at 0.8, 0.75, 0.8, and a grown bush pricks for 1 when the mob moved at least 0.003 on X
|
||||
/// or Z this tick). The blocks are those the mob's box touches anywhere along
|
||||
/// its move this tick, from where it started to where it ended.
|
||||
fn effects_from_blocks(m: &mut Mob, w: &MobWorld) {
|
||||
@@ -562,6 +562,21 @@ fn effects_from_blocks(m: &mut Mob, w: &MobWorld) {
|
||||
"minecraft:cactus" => {
|
||||
m.damage(1.0, Damage::of(DamageKind::Cactus));
|
||||
}
|
||||
// WebBlock.entityInside -> makeStuckInBlock, which a
|
||||
// spider ignores for cobwebs and a wither always.
|
||||
"minecraft:cobweb"
|
||||
if !matches!(
|
||||
m.species(),
|
||||
Species::Spider | Species::CaveSpider | Species::Wither
|
||||
) =>
|
||||
{
|
||||
m.fall_distance = 0.0;
|
||||
m.stuck_multiplier = if m.has_effect("minecraft:weaving") {
|
||||
Vec3::new(0.5, 0.25, 0.5)
|
||||
} else {
|
||||
Vec3::new(0.25, f64::from(0.05_f32), 0.25)
|
||||
};
|
||||
}
|
||||
"minecraft:sweet_berry_bush"
|
||||
if !matches!(m.species(), Species::Fox | Species::Bee) =>
|
||||
{
|
||||
@@ -828,17 +843,28 @@ fn play_ambient(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
// Nautilus/ZombieNautilus.getAmbientSound: by age, under water or on land.
|
||||
Species::Nautilus | Species::ZombieNautilus => super::nautilus::sounds(m)[0],
|
||||
Species::Enderman if m.ai.target.is_some() => "entity.enderman.scream",
|
||||
Species::Wolf if m.ai.target.is_some() => "entity.wolf.growl",
|
||||
Species::Wolf if m.ai.target.is_some() => {
|
||||
super::finalize::variant_sound(m, "growl").unwrap_or("entity.wolf.growl")
|
||||
}
|
||||
Species::Wolf if m.random.next_int_bound(3).unwrap_or(1) == 0 => {
|
||||
if m.extra.tame && m.health < m.attrs.max_health / 2.0 {
|
||||
"entity.wolf.whine"
|
||||
let kind = if m.extra.tame && m.health < m.attrs.max_health / 2.0 {
|
||||
"whine"
|
||||
} else {
|
||||
"entity.wolf.pant"
|
||||
}
|
||||
"pant"
|
||||
};
|
||||
super::finalize::variant_sound(m, kind).unwrap_or("entity.wolf.pant")
|
||||
}
|
||||
Species::Parrot => super::parrot::ambient(m, w),
|
||||
Species::Cat if m.extra.tame && m.extra.in_love > 0 => "entity.cat.purr",
|
||||
Species::Cat if !m.extra.tame => "entity.cat.stray_ambient",
|
||||
// Cat.getAmbientSound: purr in love, purreow one time in four, else ambient.
|
||||
Species::Cat if m.extra.tame && m.extra.in_love > 0 => {
|
||||
super::finalize::variant_sound(m, "purr").unwrap_or("entity.cat.purr")
|
||||
}
|
||||
Species::Cat if m.extra.tame && m.random.next_int_bound(4).unwrap_or(1) == 0 => {
|
||||
super::finalize::variant_sound(m, "purreow").unwrap_or("entity.cat.purreow")
|
||||
}
|
||||
Species::Cat if !m.extra.tame => {
|
||||
super::finalize::variant_sound(m, "stray_ambient").unwrap_or("entity.cat.stray_ambient")
|
||||
}
|
||||
Species::PolarBear if m.is_baby() => "entity.polar_bear.ambient_baby",
|
||||
Species::Dolphin if !m.fluids.in_water => "entity.dolphin.ambient",
|
||||
Species::Guardian if !m.fluids.in_water => "entity.guardian.ambient_land",
|
||||
@@ -1058,7 +1084,8 @@ fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
&& m.species() != Species::Shulker
|
||||
&& !on_climbable(m, w)
|
||||
&& (m.species() != Species::Creaking || super::creaking::can_move(m));
|
||||
let mut targets: Vec<(Who, Vec3)> = Vec::new();
|
||||
// Each with whether it rides a vehicle (`Entity.isPassenger`).
|
||||
let mut targets: Vec<(Who, Vec3, bool)> = Vec::new();
|
||||
if let Some(p) = &w.player
|
||||
// Entity.push: passengers of the same vehicle never push each other.
|
||||
&& m.rider.is_none()
|
||||
@@ -1066,21 +1093,27 @@ fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
&& !p.spectator
|
||||
&& p.bounds.intersects(bounds)
|
||||
{
|
||||
targets.push((Who::Player, p.position));
|
||||
targets.push((Who::Player, p.position, p.passenger));
|
||||
}
|
||||
for o in w.others_near(bounds) {
|
||||
if o.id != m.id && o.alive && o.pushable && o.bounds.intersects(bounds) {
|
||||
targets.push((Who::Mob(o.id), o.position));
|
||||
targets.push((Who::Mob(o.id), o.position, o.passenger));
|
||||
}
|
||||
}
|
||||
// EntitySelector.pushableBy(this): the teams' collision rules.
|
||||
let own = w.teams.of(Who::Mob(m.id));
|
||||
targets.retain(|(who, _)| crate::scoreboard::pushable_by(own, w.teams.of(*who), true));
|
||||
// Cramming (maxEntityCramming 24).
|
||||
if targets.len() > 23 {
|
||||
let _ = m.random.next_int_bound(4);
|
||||
targets.retain(|(who, ..)| crate::scoreboard::pushable_by(own, w.teams.of(*who), true));
|
||||
// Cramming: a quarter of the ticks spent with `maxEntityCramming` or more
|
||||
// others around that are not riding anything hurts it by 6.
|
||||
let limit = w.max_entity_cramming;
|
||||
if limit > 0
|
||||
&& targets.len() as i64 > i64::from(limit) - 1
|
||||
&& m.random.next_int_bound(4).unwrap_or(1) == 0
|
||||
&& targets.iter().filter(|t| !t.2).count() as i64 > i64::from(limit) - 1
|
||||
{
|
||||
m.damage(6.0, Damage::of(DamageKind::Cramming));
|
||||
}
|
||||
for (who, at) in targets {
|
||||
for (who, at, _) in targets {
|
||||
// Warden.doPush: touching it angers it, then the push itself.
|
||||
if m.species() == Species::Warden {
|
||||
super::warden::do_push(m, w, who, events);
|
||||
@@ -1455,17 +1488,66 @@ pub fn voice_sounds(m: &Mob) -> [&'static str; 3] {
|
||||
};
|
||||
[ambient, m.kind.sounds[1], m.kind.sounds[2]]
|
||||
}
|
||||
// The sound variant's baby_sounds (classic sets, like the rest here).
|
||||
Species::Pig if m.is_baby() => [
|
||||
"entity.baby_pig.ambient",
|
||||
"entity.baby_pig.hurt",
|
||||
"entity.baby_pig.death",
|
||||
// Horse.getAmbientSound, getHurtSound and getDeathSound by age.
|
||||
Species::Horse if m.is_baby() => [
|
||||
"entity.baby_horse.ambient",
|
||||
"entity.baby_horse.hurt",
|
||||
"entity.baby_horse.death",
|
||||
],
|
||||
Species::Chicken if m.is_baby() => [
|
||||
"entity.baby_chicken.ambient",
|
||||
"entity.baby_chicken.hurt",
|
||||
"entity.baby_chicken.death",
|
||||
// Drowned, Guardian and ElderGuardian: the hurt and death sounds by
|
||||
// water or land.
|
||||
Species::Drowned if m.fluids.in_water => [
|
||||
"entity.drowned.ambient_water",
|
||||
"entity.drowned.hurt_water",
|
||||
"entity.drowned.death_water",
|
||||
],
|
||||
Species::Guardian if !m.fluids.in_water => [
|
||||
m.kind.sounds[0],
|
||||
"entity.guardian.hurt_land",
|
||||
"entity.guardian.death_land",
|
||||
],
|
||||
Species::ElderGuardian if !m.fluids.in_water => [
|
||||
m.kind.sounds[0],
|
||||
"entity.elder_guardian.hurt_land",
|
||||
"entity.elder_guardian.death_land",
|
||||
],
|
||||
// Slime and MagmaCube.getHurtSound and getDeathSound: tiny ones.
|
||||
Species::Slime if m.extra.size <= 1 => [
|
||||
m.kind.sounds[0],
|
||||
"entity.slime.hurt_small",
|
||||
"entity.slime.death_small",
|
||||
],
|
||||
Species::MagmaCube if m.extra.size <= 1 => [
|
||||
m.kind.sounds[0],
|
||||
"entity.magma_cube.hurt_small",
|
||||
"entity.magma_cube.death_small",
|
||||
],
|
||||
// Shulker.getHurtSound: a closed shell.
|
||||
Species::Shulker if m.extra.counter == 0 => [
|
||||
m.kind.sounds[0],
|
||||
"entity.shulker.hurt_closed",
|
||||
m.kind.sounds[2],
|
||||
],
|
||||
// Turtle.getHurtSound and getDeathSound by age.
|
||||
Species::Turtle if m.is_baby() => [
|
||||
m.kind.sounds[0],
|
||||
"entity.turtle.hurt_baby",
|
||||
"entity.turtle.death_baby",
|
||||
],
|
||||
// SkeletonHorse.getAmbientSound: its own under water.
|
||||
Species::SkeletonHorse if m.fluids.eye_in_water => [
|
||||
"entity.skeleton_horse.ambient_water",
|
||||
m.kind.sounds[1],
|
||||
m.kind.sounds[2],
|
||||
],
|
||||
// The mob's sound variant set, the baby one for a baby.
|
||||
Species::Pig | Species::Cow | Species::Chicken | Species::Wolf | Species::Cat => {
|
||||
use super::finalize::variant_sound as v;
|
||||
match (v(m, "ambient"), v(m, "hurt"), v(m, "death")) {
|
||||
(Some(a), Some(h), Some(d)) => [a, h, d],
|
||||
_ => m.kind.sounds,
|
||||
}
|
||||
}
|
||||
_ => m.kind.sounds,
|
||||
}
|
||||
}
|
||||
@@ -1514,13 +1596,12 @@ pub fn age_up(m: &mut Mob, seconds: i32, forced: bool) {
|
||||
}
|
||||
}
|
||||
/// `Animal.playEatingSound` (empty) and its overrides, as (sound, volume,
|
||||
/// pitch); `level` is the level's random. Pigs and cats use the classic sound
|
||||
/// set, like their other sounds here.
|
||||
/// pitch); `level` is the level's random. Pigs and cats play their sound
|
||||
/// variant's set.
|
||||
pub fn eating_sound(
|
||||
m: &mut Mob,
|
||||
level: &mut impl RandomSource,
|
||||
) -> Option<(&'static str, f32, f32)> {
|
||||
let baby = m.is_baby();
|
||||
// Mth.randomBetween(level.getRandom(), 0.8, 1.2).
|
||||
let mut between = || level.next_float() * 0.4 + 0.8;
|
||||
let (sound, volume, pitch, entity_sound) = match m.species() {
|
||||
@@ -1528,16 +1609,21 @@ pub fn eating_sound(
|
||||
Species::Armadillo | Species::Pig | Species::SulfurCube => {
|
||||
let sound = match m.species() {
|
||||
Species::Armadillo => "entity.armadillo.eat",
|
||||
Species::Pig if baby => "entity.baby_pig.eat",
|
||||
Species::Pig => "entity.pig.eat",
|
||||
Species::Pig => {
|
||||
super::finalize::variant_sound(m, "eat").unwrap_or("entity.pig.eat")
|
||||
}
|
||||
_ => "entity.small_sulfur_cube.eat",
|
||||
};
|
||||
let pitch = m.voice_pitch();
|
||||
(sound, sound_volume(m), pitch, true)
|
||||
}
|
||||
// playSound(sound, 1, 1).
|
||||
Species::Cat if baby => ("entity.baby_cat.eat", 1.0, 1.0, true),
|
||||
Species::Cat => ("entity.cat.eat", 1.0, 1.0, true),
|
||||
Species::Cat => (
|
||||
super::finalize::variant_sound(m, "eat").unwrap_or("entity.cat.eat"),
|
||||
1.0,
|
||||
1.0,
|
||||
true,
|
||||
),
|
||||
Species::Fox => ("entity.fox.eat", 1.0, 1.0, true),
|
||||
// level().playSound(null, this, sound, NEUTRAL, volume, pitch).
|
||||
Species::Frog => ("entity.frog.eat", 2.0, 1.0, false),
|
||||
@@ -1876,6 +1962,7 @@ pub fn custom_travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<Mo
|
||||
Family::Guardian => super::guardian::travel(m, w, input, events),
|
||||
// Shulkers never move on their own.
|
||||
Family::Shulker => true,
|
||||
Family::Allay => super::allay::travel(m, w, input, events),
|
||||
Family::Vex => super::vex::travel(m),
|
||||
Family::Nautilus => super::nautilus::travel(m, w, input, events),
|
||||
_ => false,
|
||||
@@ -2013,12 +2100,10 @@ pub fn step_sound(
|
||||
| S::ElderGuardian
|
||||
| S::Breeze => None,
|
||||
S::Armadillo => quiet("entity.armadillo.step"),
|
||||
S::Chicken if baby => quiet("entity.baby_chicken.step"),
|
||||
S::Chicken => quiet("entity.chicken.step"),
|
||||
S::Cow | S::Mooshroom => quiet("entity.cow.step"),
|
||||
S::Pig if baby => quiet("entity.baby_pig.step"),
|
||||
S::Pig => quiet("entity.pig.step"),
|
||||
S::Wolf => quiet("entity.wolf.step"),
|
||||
S::Chicken | S::Cow | S::Pig | S::Wolf => {
|
||||
super::finalize::variant_sound(m, "step").and_then(quiet)
|
||||
}
|
||||
S::Mooshroom => quiet("entity.cow.step"),
|
||||
S::Llama | S::TraderLlama => quiet("entity.llama.step"),
|
||||
S::Frog => quiet("entity.frog.step"),
|
||||
S::Goat => quiet("entity.goat.step"),
|
||||
@@ -2103,6 +2188,8 @@ pub fn swim_sound(m: &Mob) -> &'static str {
|
||||
Species::Nautilus => "entity.nautilus.swim",
|
||||
Species::ZombieNautilus => "entity.zombie_nautilus.swim",
|
||||
Species::Turtle => "entity.turtle.swim",
|
||||
// Monster.getSwimSound, and Hoglin's own override of it.
|
||||
s if s == Species::Hoglin || is_monster(s) => "entity.hostile.swim",
|
||||
_ => "entity.generic.swim",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
//! blocks and bring them to their player (or a liked note block), dance to
|
||||
//! jukeboxes and duplicate when given an amethyst shard mid-dance.
|
||||
use super::{
|
||||
Mob,
|
||||
Mob, MobEvent, MobWorld,
|
||||
anim::{Poses, State},
|
||||
control::MoveKind,
|
||||
goals::{Cx, Goal, LookAtPlayer, MOVE, Panic, RandomLookAround, Stroll, goal_clone},
|
||||
nav::NavKind,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
|
||||
// Allay state: `main_hand` the held item, `ints[1]` how many it carries,
|
||||
@@ -95,7 +96,8 @@ impl Goal for Deliver {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `StayCloseToTarget`: back within 4 blocks of the deposit.
|
||||
/// `StayCloseToTarget.create(.., 4, 16, 2.25)`: acts only once the deposit is
|
||||
/// 16 blocks away or more, then walks until within 4.
|
||||
#[derive(Clone, Debug)]
|
||||
struct StayClose;
|
||||
impl Goal for StayClose {
|
||||
@@ -109,7 +111,19 @@ impl Goal for StayClose {
|
||||
let d = (p[0] - m.position.x).powi(2)
|
||||
+ (p[1] - m.position.y).powi(2)
|
||||
+ (p[2] - m.position.z).powi(2);
|
||||
d > 16.0
|
||||
d >= 256.0
|
||||
})
|
||||
}
|
||||
/// `MoveToTargetSink.reachedTarget`: the walk target it set is reached
|
||||
/// within a Manhattan block distance of 4 (`closeEnough`).
|
||||
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
m.extra.point.is_none()
|
||||
&& deposit(m, cx).is_some_and(|p| {
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
(0..3)
|
||||
.map(|i| (p[i].floor() as i32 - at[i].floor() as i32).abs())
|
||||
.sum::<i32>()
|
||||
> 4
|
||||
})
|
||||
}
|
||||
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
@@ -120,6 +134,14 @@ impl Goal for StayClose {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Allay.travel`: `travelFlying(input, getSpeed())` — the airborne speed is
|
||||
/// the one the move control set this tick, and no gravity applies, so an
|
||||
/// allay never falls under its own travel.
|
||||
pub fn travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<MobEvent>) -> bool {
|
||||
let speed = m.ai.speed;
|
||||
m.travel_flying_speeds(w, input, events, 0.02, 0.02, speed);
|
||||
true
|
||||
}
|
||||
/// `Allay.aiStep` / `tick`: healing, cooldowns, the hold and spin clocks.
|
||||
pub fn ai_step(m: &mut Mob) {
|
||||
if m.alive() && m.age.is_multiple_of(10) {
|
||||
|
||||
@@ -534,7 +534,7 @@ impl Goal for PufferfishPuff {
|
||||
cx.w.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.alive && !p.creative && area.intersects(p.bounds));
|
||||
// TARGETING_CONDITIONS: not other fish (not_scary_for_pufferfish) and alive.
|
||||
// TARGETING_CONDITIONS: not #not_scary_for_pufferfish, and alive.
|
||||
let mob = cx.w.others_near(area).any(|s| {
|
||||
s.id != m.id
|
||||
&& s.alive
|
||||
@@ -547,7 +547,13 @@ impl Goal for PufferfishPuff {
|
||||
| Species::Squid
|
||||
| Species::GlowSquid
|
||||
| Species::Dolphin
|
||||
| Species::Axolotl
|
||||
| Species::Turtle
|
||||
| Species::Guardian
|
||||
| Species::ElderGuardian
|
||||
| Species::Tadpole
|
||||
| Species::Nautilus
|
||||
| Species::ZombieNautilus
|
||||
| Species::SulfurCube
|
||||
)
|
||||
&& area.intersects(s.bounds)
|
||||
});
|
||||
|
||||
@@ -114,28 +114,40 @@ pub fn register(m: &mut Mob) {
|
||||
g.add(3, Tempt::new(1.25, tempt, false));
|
||||
}
|
||||
}
|
||||
/// `generateMaxHealth`, `generateSpeed`, `generateJumpStrength` and the coat.
|
||||
/// `randomizeAttributes` of each class: horses roll health, speed and jump,
|
||||
/// donkeys and mules only health (their jump stays 0.5), skeleton horses only
|
||||
/// the jump, zombie horses a smaller jump and their own speed.
|
||||
pub fn finalize(m: &mut Mob) {
|
||||
let species = m.species();
|
||||
let r = &mut m.random;
|
||||
let health =
|
||||
15.0 + r.next_int_bound(8).unwrap_or(0) as f32 + r.next_int_bound(9).unwrap_or(0) as f32;
|
||||
let speed =
|
||||
(0.45 + r.next_double() * 0.3 + r.next_double() * 0.3 + r.next_double() * 0.3) * 0.25;
|
||||
let jump = 0.4 + r.next_double() * 0.2 + r.next_double() * 0.2 + r.next_double() * 0.2;
|
||||
match m.species() {
|
||||
if matches!(species, Species::Horse | Species::Donkey | Species::Mule) {
|
||||
m.attrs.max_health = 15.0
|
||||
+ r.next_int_bound(8).unwrap_or(0) as f32
|
||||
+ r.next_int_bound(9).unwrap_or(0) as f32;
|
||||
}
|
||||
match species {
|
||||
Species::Horse => {
|
||||
m.attrs.max_health = health;
|
||||
m.attrs.speed = speed;
|
||||
m.extra.vector[0] = jump;
|
||||
m.extra.variant = m.random.next_int_bound(7).unwrap_or(0) as u8;
|
||||
m.extra.variant2 = m.random.next_int_bound(5).unwrap_or(0) as u8;
|
||||
m.attrs.speed =
|
||||
(0.45 + r.next_double() * 0.3 + r.next_double() * 0.3 + r.next_double() * 0.3)
|
||||
* 0.25;
|
||||
m.extra.vector[0] =
|
||||
0.4 + r.next_double() * 0.2 + r.next_double() * 0.2 + r.next_double() * 0.2;
|
||||
m.extra.variant = r.next_int_bound(7).unwrap_or(0) as u8;
|
||||
m.extra.variant2 = r.next_int_bound(5).unwrap_or(0) as u8;
|
||||
}
|
||||
Species::Donkey | Species::Mule => {
|
||||
// AbstractChestedHorse.randomizeAttributes: fixed speed, generated health.
|
||||
m.attrs.max_health = health;
|
||||
m.extra.vector[0] = jump;
|
||||
Species::Donkey | Species::Mule => m.extra.vector[0] = 0.5,
|
||||
Species::ZombieHorse => {
|
||||
// ZombieHorse.generateZombieHorseJumpStrength and ...Speed.
|
||||
let d = 0.066_666_666_666_666_67;
|
||||
m.extra.vector[0] =
|
||||
0.5 + r.next_double() * d + r.next_double() * d + r.next_double() * d;
|
||||
m.attrs.speed =
|
||||
(9.0 + r.next_double() + r.next_double() + r.next_double()) / f64::from(42.16f32);
|
||||
}
|
||||
_ => {
|
||||
m.extra.vector[0] =
|
||||
0.4 + r.next_double() * 0.2 + r.next_double() * 0.2 + r.next_double() * 0.2
|
||||
}
|
||||
_ => m.extra.vector[0] = jump,
|
||||
}
|
||||
m.health = m.attrs.max_health;
|
||||
}
|
||||
@@ -280,13 +292,17 @@ pub fn make_mad(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
m.extra.counter = 20;
|
||||
m.extra.flag = false;
|
||||
}
|
||||
// getAngrySound: null (silent) for the skeleton horse.
|
||||
let sound = match m.species() {
|
||||
Species::SkeletonHorse => return,
|
||||
Species::Donkey => "entity.donkey.angry",
|
||||
Species::Mule => "entity.mule.angry",
|
||||
Species::ZombieHorse => "entity.zombie_horse.angry",
|
||||
Species::Llama | Species::TraderLlama => "entity.llama.angry",
|
||||
_ if m.is_baby() => "entity.baby_horse.angry",
|
||||
_ => "entity.horse.angry",
|
||||
};
|
||||
events.push(MobEvent::Sound(sound, m.center(), 1.0, 1.0));
|
||||
m.voice(sound, events);
|
||||
}
|
||||
/// `RandomStandGoal`.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -312,10 +328,113 @@ impl Goal for RandomStand {
|
||||
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
m.extra.counter = 20;
|
||||
m.extra.flag = false;
|
||||
if m.species() == Species::Horse {
|
||||
cx.events
|
||||
.push(MobEvent::Sound("entity.horse.angry", m.center(), 1.0, 1.0));
|
||||
// playStandSound: getAmbientStandSound is the ambient sound, plain
|
||||
// playSound(sound) at volume and pitch 1.
|
||||
let sound = super::ai::voice_sounds(m)[0];
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
cx.events.push(MobEvent::Sound(sound, at, 1.0, 1.0));
|
||||
}
|
||||
}
|
||||
/// Horses that step with `AbstractHorse.playStepSound` (llamas and camels
|
||||
/// have their own).
|
||||
pub fn hoof_steps(s: Species) -> bool {
|
||||
matches!(
|
||||
s,
|
||||
Species::Horse
|
||||
| Species::Donkey
|
||||
| Species::Mule
|
||||
| Species::SkeletonHorse
|
||||
| Species::ZombieHorse
|
||||
)
|
||||
}
|
||||
/// `AbstractHorse.playStepSound` (and `Horse.playGallopSound`) on the block
|
||||
/// `pos`: silent on liquids; a ridden horse walks on wood for five steps,
|
||||
/// then gallops every third; unridden, wood or the plain step by age.
|
||||
/// Donkeys and mules never gallop (`canGallop`).
|
||||
pub fn play_step(
|
||||
m: &mut Mob,
|
||||
w: &MobWorld,
|
||||
pos: [i32; 3],
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
events: &mut Vec<MobEvent>,
|
||||
) {
|
||||
if matches!(
|
||||
&*w.facts.get(state).name,
|
||||
"minecraft:water" | "minecraft:lava" | "minecraft:bubble_column"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let above = w.world.block(pos[0], pos[1] + 1, pos[2]);
|
||||
let sound_type = if &*w.facts.get(above).name == "minecraft:snow" {
|
||||
&w.facts.get(above).sounds
|
||||
} else {
|
||||
&w.facts.get(state).sounds
|
||||
};
|
||||
let (volume, pitch) = (sound_type.volume, sound_type.pitch);
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
let baby = m.is_baby();
|
||||
let can_gallop = !matches!(m.species(), Species::Donkey | Species::Mule);
|
||||
if m.rider.is_some() && can_gallop {
|
||||
m.ai.steps.gallop += 1;
|
||||
let count = m.ai.steps.gallop;
|
||||
if count > 5 && count % 3 == 0 {
|
||||
events.push(MobEvent::Sound(
|
||||
"entity.horse.gallop",
|
||||
at,
|
||||
volume * 0.15,
|
||||
pitch,
|
||||
));
|
||||
// Horse.playGallopSound: now and then it breathes too.
|
||||
if m.species() == Species::Horse && m.random.next_int_bound(10).unwrap_or(1) == 0 {
|
||||
let breathe = if baby {
|
||||
"entity.baby_horse.breathe"
|
||||
} else {
|
||||
"entity.horse.breathe"
|
||||
};
|
||||
events.push(MobEvent::Sound(breathe, at, volume * 0.6, pitch));
|
||||
}
|
||||
} else if count <= 5 {
|
||||
events.push(MobEvent::Sound(
|
||||
"entity.horse.step_wood",
|
||||
at,
|
||||
volume * 0.15,
|
||||
pitch,
|
||||
));
|
||||
}
|
||||
} else if is_wood_sound_type(sound_type) {
|
||||
events.push(MobEvent::Sound(
|
||||
"entity.horse.step_wood",
|
||||
at,
|
||||
volume * 0.15,
|
||||
pitch,
|
||||
));
|
||||
} else {
|
||||
let step = if baby {
|
||||
"entity.baby_horse.step"
|
||||
} else {
|
||||
"entity.horse.step"
|
||||
};
|
||||
events.push(MobEvent::Sound(step, at, volume * 0.15, pitch));
|
||||
}
|
||||
}
|
||||
/// `AbstractHorse.isWoodSoundType`: WOOD, NETHER_WOOD, STEM, CHERRY_WOOD and
|
||||
/// BAMBOO_WOOD (not HARD_CROP, which only shares WOOD's step).
|
||||
fn is_wood_sound_type(sounds: &horizon_items::blocks::Sounds) -> bool {
|
||||
let step = sounds
|
||||
.step
|
||||
.strip_prefix("minecraft:")
|
||||
.unwrap_or(&sounds.step);
|
||||
let place = sounds
|
||||
.place
|
||||
.strip_prefix("minecraft:")
|
||||
.unwrap_or(&sounds.place);
|
||||
match step {
|
||||
"block.wood.step" => place == "block.wood.place",
|
||||
"block.nether_wood.step"
|
||||
| "block.stem.step"
|
||||
| "block.cherry_wood.step"
|
||||
| "block.bamboo_wood.step" => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// The mount takes its rider's input (`getRiddenInput`, `tickRidden`).
|
||||
@@ -323,6 +442,10 @@ pub fn ridden_step(m: &mut Mob) {
|
||||
let Some(r) = m.rider else {
|
||||
return;
|
||||
};
|
||||
// AbstractHorse.tickRidden: not pressing forward ends the gallop.
|
||||
if hoof_steps(m.species()) && r.forward <= 0.0 {
|
||||
m.ai.steps.gallop = 0;
|
||||
}
|
||||
// AbstractNautilus.tickRidden turns half way toward the rider.
|
||||
if super::nautilus::is_nautilus(m.species()) {
|
||||
return super::nautilus::ridden(m);
|
||||
|
||||
@@ -821,6 +821,22 @@ fn age_boundary_reached(m: &mut Mob) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `SpawnEggItem.spawnsEntity`: the spawn egg is for the mob's own kind, so
|
||||
/// it is used on it (`Mob.checkAndHandleImportantInteractions`).
|
||||
pub fn spawn_egg_is_for(egg: &str, kind: &str) -> bool {
|
||||
egg.strip_suffix("_spawn_egg") == Some(kind)
|
||||
}
|
||||
|
||||
/// `SpawnEggItem.spawnOffspringFromSpawnEgg`: the offspring of a spawn egg
|
||||
/// used on a mob of the same kind is a baby of that kind: it starts with
|
||||
/// the age of a baby and keeps the parent's variant and colour.
|
||||
pub fn spawn_egg_offspring(child: &mut Mob, species: Species, variant: u8, color: u8) {
|
||||
set_baby(child, true);
|
||||
child.extra.variant = variant;
|
||||
if species == Species::Sheep {
|
||||
child.extra.color = color;
|
||||
}
|
||||
}
|
||||
/// `setBaby`: AgeableMob through its age, zombies, piglins and zoglins.
|
||||
pub fn set_baby(m: &mut Mob, baby: bool) {
|
||||
let s = m.species();
|
||||
@@ -2046,6 +2062,46 @@ pub fn sound_variants(s: Species) -> &'static [&'static str] {
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
/// A sound of the mob's `*_sound_variant` set (`Pig.getSoundSet`,
|
||||
/// `Wolf.getSoundSet`, `Cat.getSoundSet`, ...): `kind` is the sound's key
|
||||
/// (`ambient`, `hurt`, `step`, `growl`, `purreow`...). Babies use the species'
|
||||
/// baby set (cows have none); an adult's set is `entity.<species>.*` for
|
||||
/// `classic` and `entity.<species>_<variant>.*` otherwise, except the
|
||||
/// footsteps that every variant shares (not the cow's). None for a species
|
||||
/// without sound variants.
|
||||
pub fn variant_sound(m: &Mob, kind: &str) -> Option<&'static str> {
|
||||
use std::{collections::HashMap, sync::Mutex};
|
||||
let species = m.species();
|
||||
let name = match species {
|
||||
Species::Pig => "pig",
|
||||
Species::Cow => "cow",
|
||||
Species::Chicken => "chicken",
|
||||
Species::Wolf => "wolf",
|
||||
Species::Cat => "cat",
|
||||
_ => return None,
|
||||
};
|
||||
let variant = m
|
||||
.extra
|
||||
.sound_variant
|
||||
.and_then(|i| sound_variants(species).get(i as usize))
|
||||
.copied()
|
||||
.unwrap_or("classic");
|
||||
let id = if m.is_baby() && species != Species::Cow {
|
||||
format!("entity.baby_{name}.{kind}")
|
||||
} else if variant == "classic" || (kind == "step" && species != Species::Cow) {
|
||||
format!("entity.{name}.{kind}")
|
||||
} else {
|
||||
format!("entity.{name}_{variant}.{kind}")
|
||||
};
|
||||
// A handful of ids in all: keep each one as a `&'static str`.
|
||||
static IDS: Mutex<Option<HashMap<String, &'static str>>> = Mutex::new(None);
|
||||
let mut ids = IDS.lock().ok()?;
|
||||
let ids = ids.get_or_insert_with(HashMap::new);
|
||||
Some(
|
||||
*ids.entry(id)
|
||||
.or_insert_with_key(|k| Box::leak(k.clone().into_boxed_str())),
|
||||
)
|
||||
}
|
||||
/// The registry name of the game's variant index.
|
||||
pub fn variant_name(s: Species, index: u8) -> &'static str {
|
||||
let i = index as usize;
|
||||
@@ -2098,6 +2154,39 @@ mod tests {
|
||||
assert_eq!(panda_variant(4, 4), 4);
|
||||
assert_eq!(panda_variant(6, 0), 6);
|
||||
}
|
||||
/// #2269: the offspring of a spawn egg used on a mob is a baby of that
|
||||
/// kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`), and only the mob's
|
||||
/// own kind takes the egg.
|
||||
#[test]
|
||||
fn a_spawn_egg_offspring_is_a_baby() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let kind = std::sync::Arc::new(data.types.get("minecraft:cow").unwrap().clone());
|
||||
let mut child = crate::mobs::Mob::new(
|
||||
2,
|
||||
kind,
|
||||
horizon_core::geometry::Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
assert!(!child.is_baby());
|
||||
spawn_egg_offspring(&mut child, Species::Cow, 3, 0);
|
||||
// The offspring is a baby of the mob's kind.
|
||||
assert!(child.is_baby());
|
||||
// It keeps the parent's variant.
|
||||
assert_eq!(child.extra.variant, 3);
|
||||
// The egg is for the mob's own kind.
|
||||
assert!(spawn_egg_is_for("minecraft:cow_spawn_egg", "minecraft:cow"));
|
||||
assert!(!spawn_egg_is_for(
|
||||
"minecraft:pig_spawn_egg",
|
||||
"minecraft:cow"
|
||||
));
|
||||
assert!(!spawn_egg_is_for("minecraft:stick", "minecraft:cow"));
|
||||
}
|
||||
#[test]
|
||||
fn reasons_round_trip() {
|
||||
for r in Reason::ALL {
|
||||
|
||||
@@ -773,6 +773,8 @@ pub struct Stroll {
|
||||
pub water_avoiding: Option<f32>,
|
||||
/// RandomSwimmingGoal: BehaviorUtils.getRandomSwimmablePos.
|
||||
pub swim: bool,
|
||||
/// WaterAvoidingRandomFlyingGoal: a hover spot, else an air or water pos.
|
||||
pub flying: bool,
|
||||
pub force: bool,
|
||||
wanted: [f64; 3],
|
||||
}
|
||||
@@ -784,6 +786,7 @@ impl Stroll {
|
||||
check_no_action: true,
|
||||
water_avoiding: None,
|
||||
swim: false,
|
||||
flying: false,
|
||||
force: false,
|
||||
wanted: [0.0; 3],
|
||||
}
|
||||
@@ -800,7 +803,10 @@ impl Stroll {
|
||||
}
|
||||
/// WaterAvoidingRandomFlyingGoal (a nearby air position).
|
||||
pub fn flying(speed: f64) -> Self {
|
||||
Self::new(speed)
|
||||
Self {
|
||||
flying: true,
|
||||
..Self::new(speed)
|
||||
}
|
||||
}
|
||||
/// RandomSwimmingGoal(speed, interval)
|
||||
pub fn swimming(speed: f64, interval: i32) -> Self {
|
||||
@@ -861,6 +867,21 @@ impl Goal for Stroll {
|
||||
}
|
||||
p
|
||||
}
|
||||
None if self.flying => {
|
||||
// WaterAvoidingRandomFlyingGoal.getPosition.
|
||||
let [xd, _, zd] = m.view_vector();
|
||||
m.hover_random_pos(
|
||||
cx.w,
|
||||
8,
|
||||
7,
|
||||
xd,
|
||||
zd,
|
||||
f64::from(std::f32::consts::FRAC_PI_2),
|
||||
3,
|
||||
1,
|
||||
)
|
||||
.or_else(|| m.air_and_water_random_pos(cx.w, 8, 4, -2, xd, zd))
|
||||
}
|
||||
None => m.default_random_pos(cx.w, 10, 7),
|
||||
};
|
||||
let Some(p) = pos else { return false };
|
||||
@@ -1065,6 +1086,8 @@ pub struct MeleeAttack {
|
||||
raise_arm: i32,
|
||||
/// Ticks between attacks (20 unless set).
|
||||
pub interval: i32,
|
||||
/// Ticks between attacks for a baby, when they differ.
|
||||
pub baby_interval: Option<i32>,
|
||||
}
|
||||
impl MeleeAttack {
|
||||
pub fn new(speed: f64, follow_unseen: bool) -> Self {
|
||||
@@ -1079,6 +1102,7 @@ impl MeleeAttack {
|
||||
last_check: 0,
|
||||
raise_arm: 0,
|
||||
interval: 20,
|
||||
baby_interval: None,
|
||||
}
|
||||
}
|
||||
pub fn zombie(mut self) -> Self {
|
||||
@@ -1093,6 +1117,12 @@ impl MeleeAttack {
|
||||
self.interval = ticks;
|
||||
self
|
||||
}
|
||||
/// `MeleeAttack.create(adult)` for adults, `create(baby)` for babies.
|
||||
pub fn intervals(mut self, adult: i32, baby: i32) -> Self {
|
||||
self.interval = adult;
|
||||
self.baby_interval = Some(baby);
|
||||
self
|
||||
}
|
||||
}
|
||||
impl Goal for MeleeAttack {
|
||||
goal_clone!();
|
||||
@@ -1191,7 +1221,11 @@ impl Goal for MeleeAttack {
|
||||
cx,
|
||||
);
|
||||
} else if self.until_attack <= 0 && m.within_melee_range(&t) && m.sees(cx.w, who) {
|
||||
self.until_attack = adjusted(true, self.interval);
|
||||
let interval = match self.baby_interval {
|
||||
Some(baby) if m.is_baby() => baby,
|
||||
_ => self.interval,
|
||||
};
|
||||
self.until_attack = adjusted(true, interval);
|
||||
m.swing();
|
||||
super::ai::do_hurt_target(m, who, cx);
|
||||
}
|
||||
@@ -1893,6 +1927,10 @@ impl Goal for Breed {
|
||||
if tamable && (!m.extra.tame || m.extra.sitting) {
|
||||
return false;
|
||||
}
|
||||
// AbstractHorse.canMate is false and Mule keeps it: mules never breed.
|
||||
if species == Species::Mule {
|
||||
return false;
|
||||
}
|
||||
// TurtleBreedGoal.canUse: not while carrying an egg.
|
||||
if species == Species::Turtle && super::turtle::has_egg(m) {
|
||||
return false;
|
||||
|
||||
@@ -6,7 +6,7 @@ use super::{
|
||||
anim::{Poses, State},
|
||||
goals::{
|
||||
Cx, Goal, HurtByTarget, LOOK, LookAtPlayer, MOVE, MeleeAttack, NearestTarget, Prey,
|
||||
RandomLookAround, Stroll, goal_clone,
|
||||
RandomLookAround, Stroll, goal_clone, reduced,
|
||||
},
|
||||
};
|
||||
use crate::projectiles::{Projectile, ProjectileKind};
|
||||
@@ -158,7 +158,8 @@ impl Goal for OfferFlower {
|
||||
self.ticks > 0
|
||||
}
|
||||
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
self.ticks = 400;
|
||||
// adjustedTickDelay(400): the goal ticks every other game tick.
|
||||
self.ticks = reduced(400);
|
||||
m.extra.counter = 400;
|
||||
// IronGolem.offerFlower(true): entity event 11.
|
||||
entity_event(m, 11, cx);
|
||||
|
||||
@@ -326,9 +326,17 @@ pub fn creeper_tick(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
if m.swell >= 30 {
|
||||
m.swell = 30;
|
||||
let radius = if m.extra.charged { 6.0 } else { 3.0 };
|
||||
let name = match &m.custom_name {
|
||||
Some(name) => crate::text::Text::literal(name.clone()),
|
||||
None => crate::text::Text::translate(
|
||||
&format!("entity.{}", m.kind.id.replace(':', ".")),
|
||||
&[],
|
||||
),
|
||||
};
|
||||
events.push(MobEvent::ExplosionSource {
|
||||
mob: m.id,
|
||||
charged: m.extra.charged,
|
||||
name,
|
||||
});
|
||||
events.push(MobEvent::Explode(
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
|
||||
@@ -542,12 +542,12 @@ fn shoot_crossbow(m: &mut Mob, who: Who, cx: &mut Cx) {
|
||||
Some(Who::Mob(m.id)),
|
||||
);
|
||||
p.set_bonus(super::gear::arrow_bonus(m));
|
||||
// rangedAttackUncertainty at normal difficulty: 14 - 2 * 4.
|
||||
// rangedAttackUncertainty: 14 - 4 * difficulty.
|
||||
p.shoot(
|
||||
&mut crate::projectiles::own_random(m),
|
||||
[dx, dy, dz],
|
||||
1.6,
|
||||
6.0,
|
||||
(14 - i32::from(cx.w.difficulty.id) * 4) as f32,
|
||||
);
|
||||
cx.events.push(MobEvent::Projectile(p));
|
||||
let pitch = 1.0 / (m.random.next_float() * 0.5 + 1.8) + 0.53;
|
||||
|
||||
@@ -25,6 +25,8 @@ pub struct Steps {
|
||||
pub move_dist: f32,
|
||||
pub fly_dist: f32,
|
||||
pub next_step: f32,
|
||||
/// `AbstractHorse.gallopSoundCounter`.
|
||||
pub gallop: i32,
|
||||
}
|
||||
impl Default for Steps {
|
||||
fn default() -> Self {
|
||||
@@ -32,6 +34,7 @@ impl Default for Steps {
|
||||
move_dist: 0.0,
|
||||
fly_dist: 0.0,
|
||||
next_step: 1.0,
|
||||
gallop: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -706,7 +709,11 @@ impl Mob {
|
||||
&& (self.on_ground || climbable)
|
||||
&& !self.fluids_swimming();
|
||||
if produced {
|
||||
if let Some((sound, volume, pitch)) =
|
||||
if super::equine::hoof_steps(self.species()) {
|
||||
if !self.silent {
|
||||
super::equine::play_step(self, w, legacy, legacy_state, events);
|
||||
}
|
||||
} else if let Some((sound, volume, pitch)) =
|
||||
super::ai::step_sound(self, w, legacy, legacy_state)
|
||||
&& !self.silent
|
||||
{
|
||||
@@ -863,10 +870,13 @@ impl Mob {
|
||||
}
|
||||
if damage > 0.0 {
|
||||
let at = [self.position.x, self.position.y, self.position.z];
|
||||
let sound = if damage > 4.0 {
|
||||
"entity.generic.big_fall"
|
||||
} else {
|
||||
"entity.generic.small_fall"
|
||||
// Monster.getFallSounds: the hostile pair.
|
||||
let monster = super::ai::is_monster(self.species());
|
||||
let sound = match (monster, damage > 4.0) {
|
||||
(true, true) => "entity.hostile.big_fall",
|
||||
(true, false) => "entity.hostile.small_fall",
|
||||
(false, true) => "entity.generic.big_fall",
|
||||
(false, false) => "entity.generic.small_fall",
|
||||
};
|
||||
events.push(MobEvent::Sound(sound, at, 1.0, 1.0));
|
||||
self.damage(damage as f32, super::Damage::fall());
|
||||
@@ -925,12 +935,33 @@ impl Mob {
|
||||
} else {
|
||||
0.02
|
||||
};
|
||||
self.travel_flying_speeds(
|
||||
w,
|
||||
input,
|
||||
events,
|
||||
fluid_speed,
|
||||
fluid_speed,
|
||||
self.kind.air_speed,
|
||||
);
|
||||
}
|
||||
/// `LivingEntity.travelFlying(input, waterSpeed, lavaSpeed, airSpeed)`:
|
||||
/// the speeds vary per mob (an allay flies at the speed its move control
|
||||
/// set, `Allay.travel`).
|
||||
pub fn travel_flying_speeds(
|
||||
&mut self,
|
||||
w: &MobWorld,
|
||||
input: Vec3,
|
||||
events: &mut Vec<MobEvent>,
|
||||
water_speed: f32,
|
||||
lava_speed: f32,
|
||||
air_speed: f32,
|
||||
) {
|
||||
let (speed, drag) = if self.fluids.in_water {
|
||||
(fluid_speed, f64::from(0.8_f32))
|
||||
(water_speed, f64::from(0.8_f32))
|
||||
} else if self.fluids.in_lava {
|
||||
(fluid_speed, 0.5)
|
||||
(lava_speed, 0.5)
|
||||
} else {
|
||||
(self.kind.air_speed, f64::from(0.91_f32))
|
||||
(air_speed, f64::from(0.91_f32))
|
||||
};
|
||||
self.move_relative(speed, input);
|
||||
self.do_move(w, events);
|
||||
|
||||
@@ -169,7 +169,8 @@ pub fn register_hoglin(m: &mut Mob) {
|
||||
let g = &mut m.ai.goals;
|
||||
g.add(0, Float);
|
||||
g.add(1, BabyPanic(super::goals::Panic::new(1.25)));
|
||||
g.add(2, MeleeAttack::new(1.0, true));
|
||||
// HoglinAi: MeleeAttack.create(40) for adults, 15 for babies.
|
||||
g.add(2, MeleeAttack::new(1.0, true).intervals(40, 15));
|
||||
g.add(3, super::goals::Breed::new(1.0));
|
||||
g.add(3, HoglinAvoidRepellent);
|
||||
g.add(4, super::goals::FollowParent::new(1.1));
|
||||
@@ -185,7 +186,8 @@ pub fn register_hoglin(m: &mut Mob) {
|
||||
pub fn register_zoglin(m: &mut Mob) {
|
||||
let g = &mut m.ai.goals;
|
||||
g.add(0, Float);
|
||||
g.add(2, MeleeAttack::new(1.0, true));
|
||||
// Zoglin.initMemories: MeleeAttack.create(40) for adults, 15 for babies.
|
||||
g.add(2, MeleeAttack::new(1.0, true).intervals(40, 15));
|
||||
g.add(5, Stroll::avoiding_water(0.4));
|
||||
g.add(6, LookAtPlayer::new(8.0));
|
||||
m.ai.targets.add(1, HurtByTarget::new());
|
||||
|
||||
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user