mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 06:48:22 -10:00
Compare commits
266
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81f339814b | ||
|
|
0590a85d16 | ||
|
|
be6beb6ec7 | ||
|
|
9cdfc5bd44 | ||
|
|
26af55000a | ||
|
|
3782473f52 | ||
|
|
9cbc96ee7b | ||
|
|
af0fef9196 | ||
|
|
bbec03c14f | ||
|
|
3c9bb38e88 | ||
|
|
dc943f95e8 | ||
|
|
312218a47f | ||
|
|
83b5c5ed53 | ||
|
|
578cd262a5 | ||
|
|
360272fd79 | ||
|
|
1d6d4cf0a0 | ||
|
|
a1bdb0d22d | ||
|
|
930252e2e4 | ||
|
|
7dba3b4d73 | ||
|
|
e0bf5f6599 | ||
|
|
c7504433c0 | ||
|
|
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 | ||
|
|
7dd4b772c9 | ||
|
|
03dd78d67b | ||
|
|
6267cd3e15 | ||
|
|
265640ac18 | ||
|
|
f5a3088f0d | ||
|
|
3ccbba76df | ||
|
|
c519ff6c56 | ||
|
|
400d15ab3f | ||
|
|
d6971605d4 | ||
|
|
7dc818eb5b | ||
|
|
2772738d47 | ||
|
|
131625c414 | ||
|
|
478ae35b53 | ||
|
|
5f868011ee | ||
|
|
67712eeeef | ||
|
|
5b7d19f02b | ||
|
|
74c2d13d77 | ||
|
|
9ff0d98010 | ||
|
|
f66f66c3b4 | ||
|
|
42be3018dd | ||
|
|
7cbb8c4506 | ||
|
|
6a05885ee3 | ||
|
|
c979ce46e9 | ||
|
|
0c5c38ac9e | ||
|
|
ae3b9e273f | ||
|
|
d0b6e48921 | ||
|
|
07e4a86658 | ||
|
|
3c98062662 | ||
|
|
e1b2b0b2fd | ||
|
|
9755416a02 | ||
|
|
0281365907 | ||
|
|
a5fcb49900 | ||
|
|
6f6229c2d4 | ||
|
|
0ca3a5be98 | ||
|
|
8993772de0 | ||
|
|
6a261a00a5 | ||
|
|
f8b83eea51 | ||
|
|
88398e8bda | ||
|
|
6d3489d30b | ||
|
|
7a84c93843 | ||
|
|
cba52f4e5d | ||
|
|
4d477067c3 | ||
|
|
9f1fa0a0a2 | ||
|
|
fe84cc07a1 | ||
|
|
d4e42cf726 | ||
|
|
998c94bee3 | ||
|
|
466512c90e | ||
|
|
4db071e281 | ||
|
|
2649c5e506 | ||
|
|
505de825db | ||
|
|
1f6511d0f3 | ||
|
|
1523c118d8 | ||
|
|
9239089b76 | ||
|
|
281ab9759c | ||
|
|
febad05c78 | ||
|
|
519edfac19 | ||
|
|
6561df77f6 | ||
|
|
cd7f177104 | ||
|
|
07ab7ccfc1 | ||
|
|
727f6c932a | ||
|
|
cd1f58ef72 | ||
|
|
291aae00e8 | ||
|
|
bec8cce82d | ||
|
|
0ee2425d40 | ||
|
|
7f3bd901bc | ||
|
|
d308df6167 | ||
|
|
5ded675a7d | ||
|
|
564162a0b0 | ||
|
|
8b5a27ed3f | ||
|
|
c0505424bd | ||
|
|
137045e321 | ||
|
|
4a18a4bde4 | ||
|
|
f6b467eba9 | ||
|
|
23d2146066 | ||
|
|
5f8afb17fc | ||
|
|
f7bb1ceb79 | ||
|
|
b9494af976 | ||
|
|
d1ead6a5fa | ||
|
|
3511d556ef | ||
|
|
4ff7118db7 | ||
|
|
da9950c75b | ||
|
|
626068da3a | ||
|
|
4bf633e8a9 | ||
|
|
f4d6f9c0ed | ||
|
|
93e61ef936 | ||
|
|
28e3286e40 | ||
|
|
aef4186de4 | ||
|
|
e8cb6ae83b | ||
|
|
c66554126d | ||
|
|
4a1c5fc54c | ||
|
|
a1b32304a3 | ||
|
|
7934a0b180 | ||
|
|
992c63eefb | ||
|
|
a967ca4340 | ||
|
|
c0b560527f | ||
|
|
557a827383 | ||
|
|
e06134f71a | ||
|
|
330314327a | ||
|
|
012a3cf908 | ||
|
|
0e03cac368 | ||
|
|
a48fcf0d16 | ||
|
|
2eb3de2748 | ||
|
|
67511d9749 | ||
|
|
df90f62d35 | ||
|
|
efa8df83b3 | ||
|
|
58422bcc19 | ||
|
|
5c14d18141 | ||
|
|
0f8e3a9b09 | ||
|
|
4ce1eb6b0a | ||
|
|
47d249e7dd | ||
|
|
ea6ebae264 | ||
|
|
d878f3bed2 | ||
|
|
ec5fc9d5e4 | ||
|
|
99948fdf3f | ||
|
|
73ad5f1dbb | ||
|
|
6e26990ad3 | ||
|
|
ffb6594600 | ||
|
|
4b4950d7c4 | ||
|
|
1d693cb3c3 | ||
|
|
23e3cf465c | ||
|
|
0d3ca74bd0 | ||
|
|
f69088012b | ||
|
|
154aaa2051 | ||
|
|
1ab273641e | ||
|
|
785d579f47 | ||
|
|
3fe8aa76b2 | ||
|
|
dc75e77eb1 | ||
|
|
94e8c85fd0 | ||
|
|
28c30923f4 | ||
|
|
2b74cfae4a | ||
|
|
60a4edaef9 | ||
|
|
5096b41100 | ||
|
|
e9eaa66542 | ||
|
|
2c2aa41619 | ||
|
|
f164427cba | ||
|
|
b77301b6eb | ||
|
|
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 | ||
|
|
b8f0cde900 | ||
|
|
c4348be8d2 | ||
|
|
a34a41e1b5 | ||
|
|
22cab3eeb3 | ||
|
|
9b6faeb8cf | ||
|
|
8cfc13fe2c | ||
|
|
a7e3bcf79f | ||
|
|
e7f5201c1f | ||
|
|
f507a067cd | ||
|
|
28f906c37d | ||
|
|
f8f2f904bc | ||
|
|
ce848af067 | ||
|
|
d3be8dbf05 | ||
|
|
152334eee3 | ||
|
|
67e0d54062 | ||
|
|
53cb3a19d1 | ||
|
|
84390f6dc1 | ||
|
|
9d191c28f2 | ||
|
|
5d42108a98 | ||
|
|
1972ef3bef | ||
|
|
e8145b07fa | ||
|
|
5e7295a631 | ||
|
|
761a770174 | ||
|
|
7f0a61954b | ||
|
|
a8903cba07 | ||
|
|
00df6c646f | ||
|
|
1f040d06eb | ||
|
|
d13b478ca2 | ||
|
|
91de744250 | ||
|
|
1654cb51db | ||
|
|
f105583803 | ||
|
|
fa940d5b6b | ||
|
|
bcdbc252fe | ||
|
|
186198afe6 | ||
|
|
a77639022c | ||
|
|
fcc7a818b4 | ||
|
|
a3e581b56f | ||
|
|
4c4fcedf40 | ||
|
|
7e68f1ed32 | ||
|
|
9ebccd6a59 | ||
|
|
0f565f4e4c | ||
|
|
44850bdd55 | ||
|
|
33e81f464e |
No files matched your search
+10
-1
@@ -1,2 +1,11 @@
|
||||
#!/bin/sh
|
||||
exec python3 tools/check_public_tree.py
|
||||
# Git for Windows puts a `python3` on PATH that is only the Microsoft Store
|
||||
# stub: it prints "Python" and exits non-zero, so the hook fails with no other
|
||||
# output. Use the first name that actually runs Python 3.
|
||||
for py in python3 python py; do
|
||||
if "$py" -c "import sys; sys.exit(sys.version_info < (3,))" >/dev/null 2>&1; then
|
||||
exec "$py" tools/check_public_tree.py
|
||||
fi
|
||||
done
|
||||
echo "check_public_tree: no working Python 3 found" >&2
|
||||
exit 1
|
||||
+10
-1
@@ -1,2 +1,11 @@
|
||||
#!/bin/sh
|
||||
exec python3 tools/check_public_tree.py --pre-push
|
||||
# Git for Windows puts a `python3` on PATH that is only the Microsoft Store
|
||||
# stub: it prints "Python" and exits non-zero, so the hook fails with no other
|
||||
# output. Use the first name that actually runs Python 3.
|
||||
for py in python3 python py; do
|
||||
if "$py" -c "import sys; sys.exit(sys.version_info < (3,))" >/dev/null 2>&1; then
|
||||
exec "$py" tools/check_public_tree.py --pre-push
|
||||
fi
|
||||
done
|
||||
echo "check_public_tree: no working Python 3 found" >&2
|
||||
exit 1
|
||||
@@ -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; }
|
||||
|
||||
@@ -2,10 +2,10 @@ 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
|
||||
# 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. Launchers install it only for players
|
||||
# who turn Nightly on. 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 +13,11 @@ run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.req
|
||||
# request that changes a path CODEOWNERS gives to Ludovic (this folder, tools/ci/, the signing and update code),
|
||||
# pushes the merge as nightly/build-<date>-<n> and saves the set on the nightly-state branch.
|
||||
# - This file, game-build.yml and every script below run from main's copy: a schedule runs the default branch, and
|
||||
# `uses: ./…` takes the caller's commit. The union's own .github/ is never read.
|
||||
# `uses: ./…` takes the caller's commit. A picked pull request's own .github/ is never read.
|
||||
# - `build` (game-build.yml, `trusted: false`) never reaches a release's tools: its macOS jobs run on the runner
|
||||
# that already runs pull requests' parity jobs, never the release runner; its Rust cache has its own key and is
|
||||
# never saved; and releases don't read the machine's shared sccache.
|
||||
# - `build` (game-build.yml) checks out and compiles the merge (or the union) with no secrets: its token reads contents only and
|
||||
# - `build` (game-build.yml) checks out and compiles the merge with no secrets: its token reads contents only and
|
||||
# isn't kept in the checkout; the macOS programs are signed ad hoc (Developer ID needs the runner's keychain,
|
||||
# which stays locked for nightlies).
|
||||
# - `sign` is the only job with RELEASE_SIGNING_KEY. It checks out nothing but main's tools/ci/sign_release.sh, which
|
||||
@@ -25,6 +25,9 @@ run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.req
|
||||
# names the channel ("horizon release nightly"), so a nightly's signature can't pass for a release's.
|
||||
# - `publish` verifies the signature with main's horizon-release-sign, checks the smoke test, and only then moves
|
||||
# the tag. Like sign, it never executes what build produced.
|
||||
# - The source ships with the build: `pick` writes horizon-source.tar.gz with `git archive` (git objects again, nothing
|
||||
# run), `sign` signs it with the builds, and `publish` attaches it and tags the commit nightly-source/<date>-<short>
|
||||
# for good (the dated release and the build branch go after 7 days; native mods pin the commit: docs/native-mods.md).
|
||||
# gh workflow run "nightly build" # now (skips when the pick is the same commit as last night's)
|
||||
# gh workflow run "nightly build" -f force=true # build and publish even if unchanged
|
||||
# gh workflow run "nightly build" -f dry_run=true # build and sign with a throwaway key, publish nothing
|
||||
@@ -53,6 +56,10 @@ 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: ''
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
@@ -68,6 +75,7 @@ jobs:
|
||||
contents: write # nightly/build-<date>-<n> and the nightly-state branch (main is protected)
|
||||
issues: write # says in the thread when the picked pull requests conflict
|
||||
pull-requests: write
|
||||
actions: read # main's newest green checks and parity-main runs (the base)
|
||||
outputs:
|
||||
build: ${{ steps.pick.outputs.build }}
|
||||
sha: ${{ steps.pick.outputs.sha }}
|
||||
@@ -76,6 +84,7 @@ jobs:
|
||||
date: ${{ steps.pick.outputs.date }}
|
||||
version: ${{ steps.pick.outputs.version }}
|
||||
nightly: ${{ steps.pick.outputs.nightly }}
|
||||
source_tag: ${{ steps.pick.outputs.source_tag }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -92,6 +101,7 @@ jobs:
|
||||
DRY_RUN: ${{ steps.mode.outputs.dry_run }}
|
||||
NIGHTLY_ISSUE: ${{ inputs.issue }}
|
||||
NIGHTLY_PRS: ${{ inputs.prs }}
|
||||
NIGHTLY_BASE: ${{ inputs.base }}
|
||||
run: |
|
||||
python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
{ echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -103,6 +113,18 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
{ echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
- name: The source of the build (horizon-source.tar.gz)
|
||||
if: steps.pick.outputs.build == 'true'
|
||||
env:
|
||||
SHA: ${{ steps.pick.outputs.sha }}
|
||||
run: git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: steps.pick.outputs.build == 'true'
|
||||
with:
|
||||
name: horizon-source
|
||||
path: horizon-source.tar.gz
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nightly-notes
|
||||
@@ -144,7 +166,7 @@ jobs:
|
||||
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
|
||||
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
@@ -164,6 +186,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
issues: read # the open release-blocker issues (gate)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -178,15 +201,24 @@ jobs:
|
||||
run: |
|
||||
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
|
||||
cargo run --locked --quiet -p horizon-release-sign -- verify --tag nightly --publishing "${key[@]}" dist
|
||||
- name: The smoke test drew a world
|
||||
# No release-blocker check: a nightly is how blockers get tested.
|
||||
run: python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json
|
||||
- name: Ready to publish (the smoke test drew a world, no release-blocker is open)
|
||||
# Right before publishing, as for a release: an issue labelled while the builds ran still holds it. A dry run
|
||||
# only reports what a real run would do (that is how a blocker's fix is tried).
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DRY_RUN: ${{ needs.pick.outputs.dry_run }}
|
||||
run: |
|
||||
if ! python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json --blockers; then
|
||||
[[ "$DRY_RUN" == true ]] || { echo "::error::Nothing published"; exit 1; }
|
||||
echo "::warning::A real run would publish nothing"
|
||||
fi
|
||||
- name: Publish as the `nightly` pre-release
|
||||
if: needs.pick.outputs.dry_run != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SHA: ${{ needs.pick.outputs.sha }}
|
||||
DATE: ${{ needs.pick.outputs.date }}
|
||||
SOURCE_TAG: ${{ needs.pick.outputs.source_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 tools/ci/nightly.py rotate
|
||||
@@ -198,7 +230,9 @@ jobs:
|
||||
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
python3 tools/ci/nightly.py keep-source "$SHA" "$DATE"
|
||||
python3 tools/ci/nightly.py prune --days 7
|
||||
- name: Dry run, the nightly that would be published
|
||||
if: needs.pick.outputs.dry_run == 'true'
|
||||
|
||||
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.
|
||||
|
||||
@@ -97,6 +97,9 @@ impl Report {
|
||||
for r in &f.requires {
|
||||
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
|
||||
}
|
||||
if self.kind == Kind::NativeMod {
|
||||
out += " native source: unsandboxed; not executed by the registry check. Compile and test against the exact pinned Horizon and Mixins revisions before review.\n";
|
||||
}
|
||||
if self.kind == Kind::Mod && !self.code {
|
||||
out += " no code: content only, nothing to run\n";
|
||||
} else if self.kind == Kind::Mod {
|
||||
@@ -118,6 +121,7 @@ impl Report {
|
||||
pub fn kind_of(path: &Path) -> Option<Kind> {
|
||||
match path.extension()?.to_str()? {
|
||||
"hmod" => Some(Kind::Mod),
|
||||
"hnmod" => Some(Kind::NativeMod),
|
||||
"hpack" => Some(Kind::Modpack),
|
||||
"zip" => Some(Kind::ResourcePack),
|
||||
"hshader" => Some(Kind::Shader),
|
||||
|
||||
@@ -195,6 +195,11 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
Some("copy_to_clipboard") => {
|
||||
text("value").map(crate::text::ClickEvent::CopyToClipboard)
|
||||
}
|
||||
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
|
||||
// the dialog itself; the server may send it (isAllowedFromServer).
|
||||
Some("show_dialog") => e.get("dialog").map(|dialog| {
|
||||
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ pub enum ClickEvent {
|
||||
/// `ClickEvent.RunCommand`: the command, with or without its `/`.
|
||||
RunCommand(String),
|
||||
CopyToClipboard(String),
|
||||
/// `ClickEvent.ShowDialog`: a `minecraft:dialog` registry id, or the
|
||||
/// dialog itself as the component carried it.
|
||||
ShowDialog(horizon_world::nbt::Tag),
|
||||
/// A local file to open with the system handler.
|
||||
/// Only allow this in locally constructed messages, never server/LAN text.
|
||||
OpenFile(String),
|
||||
@@ -557,6 +560,11 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
ClickEvent::RunCommand(command) => {
|
||||
json!({ "action": "run_command", "command": command })
|
||||
}
|
||||
// ClickEvent.ShowDialog.CODEC: the dialog the server sent it with.
|
||||
ClickEvent::ShowDialog(dialog) => json!({
|
||||
"action": "show_dialog",
|
||||
"dialog": crate::scoreboard::tag_to_json(dialog),
|
||||
}),
|
||||
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
|
||||
};
|
||||
out.push(("click_event".to_owned(), event));
|
||||
|
||||
@@ -324,6 +324,24 @@ pub fn ease_in_out(t: f64) -> f64 {
|
||||
0.5 - 0.5 * (std::f64::consts::PI * t).cos()
|
||||
}
|
||||
|
||||
/// `MouseHandler.grabMouse` / `releaseMouse`: the mouse position is the
|
||||
/// window's centre whenever the pointer's grab state changes, so a screen
|
||||
/// opened from gameplay starts with the cursor in the middle. A release of
|
||||
/// an already free pointer (or a grab of an already grabbed one) leaves the
|
||||
/// position where it is, as vanilla does.
|
||||
pub fn mouse_position_after_grab_change(
|
||||
grab: bool,
|
||||
was_grabbed: bool,
|
||||
size: [u32; 2],
|
||||
scale: f32,
|
||||
) -> Option<(f32, f32)> {
|
||||
if grab == was_grabbed {
|
||||
None
|
||||
} else {
|
||||
Some((size[0] as f32 / 2.0 / scale, size[1] as f32 / 2.0 / scale))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -485,4 +503,27 @@ mod tests {
|
||||
assert_eq!(look.on_scroll(0.0, 0.4, false, 1.0), None);
|
||||
assert_eq!(look.on_scroll(0.0, 0.7, false, 1.0), Some((1, (0, 1))));
|
||||
}
|
||||
#[test]
|
||||
fn the_mouse_position_is_the_centre_when_the_grab_changes() {
|
||||
// A grab of a free pointer and a release of a grabbed one both put
|
||||
// the position at the window's centre (`MouseHandler.grabMouse`,
|
||||
// `MouseHandler.releaseMouse`).
|
||||
assert_eq!(
|
||||
mouse_position_after_grab_change(true, false, [1920, 1080], 1.0),
|
||||
Some((960.0, 540.0))
|
||||
);
|
||||
assert_eq!(
|
||||
mouse_position_after_grab_change(false, true, [1920, 1080], 2.0),
|
||||
Some((480.0, 270.0))
|
||||
);
|
||||
// Without a change of the grab state the position stays where it is.
|
||||
assert_eq!(
|
||||
mouse_position_after_grab_change(false, false, [1920, 1080], 1.0),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
mouse_position_after_grab_change(true, true, [1920, 1080], 1.0),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,8 @@ unicode-bidi = "0.3.18"
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Use the registered URI handler without routing links through Explorer/cmd.
|
||||
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
|
||||
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
|
||||
# on the PATH.
|
||||
|
||||
@@ -161,6 +161,7 @@ pub fn main() -> Result<()> {
|
||||
"sound {} {:08x} {:08x}",
|
||||
match sound {
|
||||
ArrowSound::HitGround => "hit",
|
||||
ArrowSound::CrossbowHit => "crossbow_hit",
|
||||
ArrowSound::Splash => "splash",
|
||||
ArrowSound::Extinguish => "extinguish",
|
||||
ArrowSound::TridentHitGround => "trident_hit_ground",
|
||||
|
||||
@@ -369,6 +369,7 @@ pub fn main() -> Result<()> {
|
||||
banner: &|_| layers.clone(),
|
||||
skull: &|_| None,
|
||||
glyph: &|c| atlas.glyph_cell(c),
|
||||
glyphs: atlas.font.epoch(),
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
renderer.render_block(&scene, pos, state, Mat4::IDENTITY, packed, &mut verts);
|
||||
|
||||
@@ -87,7 +87,11 @@ pub fn main() {
|
||||
}
|
||||
"tick" => {
|
||||
out.push_str("tick");
|
||||
for (p, _) in entities.tickers(|p| states.get(&p).copied().unwrap_or(0)) {
|
||||
for (p, _) in entities
|
||||
.tickers(|p| states.get(&p).copied().unwrap_or(0))
|
||||
.iter()
|
||||
.copied()
|
||||
{
|
||||
out.push_str(&format!(" {},{},{}", p.0, p.1, p.2));
|
||||
}
|
||||
out.push('\n');
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use horizon_client::{
|
||||
containers::{Furnace, FurnaceKind},
|
||||
crafting::Recipes,
|
||||
furnace::FurnaceCooking,
|
||||
item_data::{canon_stack, parse_stack},
|
||||
items::Items,
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -20,12 +20,68 @@ pub struct SpawnEntry {
|
||||
/// A `ConstantInt` count (sampling draws nothing).
|
||||
pub constant: bool,
|
||||
}
|
||||
/// A `natural_mob_spawns` / `spawn_overrides` entry list.
|
||||
pub fn parse_entries(list: &Value) -> Vec<SpawnEntry> {
|
||||
list.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let count = &e["count"];
|
||||
let (min, max, constant) = match count {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64().unwrap_or(1) as u32;
|
||||
(n, n, true)
|
||||
}
|
||||
c if c["type"].as_str() == Some("minecraft:constant") => {
|
||||
let n = c["value"].as_u64().unwrap_or(1) as u32;
|
||||
(n, n, true)
|
||||
}
|
||||
c => (
|
||||
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
false,
|
||||
),
|
||||
};
|
||||
Some(SpawnEntry {
|
||||
entity: e["type"].as_str()?.to_owned(),
|
||||
weight: e["weight"].as_u64().unwrap_or(1) as u32,
|
||||
min,
|
||||
max,
|
||||
constant,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `MobSpawnSettings.MobSpawnCost`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SpawnCost {
|
||||
pub energy_budget: f64,
|
||||
pub charge: f64,
|
||||
}
|
||||
/// The `spawn_costs` of a `natural_mob_spawns` value.
|
||||
pub fn parse_costs(costs: &Value) -> HashMap<String, SpawnCost> {
|
||||
costs
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(k, c)| {
|
||||
(
|
||||
k.clone(),
|
||||
SpawnCost {
|
||||
energy_budget: c["energy_budget"].as_f64().unwrap_or(0.0),
|
||||
charge: c["charge"].as_f64().unwrap_or(0.0),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BiomeInfo {
|
||||
/// `natural_mob_spawns` by category (creature, monster, ...).
|
||||
pub spawns: HashMap<String, Vec<SpawnEntry>>,
|
||||
/// `natural_mob_spawns` spawn costs by entity type.
|
||||
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
|
||||
pub spawn_costs: HashMap<String, SpawnCost>,
|
||||
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
|
||||
pub creature_probability: f32,
|
||||
/// The `gameplay/increased_fire_burnout` attribute (jungles, swamps, peaks,
|
||||
@@ -150,12 +206,12 @@ impl Biomes {
|
||||
};
|
||||
let categories = &natural["spawns_by_category"];
|
||||
for (category, entries) in categories.as_object().into_iter().flatten() {
|
||||
let list = crate::mobs::spawn_selection::parse_entries(entries);
|
||||
let list = parse_entries(entries);
|
||||
spawns.insert(category.clone(), list);
|
||||
}
|
||||
list.push(BiomeInfo {
|
||||
spawns,
|
||||
spawn_costs: crate::mobs::spawn_selection::parse_costs(&natural["spawn_costs"]),
|
||||
spawn_costs: parse_costs(&natural["spawn_costs"]),
|
||||
creature_probability:
|
||||
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
|
||||
.as_f64()
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
use crate::chunk::block_entity::BlockEntity;
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub type Pos = (i32, i32, i32);
|
||||
|
||||
@@ -46,7 +47,7 @@ pub enum Ticking {
|
||||
pub struct BlockEntities {
|
||||
map: HashMap<Pos, BlockEntity>,
|
||||
/// `Level.blockEntityTickers`: positions and the slot each was given.
|
||||
tickers: Vec<(Pos, u32)>,
|
||||
tickers: Arc<Vec<(Pos, u32)>>,
|
||||
/// `LevelChunk.tickersInLevel`: the live slot of each ticking position.
|
||||
live: FxHashMap<Pos, u32>,
|
||||
/// Positions that may need a slot, in the order vanilla would have made
|
||||
@@ -233,7 +234,7 @@ impl BlockEntities {
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.map.clear();
|
||||
self.tickers.clear();
|
||||
self.tickers = Arc::default();
|
||||
self.live.clear();
|
||||
self.added.clear();
|
||||
self.pending.clear();
|
||||
@@ -274,7 +275,7 @@ impl BlockEntities {
|
||||
/// and freed ones leave it. `state` reads the block state at a position.
|
||||
/// Returns the slots to tick, in order; check each with `is_live` before
|
||||
/// ticking it, as the ticks before it can free it.
|
||||
pub fn tickers(&mut self, state: impl Fn(Pos) -> u32) -> Vec<(Pos, u32)> {
|
||||
pub fn tickers(&mut self, state: impl Fn(Pos) -> u32) -> Arc<Vec<(Pos, u32)>> {
|
||||
for (pos, token) in std::mem::take(&mut self.added) {
|
||||
if self.pending.get(&pos) != Some(&token) {
|
||||
continue;
|
||||
@@ -288,21 +289,21 @@ impl BlockEntities {
|
||||
}
|
||||
let slot = self.next();
|
||||
self.live.insert(pos, slot);
|
||||
self.tickers.push((pos, slot));
|
||||
Arc::make_mut(&mut self.tickers).push((pos, slot));
|
||||
}
|
||||
if !std::mem::take(&mut self.freed) && self.out.is_empty() {
|
||||
return self.tickers.clone();
|
||||
return Arc::clone(&self.tickers);
|
||||
}
|
||||
self.out.clear();
|
||||
let (live, map) = (&mut self.live, &self.map);
|
||||
self.tickers.retain(|(p, slot)| {
|
||||
Arc::make_mut(&mut self.tickers).retain(|(p, slot)| {
|
||||
let mine = live.get(p) == Some(slot);
|
||||
if mine && !map.contains_key(p) {
|
||||
live.remove(p);
|
||||
}
|
||||
mine && map.contains_key(p)
|
||||
});
|
||||
self.tickers.clone()
|
||||
Arc::clone(&self.tickers)
|
||||
}
|
||||
/// Whether a slot from `tickers` still ticks.
|
||||
pub fn is_live(&self, (pos, slot): (Pos, u32)) -> bool {
|
||||
@@ -329,8 +330,8 @@ mod tests {
|
||||
}
|
||||
fn order(b: &mut BlockEntities, states: &HashMap<Pos, u32>) -> Vec<Pos> {
|
||||
b.tickers(|p| states.get(&p).copied().unwrap_or(0))
|
||||
.into_iter()
|
||||
.map(|(p, _)| p)
|
||||
.iter()
|
||||
.map(|(p, _)| *p)
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -435,7 +435,8 @@ pub struct BellTick {
|
||||
pub ended: Option<(Vec<([f64; 3], i32)>, Vec<u64>)>,
|
||||
}
|
||||
|
||||
fn closer_to_center_than(pos: [i32; 3], p: [f64; 3], distance: f64) -> bool {
|
||||
/// `Vec3i.closerToCenterThan`: `p` is within `distance` of the block's centre.
|
||||
pub fn closer_to_center_than(pos: [i32; 3], p: [f64; 3], distance: f64) -> bool {
|
||||
let dx = f64::from(pos[0]) + 0.5 - p[0];
|
||||
let dy = f64::from(pos[1]) + 0.5 - p[1];
|
||||
let dz = f64::from(pos[2]) + 0.5 - p[2];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,8 @@ pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
|
||||
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
|
||||
};
|
||||
use crate::{
|
||||
crafting::Recipes,
|
||||
items::{ItemStack, Items},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
|
||||
impl FurnaceKind {
|
||||
pub fn recipe_type(self) -> &'static str {
|
||||
@@ -380,167 +377,6 @@ impl BlockEntity {
|
||||
}
|
||||
}
|
||||
pub use crate::providers::Providers;
|
||||
fn remainder(item: &str) -> Option<&'static str> {
|
||||
match item {
|
||||
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
|
||||
Some("minecraft:bucket")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
impl Furnace {
|
||||
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
|
||||
/// the same item with the same components restarts cooking, with the
|
||||
/// total time of the new input's recipe (200 ticks without one).
|
||||
pub fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
|
||||
let same = match (&self.items[0], &stack) {
|
||||
(Some(old), Some(new)) => old.same_item_same_components(new),
|
||||
_ => false,
|
||||
};
|
||||
self.items[0] = stack;
|
||||
if !same {
|
||||
self.cooking_total = self.items[0]
|
||||
.as_ref()
|
||||
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
|
||||
.map_or(200, |recipe| total_time(recipe.time, self.speed));
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
|
||||
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
|
||||
pub fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
|
||||
self.recipes_used
|
||||
.iter()
|
||||
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
|
||||
.collect()
|
||||
}
|
||||
/// A menu click wrote its slots into `items`, and `before` was the input
|
||||
/// slot until then: the menu called `setItem` on it only if it changed
|
||||
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
|
||||
pub fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
|
||||
if self.items[0] != before {
|
||||
let after = std::mem::replace(&mut self.items[0], before);
|
||||
self.set_input(recipes, after);
|
||||
}
|
||||
}
|
||||
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
recipes: &Recipes,
|
||||
items: &Items,
|
||||
providers: &Providers,
|
||||
) -> Option<bool> {
|
||||
let was_lit = self.lit_remaining > 0;
|
||||
let mut lit = false;
|
||||
if self.lit_remaining > 0 {
|
||||
self.lit_remaining -= 1;
|
||||
lit = self.lit_remaining > 0;
|
||||
}
|
||||
let fast = self.kind.fast();
|
||||
let has_ingredient = self.items[0].is_some();
|
||||
let has_fuel = self.items[1].is_some();
|
||||
if lit || has_fuel && has_ingredient {
|
||||
if let Some(ingredient) = self.items[0].clone() {
|
||||
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
|
||||
if self.cooking_timer == 0 {
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
}
|
||||
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
|
||||
let can_burn = match &self.items[2] {
|
||||
None => true,
|
||||
Some(out) => {
|
||||
out.item == recipe.result.item && out.count + recipe.result.count <= max
|
||||
}
|
||||
};
|
||||
if can_burn {
|
||||
if !lit {
|
||||
let fuel = self.items[1].clone();
|
||||
let time = fuel
|
||||
.as_ref()
|
||||
.map_or(0, |f| providers.burn_time(items, f, fast));
|
||||
let speed = fuel
|
||||
.as_ref()
|
||||
.map_or(1.0, |f| providers.speed(items, f, fast));
|
||||
self.lit_remaining = time;
|
||||
self.lit_total = time;
|
||||
self.speed = speed;
|
||||
if self.cooking_total > 0 && self.cooking_timer < self.cooking_total {
|
||||
let ratio = self.cooking_timer as f32 / self.cooking_total as f32;
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
self.cooking_timer =
|
||||
(ratio * self.cooking_total as f32).ceil() as i32;
|
||||
}
|
||||
if time > 0 {
|
||||
if let Some(f) = self.items[1].as_mut() {
|
||||
let item = f.item.clone();
|
||||
f.count -= 1;
|
||||
if f.count == 0 {
|
||||
self.items[1] = remainder(&item).map(|r| ItemStack {
|
||||
item: Arc::from(r),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
lit = true;
|
||||
}
|
||||
}
|
||||
if lit {
|
||||
self.cooking_timer += 1;
|
||||
if self.cooking_timer >= self.cooking_total {
|
||||
self.cooking_timer = 0;
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
match &mut self.items[2] {
|
||||
Some(out) => out.count += recipe.result.count,
|
||||
None => self.items[2] = Some(recipe.result.clone()),
|
||||
}
|
||||
*self
|
||||
.recipes_used
|
||||
.entry(recipe_used_key(&recipe.id))
|
||||
.or_insert(0) += 1;
|
||||
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
|
||||
// the bucket in the fuel slot.
|
||||
if self.items[0]
|
||||
.as_ref()
|
||||
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
|
||||
&& self.items[1]
|
||||
.as_ref()
|
||||
.is_some_and(|f| &*f.item == "minecraft:bucket")
|
||||
{
|
||||
self.items[1] =
|
||||
Some(ItemStack::new("minecraft:water_bucket", 1));
|
||||
}
|
||||
if let Some(i) = self.items[0].as_mut() {
|
||||
i.count -= 1;
|
||||
if i.count == 0 {
|
||||
self.items[0] = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else if self.cooking_timer > 0 {
|
||||
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
|
||||
}
|
||||
(was_lit != lit).then_some(lit)
|
||||
}
|
||||
}
|
||||
fn total_time(time: u32, speed: f32) -> i32 {
|
||||
if speed > 0.0 {
|
||||
(time as f32 / speed).ceil() as i32
|
||||
} else {
|
||||
time as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
|
||||
/// more with the chance of what is left over (a level random draw only when there is some).
|
||||
|
||||
@@ -326,16 +326,18 @@ fn panic_message(info: &std::panic::PanicHookInfo) -> String {
|
||||
/// frames go. Paths inside the workspace lose the build machine's prefix.
|
||||
pub fn stack_lines(backtrace: &str, from_panic: bool) -> String {
|
||||
// The workspace as it was on the machine that built this binary (it need not exist here).
|
||||
let manifest = env!("CARGO_MANIFEST_DIR");
|
||||
// A report names workspace files the same way on every host, whatever
|
||||
// separator the build machine used.
|
||||
let manifest = env!("CARGO_MANIFEST_DIR").replace('\\', "/");
|
||||
let root = manifest
|
||||
.strip_suffix("crates/horizon-client")
|
||||
.unwrap_or(manifest);
|
||||
.unwrap_or(&manifest);
|
||||
let mut frames: Vec<(String, Option<String>)> = Vec::new();
|
||||
for line in backtrace.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(at) = line.strip_prefix("at ") {
|
||||
if let Some(frame) = frames.last_mut() {
|
||||
frame.1 = Some(short_path(at, root).to_owned());
|
||||
frame.1 = Some(short_path(&at.replace('\\', "/"), root).to_owned());
|
||||
}
|
||||
} else if let Some((n, name)) = line.split_once(": ")
|
||||
&& n.chars().all(|c| c.is_ascii_digit())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! `AbstractFurnaceBlockEntity`'s cooking over the furnace data the chunk
|
||||
//! holds (`containers::Furnace`): it needs the recipes, so it lives above them.
|
||||
use crate::{
|
||||
containers::{Furnace, recipe_used_key},
|
||||
crafting::Recipes,
|
||||
items::{ItemStack, Items},
|
||||
providers::Providers,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The furnace methods that read the recipes.
|
||||
pub trait FurnaceCooking {
|
||||
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
|
||||
/// the same item with the same components restarts cooking, with the
|
||||
/// total time of the new input's recipe (200 ticks without one).
|
||||
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>);
|
||||
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
|
||||
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
|
||||
fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)>;
|
||||
/// A menu click wrote its slots into `items`, and `before` was the input
|
||||
/// slot until then: the menu called `setItem` on it only if it changed
|
||||
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
|
||||
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>);
|
||||
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
|
||||
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool>;
|
||||
}
|
||||
fn remainder(item: &str) -> Option<&'static str> {
|
||||
match item {
|
||||
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
|
||||
Some("minecraft:bucket")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
impl FurnaceCooking for Furnace {
|
||||
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
|
||||
let same = match (&self.items[0], &stack) {
|
||||
(Some(old), Some(new)) => old.same_item_same_components(new),
|
||||
_ => false,
|
||||
};
|
||||
self.items[0] = stack;
|
||||
if !same {
|
||||
self.cooking_total = self.items[0]
|
||||
.as_ref()
|
||||
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
|
||||
.map_or(200, |recipe| total_time(recipe.time, self.speed));
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
|
||||
self.recipes_used
|
||||
.iter()
|
||||
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
|
||||
.collect()
|
||||
}
|
||||
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
|
||||
if self.items[0] != before {
|
||||
let after = std::mem::replace(&mut self.items[0], before);
|
||||
self.set_input(recipes, after);
|
||||
}
|
||||
}
|
||||
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool> {
|
||||
let was_lit = self.lit_remaining > 0;
|
||||
let mut lit = false;
|
||||
if self.lit_remaining > 0 {
|
||||
self.lit_remaining -= 1;
|
||||
lit = self.lit_remaining > 0;
|
||||
}
|
||||
let fast = self.kind.fast();
|
||||
let has_ingredient = self.items[0].is_some();
|
||||
let has_fuel = self.items[1].is_some();
|
||||
if lit || has_fuel && has_ingredient {
|
||||
if let Some(ingredient) = self.items[0].clone() {
|
||||
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
|
||||
if self.cooking_timer == 0 {
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
}
|
||||
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
|
||||
let can_burn = match &self.items[2] {
|
||||
None => true,
|
||||
Some(out) => {
|
||||
out.item == recipe.result.item && out.count + recipe.result.count <= max
|
||||
}
|
||||
};
|
||||
if can_burn {
|
||||
if !lit {
|
||||
let fuel = self.items[1].clone();
|
||||
let time = fuel
|
||||
.as_ref()
|
||||
.map_or(0, |f| providers.burn_time(items, f, fast));
|
||||
let speed = fuel
|
||||
.as_ref()
|
||||
.map_or(1.0, |f| providers.speed(items, f, fast));
|
||||
self.lit_remaining = time;
|
||||
self.lit_total = time;
|
||||
self.speed = speed;
|
||||
if self.cooking_total > 0 && self.cooking_timer < self.cooking_total {
|
||||
let ratio = self.cooking_timer as f32 / self.cooking_total as f32;
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
self.cooking_timer =
|
||||
(ratio * self.cooking_total as f32).ceil() as i32;
|
||||
}
|
||||
if time > 0 {
|
||||
if let Some(f) = self.items[1].as_mut() {
|
||||
let item = f.item.clone();
|
||||
f.count -= 1;
|
||||
if f.count == 0 {
|
||||
self.items[1] = remainder(&item).map(|r| ItemStack {
|
||||
item: Arc::from(r),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
lit = true;
|
||||
}
|
||||
}
|
||||
if lit {
|
||||
self.cooking_timer += 1;
|
||||
if self.cooking_timer >= self.cooking_total {
|
||||
self.cooking_timer = 0;
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
match &mut self.items[2] {
|
||||
Some(out) => out.count += recipe.result.count,
|
||||
None => self.items[2] = Some(recipe.result.clone()),
|
||||
}
|
||||
*self
|
||||
.recipes_used
|
||||
.entry(recipe_used_key(&recipe.id))
|
||||
.or_insert(0) += 1;
|
||||
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
|
||||
// the bucket in the fuel slot.
|
||||
if self.items[0]
|
||||
.as_ref()
|
||||
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
|
||||
&& self.items[1]
|
||||
.as_ref()
|
||||
.is_some_and(|f| &*f.item == "minecraft:bucket")
|
||||
{
|
||||
self.items[1] =
|
||||
Some(ItemStack::new("minecraft:water_bucket", 1));
|
||||
}
|
||||
if let Some(i) = self.items[0].as_mut() {
|
||||
i.count -= 1;
|
||||
if i.count == 0 {
|
||||
self.items[0] = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cooking_timer = 0;
|
||||
}
|
||||
} else if self.cooking_timer > 0 {
|
||||
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
|
||||
}
|
||||
(was_lit != lit).then_some(lit)
|
||||
}
|
||||
}
|
||||
fn total_time(time: u32, speed: f32) -> i32 {
|
||||
if speed > 0.0 {
|
||||
(time as f32 / speed).ceil() as i32
|
||||
} else {
|
||||
time as i32
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -222,6 +223,7 @@ use horizon_client::{
|
||||
entities::{Experience, ExperienceOrb, ItemEntity},
|
||||
environment::Environment,
|
||||
first_person::{Hand, SwingAnimation},
|
||||
furnace::FurnaceCooking,
|
||||
gfx::{Frame, Renderer},
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
hud_layout, hud_overlays,
|
||||
@@ -273,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},
|
||||
@@ -1791,6 +1794,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>,
|
||||
@@ -2329,12 +2334,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
|
||||
{
|
||||
@@ -3552,6 +3564,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",
|
||||
@@ -3685,6 +3700,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);
|
||||
}
|
||||
@@ -4012,12 +4028,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 {
|
||||
@@ -4046,11 +4086,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.
|
||||
@@ -4244,6 +4287,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();
|
||||
@@ -4253,9 +4300,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,
|
||||
@@ -5082,6 +5150,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();
|
||||
@@ -5624,6 +5693,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.
|
||||
@@ -5686,6 +5765,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,
|
||||
@@ -5716,13 +5797,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
|
||||
@@ -5766,30 +5857,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
|
||||
@@ -6407,7 +6499,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) {
|
||||
@@ -8016,7 +8108,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| {
|
||||
@@ -8080,6 +8177,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).
|
||||
@@ -8401,7 +8499,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())
|
||||
@@ -10956,7 +11056,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
|
||||
@@ -11066,7 +11170,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_{}_{}_{}",
|
||||
@@ -11102,8 +11206,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 {
|
||||
@@ -11121,7 +11225,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,
|
||||
@@ -11200,15 +11304,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;
|
||||
@@ -12131,7 +12234,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(),
|
||||
@@ -12807,6 +12911,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
|
||||
@@ -12995,6 +13100,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)?,
|
||||
@@ -13273,6 +13384,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),
|
||||
}
|
||||
}
|
||||
@@ -13282,6 +13398,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
|
||||
@@ -13906,6 +14023,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(),
|
||||
@@ -14939,8 +15057,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() {
|
||||
@@ -15297,7 +15433,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 {
|
||||
|
||||
@@ -9,6 +9,7 @@ use horizon_client::{
|
||||
Result,
|
||||
block_entities::{Pos, Ticking},
|
||||
containers::{BlockEntity, FurnaceKind, HOPPER_SLOTS},
|
||||
furnace::FurnaceCooking,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
@@ -23,12 +24,16 @@ impl Game<'_> {
|
||||
let states = self.ticking_states();
|
||||
self.world.block_entities.set_states(states);
|
||||
}
|
||||
self.forget_gone_hoppers();
|
||||
// Only lingering cooldowns are dropped; a hopper that ticks checks
|
||||
// its own block. Once a second is soon enough for the rest.
|
||||
if self.world.game_time % 20 == 0 {
|
||||
self.forget_gone_hoppers();
|
||||
}
|
||||
let ticking = self.block_ticking_area();
|
||||
let mut spawners = Vec::new();
|
||||
let mut trial_spawners = Vec::new();
|
||||
self.item_columns = Some(Default::default());
|
||||
for slot in self.world.block_entity_tickers() {
|
||||
for slot in self.world.block_entity_tickers().iter().copied() {
|
||||
let pos = slot.0;
|
||||
// Ticks before this one can remove it (`TickingBlockEntity.isRemoved`).
|
||||
if !self.world.block_entities.is_live(slot)
|
||||
|
||||
@@ -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).
|
||||
@@ -1210,3 +1223,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() {
|
||||
|
||||
@@ -227,7 +227,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// Whether the level in place has a running task, which the server pumps
|
||||
/// between its ticks (`pump_pregen`).
|
||||
/// between its ticks (`pump_chunks`).
|
||||
pub(in crate::game) fn pregen_runs_here(&self) -> bool {
|
||||
self.cmds
|
||||
.pregen
|
||||
@@ -243,11 +243,19 @@ impl Game<'_> {
|
||||
/// Keep the workers fed until `until`: collect what they finished and
|
||||
/// hand them the next jobs as soon as each one is done, rather than once
|
||||
/// a tick.
|
||||
pub(in crate::game) fn pump_pregen(&mut self, until: Instant) {
|
||||
pub(in crate::game) fn pump_chunks(&mut self, until: Instant, paused: bool) {
|
||||
// Force-loaded chunks too: decorating in vanilla's order, a chunk
|
||||
// waits for its neighbours far more often than once a tick allows.
|
||||
// And the players' chunks while they load, as vanilla's server runs
|
||||
// its chunk tasks while it waits for the next tick
|
||||
// (`MinecraftServer.waitUntilNextTick`): once a tick, a few jobs per
|
||||
// worker (`schedule_generation`) capped joining a world at about 120
|
||||
// columns a second with two workers, the workers idle most of the
|
||||
// time. Not while a respawn search waits for a chunk: its area is
|
||||
// only asked for by the tick's own `update_world`.
|
||||
let players = !paused && self.spawn_wait.is_empty();
|
||||
while self.pregen_runs_here()
|
||||
|| (!self.streaming.forced.is_empty() && self.streaming.busy())
|
||||
|| ((players || !self.streaming.forced.is_empty()) && self.streaming.busy())
|
||||
{
|
||||
let now = Instant::now();
|
||||
if now >= until {
|
||||
|
||||
@@ -343,8 +343,10 @@ impl Game<'_> {
|
||||
// hook does.
|
||||
let action = arg(2)?;
|
||||
self.mod_key(physical, action != "up", action == "repeat");
|
||||
self.native_key_event(physical, action != "up", action == "repeat");
|
||||
if action == "tap" {
|
||||
self.mod_key(physical, false, false);
|
||||
self.native_key_event(physical, false, false);
|
||||
}
|
||||
// Text screens consume raw presses; releases follow key binds.
|
||||
let typing = matches!(self.screen, Screen::Chat | Screen::Options);
|
||||
|
||||
@@ -277,6 +277,19 @@ impl Game<'_> {
|
||||
pub(super) fn spawn_mob(&mut self, kind: &str, at: [f64; 3], persistent: bool) -> Option<u32> {
|
||||
self.spawn_mob_for(kind, at, persistent, false)
|
||||
}
|
||||
/// `SummonCommand` and `SpawnEggItem`: vanilla spawns the mob, but it
|
||||
/// never calls `Mob.setPersistenceRequired` for it, so the mob is not
|
||||
/// persistent: the despawn rule holds it like any other mob, and only
|
||||
/// its NBT can make it persistent.
|
||||
pub(super) fn spawn_mob_spawned(&mut self, kind: &str, at: [f64; 3]) -> Option<u32> {
|
||||
// The spawn is not a natural one (no jockeys, the patrol leader roll
|
||||
// stays), but the mob is not persistent.
|
||||
let id = self.spawn_mob_for(kind, at, true, false)?;
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.persistent = false;
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
/// `spawn_mob` for a patrol or raid (`EntitySpawnReason.PATROL` / `EVENT`),
|
||||
/// which never rolls a patrol leader.
|
||||
pub(super) fn spawn_mob_for(
|
||||
@@ -334,7 +347,7 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let id = self
|
||||
.spawn_mob(kind, at, true)
|
||||
.spawn_mob_spawned(kind, at)
|
||||
.ok_or_else(|| format!("unknown or unsupported mob {kind}"))?;
|
||||
if baby && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.extra.age = if m.species() == Species::Sniffer {
|
||||
@@ -456,6 +469,7 @@ impl Game<'_> {
|
||||
biomes: self.res.biomes(),
|
||||
mob_griefing: self.ui.rules.bool("mob_griefing"),
|
||||
mob_drops: self.ui.rules.bool("mob_drops"),
|
||||
max_entity_cramming: self.ui.rules.int("max_entity_cramming"),
|
||||
end: self.dim == Dim::End,
|
||||
world_seed: self.level.seed,
|
||||
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
|
||||
@@ -732,6 +746,7 @@ impl Game<'_> {
|
||||
self.guardian_bubbles();
|
||||
self.villager_reputation_tick();
|
||||
self.merchant_tick();
|
||||
self.villager_item_sense();
|
||||
self.villager_pickups();
|
||||
for m in dead {
|
||||
self.mob_died(&m);
|
||||
@@ -789,14 +804,30 @@ impl Game<'_> {
|
||||
// takes the experience (before the loot is dropped). It takes what
|
||||
// would drop, whoever killed the mob.
|
||||
self.sculk_mob_died(m);
|
||||
let mut catalyst_xp = 0;
|
||||
let consumed =
|
||||
self.catalyst_takes_experience([m.position.x, m.position.y, m.position.z], |_| {
|
||||
if horizon_client::mobs::drops::drops_experience(&dying) {
|
||||
catalyst_xp = if horizon_client::mobs::drops::drops_experience(&dying) {
|
||||
horizon_client::mobs::drops::base_experience(&mut dying).max(0) as u32
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
catalyst_xp
|
||||
});
|
||||
// CatalystListener: with experience to take, the player who last hurt
|
||||
// the mob gets KILL_MOB_NEAR_SCULK_CATALYST.
|
||||
if consumed && catalyst_xp > 0 {
|
||||
let who = match killer {
|
||||
Some(Some(conn)) => Some(super::commands::Target::Guest(conn)),
|
||||
Some(None) if self.lan_acting.is_none() => Some(super::commands::Target::Player),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(who) = who {
|
||||
self.for_player(who, |g| {
|
||||
g.trigger_player("minecraft:kill_mob_near_sculk_catalyst")
|
||||
});
|
||||
}
|
||||
}
|
||||
if by_player {
|
||||
// A guest's kill is theirs: their statistics, scores and
|
||||
// advancements (LivingEntity.die credits lastHurtByPlayer).
|
||||
@@ -856,7 +887,42 @@ impl Game<'_> {
|
||||
self.stats.custom("mob_kills", 1);
|
||||
self.score_player_killed(super::commands::Target::Mob(m.id));
|
||||
let view = self.mob_entity(m);
|
||||
self.trigger_entity("minecraft:player_killed_entity", "entity", &view);
|
||||
let blow = m.last_damage.map(|(d, _)| self.damage_view(d));
|
||||
self.trigger_killed(&view, blow.as_ref());
|
||||
}
|
||||
/// A mob's damage source as advancement predicates see it: its type, the
|
||||
/// causing entity, and the projectile when one dealt it.
|
||||
fn damage_view(&self, d: Damage) -> horizon_client::advancements::DamageView {
|
||||
let source = match d.attacker {
|
||||
Some(Who::Player) => Some(self.player_entity()),
|
||||
Some(Who::Mob(id)) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map(|o| self.mob_entity(o)),
|
||||
None => None,
|
||||
};
|
||||
let projectile = match d.kind {
|
||||
DamageKind::Arrow => Some("minecraft:arrow"),
|
||||
DamageKind::Fireball => Some("minecraft:fireball"),
|
||||
_ => None,
|
||||
};
|
||||
let direct = match projectile {
|
||||
Some(kind) => Some(horizon_client::advancements::EntityView {
|
||||
kind: kind.into(),
|
||||
position: d.from.unwrap_or_default(),
|
||||
..Default::default()
|
||||
}),
|
||||
None => source.clone(),
|
||||
};
|
||||
horizon_client::advancements::DamageView {
|
||||
kind: damage_type(d.kind, matches!(d.attacker, Some(Who::Player))).to_owned(),
|
||||
// DamageSource.isDirect: the causing entity is the direct one.
|
||||
is_direct: projectile.is_none(),
|
||||
direct,
|
||||
source,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
/// `dropAllDeathLoot` through `mobs::drops`: the entity loot table from the
|
||||
/// world's random sequence, equipment by drop chance, species extras and
|
||||
@@ -1193,14 +1259,18 @@ impl Game<'_> {
|
||||
owner,
|
||||
} => self.add_fangs(at, yaw, delay, owner),
|
||||
MobEvent::Shoot(..) => {}
|
||||
MobEvent::ExplosionSource { mob, charged } => {
|
||||
MobEvent::ExplosionSource { mob, charged, name } => {
|
||||
self.combat.explosion_source = Some(mob);
|
||||
// The creeper causes the blast's damage, but it is out of
|
||||
// the mob list already: its name comes with the event.
|
||||
self.note_named_attacker("minecraft:explosion", mob, name);
|
||||
if charged {
|
||||
self.combat.charged_exploders.push(mob);
|
||||
}
|
||||
}
|
||||
MobEvent::Explode(at, radius, species) => {
|
||||
// The exploding mob causes the damage (Creeper.explodeCreeper).
|
||||
let noted = self.combat.explosion_source.is_some();
|
||||
let source_mob = self.combat.explosion_source.or_else(|| {
|
||||
self.mobs
|
||||
.iter()
|
||||
@@ -1211,7 +1281,7 @@ impl Game<'_> {
|
||||
.map(|m| m.id)
|
||||
.next()
|
||||
});
|
||||
if let Some(id) = source_mob {
|
||||
if !noted && let Some(id) = source_mob {
|
||||
self.note_attacker("minecraft:explosion", id);
|
||||
}
|
||||
self.combat.explosion_source = source_mob;
|
||||
@@ -1279,8 +1349,10 @@ impl Game<'_> {
|
||||
MobEvent::Hiss { mob } => {
|
||||
if let Some(m) = self.mobs.iter().find(|m| m.id == mob) {
|
||||
let at = m.center();
|
||||
let sound = horizon_client::mobs::finalize::variant_sound(m, "hiss")
|
||||
.unwrap_or("entity.cat.hiss");
|
||||
self.audio
|
||||
.play("minecraft:entity.cat.hiss", Some(at), 1.0, 1.0);
|
||||
.play(&format!("minecraft:{sound}"), Some(at), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
MobEvent::Tamed { .. } | MobEvent::Buck { .. } => self.mount_event(&e),
|
||||
@@ -1296,6 +1368,26 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
},
|
||||
MobEvent::Bonemeal { pos } => {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
if self.with_level(|b, level, ticks| b.grow_crop(level, ticks, at)) {
|
||||
self.level_events
|
||||
.push(horizon_world::level::LevelEvent::LevelEvent {
|
||||
id: 1505,
|
||||
pos: at,
|
||||
data: 15,
|
||||
});
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
}
|
||||
MobEvent::RingBell { bell } => {
|
||||
let pos = BlockPos::new(bell[0], bell[1], bell[2]);
|
||||
let rang = self
|
||||
.with_level(|b, level, ticks| b.bell_attempt_to_ring(level, ticks, pos));
|
||||
if rang {
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
}
|
||||
MobEvent::ChestOpen { chest, open } => self.golem_chest_open(chest, open),
|
||||
MobEvent::ChestSlot { chest, slot, stack } => {
|
||||
self.golem_chest_slot(chest, slot, stack)
|
||||
@@ -1857,6 +1949,9 @@ impl Game<'_> {
|
||||
&& let Some(child) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
child.extra.age = -24000;
|
||||
// getBreedOffspring creates the baby without finalizeSpawn: it keeps
|
||||
// the default (`classic`) sound variant.
|
||||
child.extra.sound_variant = None;
|
||||
// A llama's baby was drawn in the parent's tick, with the
|
||||
// experience.
|
||||
if let Some(o) = llama {
|
||||
@@ -1952,8 +2047,12 @@ impl Game<'_> {
|
||||
}
|
||||
// Offspring inherit a parent's variant (pigs/cows/chickens) or mixed wool.
|
||||
child.extra.variant = p.extra.variant;
|
||||
// Fox.getBreedOffspring: either parent's variant.
|
||||
if p.species() == Species::Fox && !coin {
|
||||
// Fox, Pig, Cow and Chicken.getBreedOffspring: either parent's variant.
|
||||
if matches!(
|
||||
p.species(),
|
||||
Species::Fox | Species::Pig | Species::Cow | Species::Chicken
|
||||
) && !coin
|
||||
{
|
||||
child.extra.variant = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant);
|
||||
}
|
||||
if p.species() == Species::Sheep {
|
||||
@@ -2083,6 +2182,10 @@ impl Game<'_> {
|
||||
if let Some(a) = attacker.or(owner) {
|
||||
self.note_attacker(damage_type, a);
|
||||
}
|
||||
// The projectile is the direct entity: named when it has no owner.
|
||||
if let Some(name) = self.combat.projectile_direct.take() {
|
||||
self.note_direct_entity(damage_type, name);
|
||||
}
|
||||
// DamageSource.scalesWithDifficulty: a mob (or its projectile's
|
||||
// owner) caused the hit, not a player.
|
||||
let living_cause = attacker.or(owner).is_some();
|
||||
@@ -2559,6 +2662,7 @@ impl Game<'_> {
|
||||
let name = match sound {
|
||||
ArrowSound::Splash => "minecraft:entity.generic.splash",
|
||||
ArrowSound::HitGround => "minecraft:entity.arrow.hit",
|
||||
ArrowSound::CrossbowHit => "minecraft:item.crossbow.hit",
|
||||
ArrowSound::Extinguish => "minecraft:entity.generic.extinguish_fire",
|
||||
ArrowSound::TridentHitGround => "minecraft:item.trident.hit_ground",
|
||||
ArrowSound::TridentHit => "minecraft:item.trident.hit",
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
furnace::FurnaceCooking,
|
||||
items::ItemStack,
|
||||
lan::{guest::Guest, menu::Slots},
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
|
||||
@@ -90,6 +90,14 @@ impl Game<'_> {
|
||||
.any(|g| Some(g.conn) != self.lan_acting && g.vehicle == Some(vehicle))
|
||||
})
|
||||
}
|
||||
/// Whether a server player rides `vehicle` (`Entity.isPassenger`: a
|
||||
/// passenger does not use a portal on its own, the vehicle carries it
|
||||
/// across, `Entity.teleportCrossDimension`).
|
||||
pub(super) fn guest_rides(&self, vehicle: u32) -> bool {
|
||||
self.lan
|
||||
.as_ref()
|
||||
.is_some_and(|h| h.hub.guests.iter().any(|g| g.vehicle == Some(vehicle)))
|
||||
}
|
||||
|
||||
/// `Player.rideTick` for a server player, before its moves this tick:
|
||||
/// it stays in its seat, steers its minecart, or gets out.
|
||||
|
||||
@@ -52,8 +52,8 @@ pub(super) struct Parked {
|
||||
armor_stands: Vec<horizon_client::entities::ArmorStand>,
|
||||
cushions: Vec<horizon_client::entities::Cushion>,
|
||||
others: Vec<commands::OtherEntity>,
|
||||
boats: Vec<boats::Boat>,
|
||||
carts: Vec<minecarts::Minecart>,
|
||||
pub(super) boats: Vec<boats::Boat>,
|
||||
pub(super) carts: Vec<minecarts::Minecart>,
|
||||
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
|
||||
bolts: Vec<lightning::Bolt>,
|
||||
crystals: Vec<dragon::Crystal>,
|
||||
@@ -65,7 +65,7 @@ pub(super) struct Parked {
|
||||
ominous_spawners: Vec<ominous_item_spawner::OminousItemSpawner>,
|
||||
tnts: Vec<tnt::PrimedTnt>,
|
||||
hoppers: HashMap<(i32, i32, i32), machines::HopperState>,
|
||||
mobs: Vec<Mob>,
|
||||
pub(super) mobs: Vec<Mob>,
|
||||
/// The entity ids of the level's fence knots (`knot_ids`).
|
||||
knot_ids: HashMap<[i32; 3], u32>,
|
||||
projectiles: Vec<horizon_client::projectiles::Projectile>,
|
||||
@@ -122,6 +122,12 @@ impl Levels {
|
||||
.iter_mut()
|
||||
.map(|(d, p)| (*d, &mut p.world.chunks))
|
||||
}
|
||||
|
||||
/// The level not in place, in `dim`.
|
||||
pub(super) fn parked_mut(&mut self, dim: Dim) -> Option<&mut Parked> {
|
||||
self.parked.get_mut(&dim)
|
||||
}
|
||||
|
||||
/// The levels that are not in place, by dimension.
|
||||
pub(super) fn parked_dims(&self) -> Vec<Dim> {
|
||||
self.parked.keys().copied().collect()
|
||||
@@ -297,6 +303,23 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The entity's memory (its UUID and the compound it was loaded with)
|
||||
/// moves with it from the level not in place to the level in place, so
|
||||
/// it is saved in the other level as it was.
|
||||
pub(super) fn move_entity_memory(&mut self, from: Dim, id: u32) {
|
||||
let Some(p) = self.levels.parked.get_mut(&from) else {
|
||||
return;
|
||||
};
|
||||
let uuid = p.persist.uuids.remove(&id);
|
||||
let raw = p.persist.raw.remove(&id);
|
||||
if let Some(u) = uuid {
|
||||
self.persist.uuids.insert(id, u);
|
||||
}
|
||||
if let Some(r) = raw {
|
||||
self.persist.raw.insert(id, r);
|
||||
}
|
||||
}
|
||||
|
||||
/// Put `dim`'s level in place for a command whose source is there
|
||||
/// (`execute in`, `execute at` a player in another level), opening it if
|
||||
/// nobody is in it; returns the level to put back afterwards. Only a
|
||||
@@ -408,9 +431,9 @@ impl Game<'_> {
|
||||
}
|
||||
*next += self.clock.tick_duration();
|
||||
if *next > now {
|
||||
// A `/pregen` task or force-loaded chunks keep the workers fed
|
||||
// until the next tick.
|
||||
self.pump_pregen(*next);
|
||||
// The chunks loading (the players', force-loaded ones, a
|
||||
// `/pregen` task) keep the workers fed until the next tick.
|
||||
self.pump_chunks(*next, paused);
|
||||
let now = Instant::now();
|
||||
if *next > now {
|
||||
std::thread::sleep(*next - now);
|
||||
@@ -454,9 +477,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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,326 @@
|
||||
//! 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,
|
||||
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,
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers for native mods, kept as stable as the hook points.
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// 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<'_> {
|
||||
/// 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);
|
||||
|
||||
@@ -273,6 +273,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);
|
||||
|
||||
@@ -700,7 +700,6 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let text = content.plain();
|
||||
log::info!("[chat] {text}");
|
||||
// addServerSystemMessage and addClientSystemMessage both carry
|
||||
// GuiMessageTag.systemSinglePlayer: the grey bar at the line's left.
|
||||
let indicator = (source != horizon_client::chat::ChatSource::Player)
|
||||
@@ -3541,6 +3540,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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
Mob, Who,
|
||||
spawner::{Spawner, SpawnerAdded, SpawnerEvent, SpawnerSpawn},
|
||||
spawning,
|
||||
trial_spawner::TrialState,
|
||||
},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
@@ -64,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(());
|
||||
};
|
||||
@@ -373,12 +373,11 @@ impl Game<'_> {
|
||||
/// clicked block has no spawner.
|
||||
pub(super) fn use_spawn_egg_on_spawner(&mut self, pos: [i32; 3], kind: &str) -> Option<bool> {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
if !matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
Some(BlockEntity::Spawner(_))
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let trial = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::Spawner(_)) => false,
|
||||
Some(BlockEntity::TrialSpawner(_)) => true,
|
||||
_ => return None,
|
||||
};
|
||||
if !self.ui.rules.bool("spawner_blocks_work") {
|
||||
self.system_message(horizon_client::text::Text::translate(
|
||||
"advMode.notEnabled.spawner",
|
||||
@@ -386,16 +385,26 @@ impl Game<'_> {
|
||||
));
|
||||
return Some(false);
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
let mut random = std::mem::replace(
|
||||
&mut self.world.random,
|
||||
horizon_core::random::LegacyRandom::new(0),
|
||||
);
|
||||
if let Some(BlockEntity::Spawner(s)) = self.world.block_entities.get_mut(&key) {
|
||||
s.set_entity_id(kind, &mut random, &mut events);
|
||||
if trial {
|
||||
// TrialSpawnerBlockEntity.setEntityId: overrideEntityToSpawn, which
|
||||
// ends in setState(INACTIVE).
|
||||
if let Some(BlockEntity::TrialSpawner(t)) = self.world.block_entities.get_mut(&key) {
|
||||
t.override_entity(kind);
|
||||
}
|
||||
let ominous = self.trial_spawner_block(key).is_some_and(|(_, o)| o);
|
||||
let _ = self.set_trial_spawner_state(key, TrialState::Inactive, ominous);
|
||||
} else {
|
||||
let mut events = Vec::new();
|
||||
let mut random = std::mem::replace(
|
||||
&mut self.world.random,
|
||||
horizon_core::random::LegacyRandom::new(0),
|
||||
);
|
||||
if let Some(BlockEntity::Spawner(s)) = self.world.block_entities.get_mut(&key) {
|
||||
s.set_entity_id(kind, &mut random, &mut events);
|
||||
}
|
||||
self.world.random = random;
|
||||
let _ = self.apply_spawner_events(key, events, &HashMap::new());
|
||||
}
|
||||
self.world.random = random;
|
||||
let _ = self.apply_spawner_events(key, events, &HashMap::new());
|
||||
// sendBlockUpdated(3) and the player's block_change game event.
|
||||
self.spawner_block_updated(key);
|
||||
self.post_block_game_event("minecraft:block_change", pos, Some(Who::Player), None);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::{Game, GameLevel, Screen, dispenser::new_item_motion, storage::is_shu
|
||||
use horizon_client::{
|
||||
block_entity_item, block_entity_nbt,
|
||||
containers::{Banner, BlockEntity},
|
||||
furnace::FurnaceCooking,
|
||||
item_data,
|
||||
items::ItemStack,
|
||||
save,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -272,7 +272,7 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
/// `setState` (and `removeOminous`): the block's state and ominous flag.
|
||||
fn set_trial_spawner_state(
|
||||
pub(super) fn set_trial_spawner_state(
|
||||
&mut self,
|
||||
key: Pos,
|
||||
state: TrialState,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
@@ -588,6 +599,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_preset_keeps_the_animated_textures_on() {
|
||||
// The Performance preset used to turn them off, which froze the fire,
|
||||
// the water, the lava and the portals (#2064).
|
||||
assert!(Graphics::default().animated_textures);
|
||||
for preset in Preset::ALL {
|
||||
let mut s = crate::settings::Settings::default();
|
||||
apply_preset(preset, &mut s);
|
||||
assert!(
|
||||
s.horizon.animated_textures,
|
||||
"{preset:?} turned the animated textures off"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_workers_leave_a_core_as_vanilla_does() {
|
||||
let g = Graphics::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,
|
||||
|
||||
@@ -69,6 +69,7 @@ pub mod flatgen;
|
||||
pub mod fog;
|
||||
pub mod font;
|
||||
pub mod friends_screen;
|
||||
pub mod furnace;
|
||||
pub mod game_mode_switcher;
|
||||
pub mod game_rules_screen;
|
||||
pub mod gfx;
|
||||
|
||||
@@ -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),
|
||||
@@ -2013,12 +2099,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 +2187,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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,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 +110,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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1065,6 +1065,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 +1081,7 @@ impl MeleeAttack {
|
||||
last_check: 0,
|
||||
raise_arm: 0,
|
||||
interval: 20,
|
||||
baby_interval: None,
|
||||
}
|
||||
}
|
||||
pub fn zombie(mut self) -> Self {
|
||||
@@ -1093,6 +1096,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 +1200,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 +1906,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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -20,12 +20,32 @@ pub type Atlas<'a> = &'a dyn Fn(&str) -> Option<[f32; 4]>;
|
||||
/// the model UV and reads `enchanted_glint_armor`.
|
||||
pub const ARMOR_GLINT: u32 = 13;
|
||||
|
||||
/// `RenderTypes.breezeWind`: the texture of the breeze's wind layer scrolls
|
||||
/// by 0.02 of the texture per tick.
|
||||
pub const BREEZE_WIND_SCROLL: f32 = 0.02;
|
||||
/// `WindChargeRenderer.xOffset`: the wind charge's texture scrolls by 0.03 of
|
||||
/// the texture per tick.
|
||||
pub const WIND_CHARGE_SCROLL: f32 = 0.03;
|
||||
/// The offset of a scrolling texture: `RenderTypes.breezeWind` takes the
|
||||
/// texture's age times the speed, modulo one.
|
||||
pub fn scroll_offset(age: f32, speed: f32) -> f32 {
|
||||
(age * speed) % 1.0
|
||||
}
|
||||
/// The vertex's U with the scroll: the texture matrix of
|
||||
/// `RenderTypes.breezeWind` moves the texture under the model, so the U
|
||||
/// moves by the offset and wraps around the texture.
|
||||
fn scroll_u(u: f32, scroll: f32) -> f32 {
|
||||
if scroll == 0.0 { u } else { (u + scroll) % 1.0 }
|
||||
}
|
||||
|
||||
/// One draw of a model with a texture.
|
||||
pub struct Layer {
|
||||
pub model: String,
|
||||
pub texture: String,
|
||||
pub color: u32,
|
||||
pub full_bright: bool,
|
||||
/// `RenderTypes.breezeWind`: the texture scrolls with the entity's age.
|
||||
pub scroll: f32,
|
||||
}
|
||||
/// DyeColor.getTextureDiffuseColor (RGB).
|
||||
pub const DYE: [u32; 16] = [
|
||||
@@ -437,6 +457,7 @@ pub fn layers(m: &Mob) -> Vec<Layer> {
|
||||
texture: texture.into(),
|
||||
color,
|
||||
full_bright,
|
||||
scroll: 0.0,
|
||||
})
|
||||
};
|
||||
match m.species() {
|
||||
@@ -490,16 +511,17 @@ pub fn layers(m: &Mob) -> Vec<Layer> {
|
||||
push(&mut v, "happy_ghast_harness#main", &texture, white, false);
|
||||
}
|
||||
}
|
||||
// BreezeWindLayer (the scroll is not reproduced: atlas sprites do
|
||||
// not wrap) and BreezeEyesLayer.
|
||||
// BreezeWindLayer and BreezeEyesLayer. The wind layer's texture
|
||||
// scrolls (`RenderTypes.breezeWind`): the offset is taken inside the
|
||||
// sprite, so the atlas does not have to wrap.
|
||||
S::Breeze => {
|
||||
push(
|
||||
&mut v,
|
||||
"breeze#wind",
|
||||
"entity/breeze/breeze_wind",
|
||||
white,
|
||||
false,
|
||||
);
|
||||
v.push(Layer {
|
||||
model: "breeze#wind".into(),
|
||||
texture: "entity/breeze/breeze_wind".into(),
|
||||
color: white,
|
||||
full_bright: false,
|
||||
scroll: scroll_offset(m.age as f32, BREEZE_WIND_SCROLL),
|
||||
});
|
||||
push(
|
||||
&mut v,
|
||||
"breeze#eyes",
|
||||
@@ -827,6 +849,7 @@ fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
|
||||
texture: format!("entity/equipment/{folder}/{asset}"),
|
||||
color: tint,
|
||||
full_bright: false,
|
||||
scroll: 0.0,
|
||||
});
|
||||
if asset == "leather" {
|
||||
v.push(Layer {
|
||||
@@ -834,6 +857,7 @@ fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
|
||||
texture: format!("entity/equipment/{folder}/leather_overlay"),
|
||||
color: 0xFFFF_FFFF,
|
||||
full_bright: false,
|
||||
scroll: 0.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1046,6 +1070,23 @@ pub fn draw(
|
||||
light: u32,
|
||||
overlay: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
draw_scrolled(part, base, poses, uv, 0.0, color, light, overlay, out);
|
||||
}
|
||||
/// `draw` for a texture that scrolls: the vertex's U is moved by the offset
|
||||
/// and wraps inside the texture, as the texture matrix of
|
||||
/// `RenderTypes.breezeWind` does.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw_scrolled(
|
||||
part: &Part,
|
||||
base: glam::Mat4,
|
||||
poses: &Poses,
|
||||
uv: [f32; 4],
|
||||
scroll: f32,
|
||||
color: u32,
|
||||
light: u32,
|
||||
overlay: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
draw_normals(
|
||||
part,
|
||||
@@ -1053,6 +1094,7 @@ pub fn draw(
|
||||
ModelNormals::new(base),
|
||||
poses,
|
||||
uv,
|
||||
scroll,
|
||||
color,
|
||||
light,
|
||||
overlay,
|
||||
@@ -1066,6 +1108,7 @@ fn draw_normals(
|
||||
normals: ModelNormals,
|
||||
poses: &Poses,
|
||||
uv: [f32; 4],
|
||||
scroll: f32,
|
||||
color: u32,
|
||||
light: u32,
|
||||
overlay: u32,
|
||||
@@ -1103,7 +1146,7 @@ fn draw_normals(
|
||||
normal,
|
||||
pos: pos.to_array(),
|
||||
uv: [
|
||||
uv[0] + (uv[2] - uv[0]) * v[3],
|
||||
uv[0] + (uv[2] - uv[0]) * scroll_u(v[3], scroll),
|
||||
uv[1] + (uv[3] - uv[1]) * v[4],
|
||||
],
|
||||
layer: overlay,
|
||||
@@ -1115,7 +1158,7 @@ fn draw_normals(
|
||||
}
|
||||
}
|
||||
for c in &part.children {
|
||||
draw_normals(c, m, normals, poses, uv, color, light, overlay, out);
|
||||
draw_normals(c, m, normals, poses, uv, scroll, color, light, overlay, out);
|
||||
}
|
||||
}
|
||||
/// Render a mob with its variant model, texture and layers.
|
||||
@@ -1236,6 +1279,7 @@ fn render_visible(
|
||||
texture: texture.into(),
|
||||
color: ((a.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF,
|
||||
full_bright: true,
|
||||
scroll: 0.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1277,11 +1321,12 @@ fn render_visible(
|
||||
None => overlay,
|
||||
};
|
||||
let start = out.len();
|
||||
draw(
|
||||
draw_scrolled(
|
||||
&part,
|
||||
base,
|
||||
&layer_poses,
|
||||
uv,
|
||||
layer.scroll,
|
||||
layer.color,
|
||||
light,
|
||||
overlay,
|
||||
@@ -1671,3 +1716,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod scroll_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_scrolling_texture_wraps_around() {
|
||||
// RenderTypes.breezeWind: the breeze's wind layer's texture scrolls
|
||||
// by 0.02 of the texture per tick, the wind charge's by 0.03.
|
||||
assert_eq!(scroll_offset(0.0, BREEZE_WIND_SCROLL), 0.0);
|
||||
assert!((scroll_offset(25.0, BREEZE_WIND_SCROLL) - 0.5).abs() < 1e-6);
|
||||
assert!((scroll_offset(50.0, BREEZE_WIND_SCROLL) - 0.0).abs() < 1e-6);
|
||||
assert!((scroll_offset(50.0, WIND_CHARGE_SCROLL) - 0.5).abs() < 1e-6);
|
||||
assert!((scroll_offset(100.0, WIND_CHARGE_SCROLL) - 0.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scrolled_u_wraps_inside_the_texture() {
|
||||
// Without a scroll the U is the vertex's own.
|
||||
assert_eq!(scroll_u(0.25, 0.0), 0.25);
|
||||
assert_eq!(scroll_u(1.0, 0.0), 1.0);
|
||||
// With one, it moves and wraps around the texture.
|
||||
assert!((scroll_u(0.25, 0.5) - 0.75).abs() < 1e-6);
|
||||
assert!((scroll_u(0.75, 0.5) - 0.25).abs() < 1e-6);
|
||||
assert!((scroll_u(1.0, 0.5) - 0.5).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -593,3 +593,65 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses) {
|
||||
p.xrot("head", s.x_rot.to_radians());
|
||||
p.yrot("head", (s.y_rot - 180.0).to_radians());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mobs::sensor_scan_tests::kind;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A shulker fixed to a block face.
|
||||
fn shulker() -> Mob {
|
||||
let mut kind = kind();
|
||||
let k = Arc::make_mut(&mut kind);
|
||||
k.id = "minecraft:shulker".into();
|
||||
k.species = Species::Shulker;
|
||||
k.max_head_y_rot = 180.0;
|
||||
let mut m = Mob::new(1, kind, Vec3::ZERO, 0.0);
|
||||
m.extra.variant3 = 2;
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_body_stays_square_while_the_head_turns() {
|
||||
// Shulker.recreateFromPacket sets the body rotation to 0 and
|
||||
// Shulker.ShulkerBodyRotationControl.clientTick is empty, so the
|
||||
// shulker's body never turns: it stays square to the block it is
|
||||
// fixed to and only its head turns (#1661).
|
||||
let mut m = shulker();
|
||||
m.yaw = 90.0;
|
||||
m.head_yaw = 90.0;
|
||||
// It moved, so a body that follows the mob would turn with it.
|
||||
m.previous = Vec3::new(1.0, 0.0, 0.0);
|
||||
m.body_rotation_tick();
|
||||
assert_eq!(
|
||||
m.body_yaw, 0.0,
|
||||
"the shulker's body stays square to the block"
|
||||
);
|
||||
assert_eq!(m.previous_body_yaw, 0.0);
|
||||
// A mob that moves turns its body to where it goes.
|
||||
let mut other = Mob::new(2, kind(), Vec3::ZERO, 0.0);
|
||||
other.yaw = 90.0;
|
||||
other.previous = Vec3::new(1.0, 0.0, 0.0);
|
||||
other.body_rotation_tick();
|
||||
assert_eq!(other.body_yaw, 90.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_head_turns_to_its_target() {
|
||||
// ShulkerLookControl.clampHeadRotationToBody does nothing, so the
|
||||
// head is not clamped to the square body: it turns to the target.
|
||||
let mut m = shulker();
|
||||
m.ai.look.kind = LookKind::Shulker;
|
||||
for _ in 0..20 {
|
||||
// A goal keeps the look up to date every tick.
|
||||
m.ai.look.set_look_at([5.0, 0.0, 0.0], 10.0, 10.0);
|
||||
m.look_control_tick();
|
||||
}
|
||||
assert!(
|
||||
m.head_yaw.abs() > 45.0,
|
||||
"the head turns to the target, not back to the square body"
|
||||
);
|
||||
assert_eq!(m.body_yaw, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -23,61 +23,7 @@ use horizon_core::{
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
|
||||
/// A `natural_mob_spawns` / `spawn_overrides` entry list.
|
||||
pub fn parse_entries(list: &Value) -> Vec<SpawnEntry> {
|
||||
list.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let count = &e["count"];
|
||||
let (min, max, constant) = match count {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64().unwrap_or(1) as u32;
|
||||
(n, n, true)
|
||||
}
|
||||
c if c["type"].as_str() == Some("minecraft:constant") => {
|
||||
let n = c["value"].as_u64().unwrap_or(1) as u32;
|
||||
(n, n, true)
|
||||
}
|
||||
c => (
|
||||
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
|
||||
false,
|
||||
),
|
||||
};
|
||||
Some(SpawnEntry {
|
||||
entity: e["type"].as_str()?.to_owned(),
|
||||
weight: e["weight"].as_u64().unwrap_or(1) as u32,
|
||||
min,
|
||||
max,
|
||||
constant,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `MobSpawnSettings.MobSpawnCost`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SpawnCost {
|
||||
pub energy_budget: f64,
|
||||
pub charge: f64,
|
||||
}
|
||||
/// The `spawn_costs` of a `natural_mob_spawns` value.
|
||||
pub fn parse_costs(costs: &Value) -> HashMap<String, SpawnCost> {
|
||||
costs
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(k, c)| {
|
||||
(
|
||||
k.clone(),
|
||||
SpawnCost {
|
||||
energy_budget: c["energy_budget"].as_f64().unwrap_or(0.0),
|
||||
charge: c["charge"].as_f64().unwrap_or(0.0),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub use crate::biome::{SpawnCost, parse_costs, parse_entries};
|
||||
|
||||
/// `MobCategory`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
|
||||
@@ -168,6 +168,12 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) {
|
||||
Climate::Warm => 1,
|
||||
Climate::Cold => 2,
|
||||
};
|
||||
// `Registry.getRandom` of the `*_sound_variant` registry (pig, cow,
|
||||
// chicken, cat and wolf), drawn by their finalizeSpawn.
|
||||
let sounds = super::finalize::sound_variants(m.species()).len();
|
||||
if sounds > 0 {
|
||||
m.extra.sound_variant = Some(m.random.next_int_bound(sounds as i32).unwrap_or(0) as u8);
|
||||
}
|
||||
match m.species() {
|
||||
// Warden.finalizeSpawn: a minute before it digs back down.
|
||||
S::Warden => super::warden::finalize(m, false, &mut Vec::new()),
|
||||
@@ -301,6 +307,17 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) {
|
||||
}
|
||||
S::Fox | S::Panda | S::Dolphin => m.extra.pick_up_loot = true,
|
||||
S::TropicalFish => super::aquatic::finalize(m),
|
||||
// Salmon.finalizeSpawn: a weighted size (small 30, medium 50, large 15).
|
||||
S::Salmon => {
|
||||
let r = m.random.next_int_bound(95).unwrap_or(0);
|
||||
m.extra.variant = if r < 30 {
|
||||
0
|
||||
} else if r < 80 {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
}
|
||||
S::Slime | S::MagmaCube => super::cube::finalize(m),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -1602,6 +1602,8 @@ impl Def {
|
||||
pub fn water_slowdown(&self) -> f32 {
|
||||
match self.species {
|
||||
Species::PolarBear => 0.98,
|
||||
// SkeletonHorse.getWaterSlowDown.
|
||||
Species::SkeletonHorse => 0.96,
|
||||
_ => 0.8,
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user