mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
350
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e6cd2ed70 | ||
|
|
a813c4a755 | ||
|
|
e8e7254710 | ||
|
|
639100ac95 | ||
|
|
9cb12d984b | ||
|
|
b211519e79 | ||
|
|
23d7e03a5b | ||
|
|
9a8e0e36a9 | ||
|
|
d68895d0b1 | ||
|
|
59ed6869e3 | ||
|
|
1ac4bcbe9b | ||
|
|
9b487bdfe9 | ||
|
|
1ec2b888aa | ||
|
|
dbb396e599 | ||
|
|
1e3a9d53a7 | ||
|
|
e5cd63b608 | ||
|
|
06522c11db | ||
|
|
be0a93271f | ||
|
|
7bdfe942a4 | ||
|
|
0ea243a1c7 | ||
|
|
9dd27667f2 | ||
|
|
8709026be3 | ||
|
|
2af3308892 | ||
|
|
2b910fa16d | ||
|
|
4483c2613c | ||
|
|
3b19c386cf | ||
|
|
b4b80eaeb2 | ||
|
|
71d0220215 | ||
|
|
c27e3287c5 | ||
|
|
c326afc7f1 | ||
|
|
fd6c1e1770 | ||
|
|
82d36d1f9b | ||
|
|
665c28588e | ||
|
|
a64c4c6562 | ||
|
|
588b2fd116 | ||
|
|
7a0610ade8 | ||
|
|
b3fff5222b | ||
|
|
9281a0b5d2 | ||
|
|
38588ab7aa | ||
|
|
8521aaac41 | ||
|
|
bdd6b0ea12 | ||
|
|
c179c8753f | ||
|
|
19abf6182d | ||
|
|
f87bbb5342 | ||
|
|
7c7937438e | ||
|
|
6207624448 | ||
|
|
4f7a0f2ba1 | ||
|
|
24800ac54e | ||
|
|
66dabf51ee | ||
|
|
88aa505533 | ||
|
|
ab080a5407 | ||
|
|
d89e27113f | ||
|
|
f08a564fa6 | ||
|
|
7262304a6d | ||
|
|
1d2c5db426 | ||
|
|
964fbe1c29 | ||
|
|
6ab1b40ad4 | ||
|
|
cd18488cc2 | ||
|
|
970ee43f8e | ||
|
|
e05847289f | ||
|
|
bc0c0fdf4a | ||
|
|
97437beaff | ||
|
|
869efb87d2 | ||
|
|
2edb5193ef | ||
|
|
d9f49ecec0 | ||
|
|
73d0cd36e8 | ||
|
|
abda92d574 | ||
|
|
cacd39338f | ||
|
|
d7cd2ed5e8 | ||
|
|
ca63a998cf | ||
|
|
86b651405a | ||
|
|
cf9fb96efa | ||
|
|
6850ec1b3a | ||
|
|
c2d43643aa | ||
|
|
ab083f9005 | ||
|
|
94290b01ee | ||
|
|
fc6102738c | ||
|
|
cbbf6f8b2e | ||
|
|
f00228e76a | ||
|
|
cad80ba3aa | ||
|
|
f1d14c918f | ||
|
|
7953969510 | ||
|
|
b62c2854ad | ||
|
|
830b841850 | ||
|
|
ba01b418c2 | ||
|
|
5a3ea22419 | ||
|
|
cd43ad9a4a | ||
|
|
98a11014ea | ||
|
|
467e28f2eb | ||
|
|
069a4f7f23 | ||
|
|
f78ba8c75f | ||
|
|
fdb763d5e3 | ||
|
|
9aca5c35ca | ||
|
|
a16d84d15d | ||
|
|
35fddca7e6 | ||
|
|
90a3fc82aa | ||
|
|
84c631a88b | ||
|
|
83bdb31eea | ||
|
|
8d1df095e7 | ||
|
|
1ac76ac103 | ||
|
|
4a82c014bb | ||
|
|
cd4ff3828b | ||
|
|
2a6b5836a9 | ||
|
|
380c40562d | ||
|
|
f010708103 | ||
|
|
9c62aba384 | ||
|
|
7d08cdf860 | ||
|
|
c6c2ec5f9f | ||
|
|
01e6dae6f7 | ||
|
|
ede89f4d37 | ||
|
|
033d704ac5 | ||
|
|
fec49b4482 | ||
|
|
86e5db17b5 | ||
|
|
9f04b02e12 | ||
|
|
0239df6f9d | ||
|
|
de1a7b6954 | ||
|
|
bcd92633ac | ||
|
|
f065d0d11d | ||
|
|
32a7a4c730 | ||
|
|
1b9f8cab58 | ||
|
|
d558a4abf3 | ||
|
|
95000c906a | ||
|
|
512545ade7 | ||
|
|
33afb63acb | ||
|
|
26b172eb1d | ||
|
|
ac42f11d7a | ||
|
|
3690dba775 | ||
|
|
7f01cb4f10 | ||
|
|
627ab546b9 | ||
|
|
751f242824 | ||
|
|
6817cb4af0 | ||
|
|
c2fb0b6730 | ||
|
|
6a56f7e39f | ||
|
|
f2070cef54 | ||
|
|
648060047c | ||
|
|
92f33c89fb | ||
|
|
bcb539057d | ||
|
|
e2b35bc827 | ||
|
|
8601d34f20 | ||
|
|
d812712ded | ||
|
|
ce94056986 | ||
|
|
d50a5e1f64 | ||
|
|
86d90c38a9 | ||
|
|
ae713f7bd8 | ||
|
|
1d018614ac | ||
|
|
653e3a4ee8 | ||
|
|
fd915f8a5d | ||
|
|
0504663c41 | ||
|
|
9203f21b99 | ||
|
|
9ac405e9cc | ||
|
|
903b427006 | ||
|
|
2ce21900ae | ||
|
|
03cbb48fa4 | ||
|
|
42cff7bbc2 | ||
|
|
604025a53b | ||
|
|
56b6229ef4 | ||
|
|
b7d802d7a6 | ||
|
|
cb246e7528 | ||
|
|
05d150349f | ||
|
|
712306d3b9 | ||
|
|
ea5f288a1f | ||
|
|
db228099ab | ||
|
|
fed7ea2275 | ||
|
|
9ce9282b21 | ||
|
|
2da8d6bf28 | ||
|
|
5b435bb03a | ||
|
|
a44d5c81a2 | ||
|
|
101d4689f6 | ||
|
|
c7c890dd69 | ||
|
|
62b8f33671 | ||
|
|
48fd9f4ea5 | ||
|
|
d77569c672 | ||
|
|
937dcba5b8 | ||
|
|
0f581c669e | ||
|
|
36f6ab9839 | ||
|
|
d3b28edfb6 | ||
|
|
9c49424b4d | ||
|
|
f4271d7c16 | ||
|
|
6a65ee1124 | ||
|
|
ad70f755d7 | ||
|
|
d2ca443d6f | ||
|
|
78dba05d8f | ||
|
|
0e927e6f11 | ||
|
|
2eb96ddb7c | ||
|
|
97dd6d982a | ||
|
|
393b8ef7e6 | ||
|
|
15be176380 | ||
|
|
8905aaf0dd | ||
|
|
bbc1ffb408 | ||
|
|
4c78d32e36 | ||
|
|
e5e1b74fd8 | ||
|
|
9809bf38b6 | ||
|
|
5ea88533e0 | ||
|
|
95eb81bfe5 | ||
|
|
c752c21246 | ||
|
|
594394ab30 | ||
|
|
c3ef0a06e7 | ||
|
|
531212c812 | ||
|
|
2e9827a259 | ||
|
|
2f6d259615 | ||
|
|
6c769f38ae | ||
|
|
475f64b79d | ||
|
|
b22c42ea85 | ||
|
|
a21cf57a11 | ||
|
|
6c36da40e0 | ||
|
|
4940a24423 | ||
|
|
765cef8a4e | ||
|
|
29616ad502 | ||
|
|
79fca90886 | ||
|
|
f918fc711a | ||
|
|
cc0f8f8a50 | ||
|
|
c4427d2a14 | ||
|
|
f2db04bd64 | ||
|
|
03d8ffe2c6 | ||
|
|
ff28fd8390 | ||
|
|
a19cad22cb | ||
|
|
36c18fd8ea | ||
|
|
c352c80c96 | ||
|
|
9ab0e2b6a8 | ||
|
|
9cebcc8d7f | ||
|
|
6384234e37 | ||
|
|
3caf28c4d6 | ||
|
|
2c894783aa | ||
|
|
9266e0c7c9 | ||
|
|
909f15b909 | ||
|
|
da57350af5 | ||
|
|
66607c9eab | ||
|
|
400d9e6115 | ||
|
|
140958fa58 | ||
|
|
04b0b9aed5 | ||
|
|
5e27fd3421 | ||
|
|
e544924504 | ||
|
|
29754b857d | ||
|
|
8144385ce6 | ||
|
|
11e1ff12d0 | ||
|
|
25c53cc803 | ||
|
|
43e407562f | ||
|
|
a45d0de43e | ||
|
|
f6db8b1f56 | ||
|
|
426b3c83cf | ||
|
|
a146754b0b | ||
|
|
bafd010865 | ||
|
|
8f78fdeb7b | ||
|
|
d2a8cf3bdf | ||
|
|
ed9fdb5208 | ||
|
|
2929bb6dba | ||
|
|
6120c8c0ad | ||
|
|
53ea3e7224 | ||
|
|
462787aee1 | ||
|
|
b90f9ec1ef | ||
|
|
77fe538e09 | ||
|
|
d8b8246622 | ||
|
|
35a02c79a8 | ||
|
|
db985f717c | ||
|
|
e41c602359 | ||
|
|
daee960ba2 | ||
|
|
b33ae69938 | ||
|
|
951b88a3c2 | ||
|
|
414a2b82a8 | ||
|
|
9958fe0e49 | ||
|
|
cdafcd756f | ||
|
|
3fe301aaa1 | ||
|
|
dca6ebba0e | ||
|
|
6af81b600f | ||
|
|
851789830a | ||
|
|
317f30427e | ||
|
|
91f1f839ab | ||
|
|
1c6c6c0b97 | ||
|
|
a1761dcb65 | ||
|
|
cf6b91a710 | ||
|
|
2b2aecd975 | ||
|
|
8aad511579 | ||
|
|
94600b266f | ||
|
|
63ee634ee0 | ||
|
|
752138e866 | ||
|
|
c74626448c | ||
|
|
d248ab2605 | ||
|
|
10d2b202d5 | ||
|
|
97f8bca9da | ||
|
|
484cc945aa | ||
|
|
ab40cd63e6 | ||
|
|
00f2663e7b | ||
|
|
e6587dce6a | ||
|
|
d75f767925 | ||
|
|
a45c73bb36 | ||
|
|
91d6bdba85 | ||
|
|
4248048ab6 | ||
|
|
2277c85fc3 | ||
|
|
e98d939919 | ||
|
|
fe8565b874 | ||
|
|
b32989cf1e | ||
|
|
fece9d85e3 | ||
|
|
7569f9615a | ||
|
|
90501f9ab4 | ||
|
|
c1844dfe57 | ||
|
|
bf527e7a13 | ||
|
|
dbef78bcb8 | ||
|
|
564b230ab1 | ||
|
|
555ddb94bf | ||
|
|
e92937d956 | ||
|
|
d62a1e5d20 | ||
|
|
1862395e4b | ||
|
|
077da2e06b | ||
|
|
a6d2eb9d64 | ||
|
|
0300ad8922 | ||
|
|
7a3a19697a | ||
|
|
e8ae26c270 | ||
|
|
e50eb7c71f | ||
|
|
b62095dfcb | ||
|
|
ba3b567f54 | ||
|
|
5d3e9d218d | ||
|
|
36ef05b0b9 | ||
|
|
49ad61035b | ||
|
|
3842d851d8 | ||
|
|
b28108128d | ||
|
|
c50557ea0b | ||
|
|
f364c4f9c6 | ||
|
|
18f4dd92ae | ||
|
|
47654b62a3 | ||
|
|
2f3dade42f | ||
|
|
af49fd7dc3 | ||
|
|
af6faa88e5 | ||
|
|
e71d4281ac | ||
|
|
7102727197 | ||
|
|
70868f3208 | ||
|
|
aff673a08d | ||
|
|
2e642857fc | ||
|
|
147e5269c7 | ||
|
|
b3a499d85d | ||
|
|
b913dab5e5 | ||
|
|
8c8f857313 | ||
|
|
9c427dfae0 | ||
|
|
ef711f8b1f | ||
|
|
601e7918a8 | ||
|
|
dd3697bb4d | ||
|
|
273729aac8 | ||
|
|
53501ab94e | ||
|
|
85a6ce0921 | ||
|
|
8691d9a8e8 | ||
|
|
8d77913ea5 | ||
|
|
5fa423169f | ||
|
|
183382cd2b | ||
|
|
329ce1b724 | ||
|
|
2a0824cf5f | ||
|
|
cc39d57e07 | ||
|
|
7cc8def23f | ||
|
|
2f6f0e3e0f | ||
|
|
4825e537cc | ||
|
|
2e91bd84e9 | ||
|
|
3287a74d64 |
No files matched your search
@@ -0,0 +1,274 @@
|
||||
name: game build
|
||||
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server and Windows on
|
||||
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json). Called by
|
||||
# release.yml (the releases) and nightly-build.yml (the opt-in Nightly), always from main's copy of this file: the
|
||||
# caller's commit, never the one being built. It holds no secrets: signing the checksums is the callers' own job,
|
||||
# which never runs what this one built. See docs/release-flow.md.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: The commit to build
|
||||
type: string
|
||||
required: true
|
||||
version:
|
||||
description: The version built in (HORIZON_VERSION), e.g. 0.2.81 or nightly-2026-10-28
|
||||
type: string
|
||||
required: true
|
||||
nightly:
|
||||
description: A nightly's "<date> <short sha>" (HORIZON_NIGHTLY), shown by the game; empty for a release
|
||||
type: string
|
||||
default: ''
|
||||
developer_id:
|
||||
description: Sign the macOS programs with Developer ID and notarize the zip (the runner's keychain)
|
||||
type: boolean
|
||||
default: true
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
exporters:
|
||||
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it.
|
||||
runs-on: ${{ vars.MAC_RUNNER || 'ludovics-mac-mini' }}
|
||||
# Three full preparations (two isolated, one batched) besides the compile.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: bash tools/ci/runner-env.sh
|
||||
- run: bash tools/ci/vanilla-data.sh
|
||||
- name: Compile and validate original exporters against the pinned jars
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# target/ is gone after the clean checkout, and vanilla-data.sh builds the preparer only when it
|
||||
# prepares afresh.
|
||||
cargo build --release --locked -p horizon-prepare
|
||||
# Compiled here, never taken from the runner's shared preparations: pull requests' parity jobs
|
||||
# write those (vanilla-data.sh links .local/exporters to them).
|
||||
rm -rf .local/exporters
|
||||
bundle=$(target/release/horizon-prepare compile-exporters --root "$PWD" | tail -1)
|
||||
mkdir -p exporters
|
||||
cp -R "$bundle/server" "$bundle/client" "$bundle/manifest.json" exporters/
|
||||
# Only our classes and their manifest leave this runner.
|
||||
stray=$(find exporters -type f ! -path exporters/manifest.json \
|
||||
! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \))
|
||||
[ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; }
|
||||
python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: original-exporters
|
||||
path: exporters/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
build:
|
||||
needs: exporters
|
||||
name: build ${{ matrix.name }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux
|
||||
os: ubuntu-22.04
|
||||
dir: horizon-linux-x86_64
|
||||
asset: horizon-linux-x86_64.tar.gz
|
||||
# The server build too: packaging/server/install.sh installs it on Linux.
|
||||
server_dir: horizon-server-linux-x86_64
|
||||
server_asset: horizon-server-linux-x86_64.tar.gz
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
dir: horizon-windows-x64
|
||||
asset: horizon-windows-x64.zip
|
||||
exe: .exe
|
||||
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
- name: macos
|
||||
mac: true
|
||||
dir: horizon-macos-arm64
|
||||
asset: horizon-macos-arm64.zip
|
||||
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
|
||||
# Notarizing the zip can take a few minutes.
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
# The release's version, built in (horizon_core::release): --version, user agents, crash reports and
|
||||
# telemetry say it. No version-bump commits.
|
||||
HORIZON_VERSION: ${{ inputs.version }}
|
||||
# A nightly's date and commit ("2026-10-28 0c02067"), which the game shows; empty for a release.
|
||||
HORIZON_NIGHTLY: ${{ inputs.nightly }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
|
||||
# parity jobs, and nothing they leave (target/, an untracked .cargo/config.toml, a build script's output)
|
||||
# may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which
|
||||
# keeps its preparations outside the checkout.
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
shared-key: release-${{ matrix.name }}
|
||||
- name: ALSA and udev headers
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
|
||||
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
|
||||
done
|
||||
- name: The signing keychain, unlocked
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
|
||||
env:
|
||||
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
|
||||
run: packaging/macos/keychain.sh unlock
|
||||
- name: Signed with Developer ID and the hardened runtime
|
||||
# Before the checks below, so they run the programs as players will.
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
|
||||
env:
|
||||
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \
|
||||
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary"
|
||||
codesign --verify --strict --verbose=2 "target/release/$binary"
|
||||
done
|
||||
- name: Only system libraries
|
||||
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
|
||||
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
file="target/release/$binary${{ matrix.exe }}"
|
||||
if [[ "$RUNNER_OS" == macOS ]]; then
|
||||
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
|
||||
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
|
||||
else
|
||||
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
|
||||
fi
|
||||
done
|
||||
- name: Every program starts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin=target/release
|
||||
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
|
||||
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
|
||||
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
|
||||
# The version is the release's.
|
||||
for binary in horizon-prepare horizon-server; do
|
||||
out=$("$bin/$binary${{ matrix.exe }}" --version | tr -d '\r')
|
||||
echo "$out"
|
||||
[[ "$out" == "$binary $HORIZON_VERSION" ]] || { echo "::error::expected $binary $HORIZON_VERSION"; exit 1; }
|
||||
done
|
||||
- name: Tools from the runner
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Resources prepared from the runner's own Minecraft
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
- name: The game runs (offscreen, a new world, 1500 frames)
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# From a scratch folder: the game keeps its saves and options in the folder it starts in.
|
||||
home="$RUNNER_TEMP/horizon-smoke"
|
||||
rm -rf "$home" && mkdir -p "$home"
|
||||
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
|
||||
client="$PWD/target/release/horizon-client"
|
||||
resources="$PWD/.local/horizon-client/$version"
|
||||
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
|
||||
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
|
||||
--smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; }
|
||||
python3 - "$home/smoke.json" <<'EOF'
|
||||
import json, sys
|
||||
report = json.load(open(sys.argv[1]))
|
||||
print(json.dumps(report))
|
||||
if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \
|
||||
or not report.get("meshed_sections") or not report.get("player"):
|
||||
sys.exit("the smoke run did not draw a world")
|
||||
EOF
|
||||
# Published with the release; the publish job checks it again before it publishes anything.
|
||||
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: runner.environment == 'self-hosted'
|
||||
with:
|
||||
name: smoke-test.json
|
||||
path: ${{ runner.temp }}/smoke-test.json
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: original-exporters
|
||||
path: exporters
|
||||
- name: Package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
cd dist
|
||||
rm -f "${{ matrix.asset }}"
|
||||
case "${{ matrix.asset }}" in
|
||||
*.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;;
|
||||
*) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null
|
||||
else
|
||||
# Without resource forks and extended attributes: plain ditto adds an
|
||||
# AppleDouble `._` file beside every file that has any.
|
||||
ditto -c -k --norsrc --noextattr --noqtn --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}"
|
||||
if unzip -Z1 "${{ matrix.asset }}" | grep -E '(^|/)(\._|__MACOSX/)'; then
|
||||
echo "::error::macOS metadata files in ${{ matrix.asset }}"; exit 1
|
||||
fi
|
||||
fi ;;
|
||||
esac
|
||||
ls -l "${{ matrix.asset }}"
|
||||
- name: Notarized
|
||||
# A zip of programs can't be stapled: Gatekeeper asks Apple the first time each one runs.
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
codesign --verify --strict "dist/${{ matrix.dir }}/$binary"
|
||||
done
|
||||
packaging/macos/notarize.sh "dist/${{ matrix.asset }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: dist/${{ matrix.asset }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: The server build (the game without sound or controllers, as horizon-engine)
|
||||
if: matrix.server_asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
|
||||
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
|
||||
# and with code mods like the client.
|
||||
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
|
||||
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
|
||||
folder="dist/${{ matrix.server_dir }}"
|
||||
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
|
||||
"$folder/horizon-engine" --help | grep -q -- '--resources'
|
||||
[[ "$("$folder/horizon-server" --version)" == "horizon-server $HORIZON_VERSION" ]]
|
||||
cd dist
|
||||
rm -f "${{ matrix.server_asset }}"
|
||||
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
|
||||
ls -l "${{ matrix.server_asset }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: matrix.server_asset
|
||||
with:
|
||||
name: ${{ matrix.server_asset }}
|
||||
path: dist/${{ matrix.server_asset }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: The signing keychain, locked again
|
||||
if: always() && runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
|
||||
run: packaging/macos/keychain.sh lock
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
name: nightly build
|
||||
# The opt-in Nightly channel (docs/release-flow.md "Nightly"; Ludovic, 2026-10-27). Once a night 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, and publishes it 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 union's code:
|
||||
# - 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.
|
||||
# - `build` (game-build.yml) checks out and compiles the union 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
|
||||
# runs the runner's sha256sum and OpenSSL over the archives: it hashes them, never runs them. The signed message
|
||||
# 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.
|
||||
# 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
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 20 * * *' # 20:17 UTC: after the day's waves, before the 03:07 Paris parity audit takes the Mac mini
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: Build and publish even if tonight's pick is the commit already published
|
||||
type: boolean
|
||||
default: false
|
||||
dry_run:
|
||||
description: Build and sign with a throwaway key, publish nothing
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: nightly-build
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
pick:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
build: ${{ steps.pick.outputs.build }}
|
||||
sha: ${{ steps.pick.outputs.sha }}
|
||||
source: ${{ steps.pick.outputs.source }}
|
||||
date: ${{ steps.pick.outputs.date }}
|
||||
version: ${{ steps.pick.outputs.version }}
|
||||
nightly: ${{ steps.pick.outputs.nightly }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- id: mode
|
||||
# A dry run never publishes; a hand-started run off main is always one.
|
||||
run: echo "dry_run=${{ github.event_name == 'workflow_dispatch' && (inputs.dry_run || github.ref != 'refs/heads/main') }}" >> "$GITHUB_OUTPUT"
|
||||
- id: pick
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
FORCE: ${{ (inputs.force || steps.mode.outputs.dry_run == 'true') && 'yes' || '' }}
|
||||
run: |
|
||||
python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
{ echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nightly-notes
|
||||
path: nightly-notes.md
|
||||
retention-days: 3
|
||||
|
||||
build:
|
||||
needs: pick
|
||||
if: needs.pick.outputs.build == 'true'
|
||||
uses: ./.github/workflows/game-build.yml
|
||||
with:
|
||||
ref: ${{ needs.pick.outputs.sha }}
|
||||
version: ${{ needs.pick.outputs.version }}
|
||||
nightly: ${{ needs.pick.outputs.nightly }}
|
||||
developer_id: false
|
||||
|
||||
sign:
|
||||
needs: [pick, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# The key's only job: main's sign_release.sh hashes the archives and signs with the runner's OpenSSL.
|
||||
environment: ${{ needs.pick.outputs.dry_run != 'true' && 'release' || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: tools/ci/sign_release.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
pattern: horizon-*
|
||||
merge-multiple: true
|
||||
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig) for the nightly channel
|
||||
env:
|
||||
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
|
||||
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
|
||||
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
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
path: dist/SHA256SUMS*
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: needs.pick.outputs.dry_run == 'true'
|
||||
with:
|
||||
name: ephemeral.pub
|
||||
path: dist/ephemeral.pub
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
needs: [pick, build, sign]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: The signature, checked as launchers check it
|
||||
env:
|
||||
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
|
||||
cargo run --locked --quiet -p horizon-release-sign -- verify --tag 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: 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 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 tools/ci/nightly.py rotate
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
# A pre-release, never GitHub's latest: launchers on the release channel never see it.
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" \
|
||||
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
python3 tools/ci/nightly.py prune --days 7
|
||||
- name: Dry run, the nightly that would be published
|
||||
if: needs.pick.outputs.dry_run == 'true'
|
||||
run: |
|
||||
echo "Would publish nightly at ${{ needs.pick.outputs.sha }} (${{ needs.pick.outputs.source }})"
|
||||
ls -l dist
|
||||
+10
-245
@@ -40,7 +40,10 @@ permissions:
|
||||
# The open release-blocker issues (decide).
|
||||
issues: read
|
||||
concurrency:
|
||||
group: game-release
|
||||
# One release at a time. A hand-started dry run publishes nothing, so it can't race a real release: it gets a
|
||||
# group of its own. In the shared group, a release queued behind it replaced it as the pending run and GitHub
|
||||
# cancelled it (#2779's dry run, 2026-10-27), which happens all day while releases go out in batches.
|
||||
group: game-release${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && '-dry-run' || '' }}
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
decide:
|
||||
@@ -79,252 +82,14 @@ jobs:
|
||||
# A dry run builds whatever the decision, to test the builds.
|
||||
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
|
||||
|
||||
exporters:
|
||||
build:
|
||||
needs: decide
|
||||
if: needs.decide.outputs.release == 'true'
|
||||
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it.
|
||||
runs-on: ${{ vars.MAC_RUNNER || 'ludovics-mac-mini' }}
|
||||
# Three full preparations (two isolated, one batched) besides the compile.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head }}
|
||||
persist-credentials: false
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: bash tools/ci/runner-env.sh
|
||||
- run: bash tools/ci/vanilla-data.sh
|
||||
- name: Compile and validate original exporters against the pinned jars
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# target/ is gone after the clean checkout, and vanilla-data.sh builds the preparer only when it
|
||||
# prepares afresh.
|
||||
cargo build --release --locked -p horizon-prepare
|
||||
# Compiled here, never taken from the runner's shared preparations: pull requests' parity jobs
|
||||
# write those (vanilla-data.sh links .local/exporters to them).
|
||||
rm -rf .local/exporters
|
||||
bundle=$(target/release/horizon-prepare compile-exporters --root "$PWD" | tail -1)
|
||||
mkdir -p exporters
|
||||
cp -R "$bundle/server" "$bundle/client" "$bundle/manifest.json" exporters/
|
||||
# Only our classes and their manifest leave this runner.
|
||||
stray=$(find exporters -type f ! -path exporters/manifest.json \
|
||||
! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \))
|
||||
[ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; }
|
||||
python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: original-exporters
|
||||
path: exporters/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
build:
|
||||
needs: [decide, exporters]
|
||||
if: needs.decide.outputs.release == 'true'
|
||||
name: build ${{ matrix.name }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux
|
||||
os: ubuntu-22.04
|
||||
dir: horizon-linux-x86_64
|
||||
asset: horizon-linux-x86_64.tar.gz
|
||||
# The server build too: packaging/server/install.sh installs it on Linux.
|
||||
server_dir: horizon-server-linux-x86_64
|
||||
server_asset: horizon-server-linux-x86_64.tar.gz
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
dir: horizon-windows-x64
|
||||
asset: horizon-windows-x64.zip
|
||||
exe: .exe
|
||||
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
- name: macos
|
||||
mac: true
|
||||
dir: horizon-macos-arm64
|
||||
asset: horizon-macos-arm64.zip
|
||||
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
|
||||
# Notarizing the zip can take a few minutes.
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
# The release's version, built in (horizon_core::release): --version, user agents, crash reports and
|
||||
# telemetry say it. No version-bump commits.
|
||||
HORIZON_VERSION: ${{ needs.decide.outputs.version }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head }}
|
||||
persist-credentials: false
|
||||
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
|
||||
# parity jobs, and nothing they leave (target/, an untracked .cargo/config.toml, a build script's output)
|
||||
# may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which
|
||||
# keeps its preparations outside the checkout.
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
shared-key: release-${{ matrix.name }}
|
||||
- name: ALSA and udev headers
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
|
||||
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
|
||||
done
|
||||
- name: The signing keychain, unlocked
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
|
||||
env:
|
||||
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
|
||||
run: packaging/macos/keychain.sh unlock
|
||||
- name: Signed with Developer ID and the hardened runtime
|
||||
# Before the checks below, so they run the programs as players will.
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
|
||||
env:
|
||||
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \
|
||||
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary"
|
||||
codesign --verify --strict --verbose=2 "target/release/$binary"
|
||||
done
|
||||
- name: Only system libraries
|
||||
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
|
||||
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
file="target/release/$binary${{ matrix.exe }}"
|
||||
if [[ "$RUNNER_OS" == macOS ]]; then
|
||||
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
|
||||
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
|
||||
else
|
||||
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
|
||||
fi
|
||||
done
|
||||
- name: Every program starts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin=target/release
|
||||
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
|
||||
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
|
||||
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
|
||||
# The version is the release's.
|
||||
for binary in horizon-prepare horizon-server; do
|
||||
out=$("$bin/$binary${{ matrix.exe }}" --version | tr -d '\r')
|
||||
echo "$out"
|
||||
[[ "$out" == "$binary $HORIZON_VERSION" ]] || { echo "::error::expected $binary $HORIZON_VERSION"; exit 1; }
|
||||
done
|
||||
- name: Tools from the runner
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Resources prepared from the runner's own Minecraft
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
- name: The game runs (offscreen, a new world, 1500 frames)
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# From a scratch folder: the game keeps its saves and options in the folder it starts in.
|
||||
home="$RUNNER_TEMP/horizon-smoke"
|
||||
rm -rf "$home" && mkdir -p "$home"
|
||||
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
|
||||
client="$PWD/target/release/horizon-client"
|
||||
resources="$PWD/.local/horizon-client/$version"
|
||||
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
|
||||
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
|
||||
--smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; }
|
||||
python3 - "$home/smoke.json" <<'EOF'
|
||||
import json, sys
|
||||
report = json.load(open(sys.argv[1]))
|
||||
print(json.dumps(report))
|
||||
if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \
|
||||
or not report.get("meshed_sections") or not report.get("player"):
|
||||
sys.exit("the smoke run did not draw a world")
|
||||
EOF
|
||||
# Published with the release; the publish job checks it again before it publishes anything.
|
||||
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: runner.environment == 'self-hosted'
|
||||
with:
|
||||
name: smoke-test.json
|
||||
path: ${{ runner.temp }}/smoke-test.json
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: original-exporters
|
||||
path: exporters
|
||||
- name: Package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
cd dist
|
||||
rm -f "${{ matrix.asset }}"
|
||||
case "${{ matrix.asset }}" in
|
||||
*.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;;
|
||||
*) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null
|
||||
else
|
||||
# Without resource forks and extended attributes: plain ditto adds an
|
||||
# AppleDouble `._` file beside every file that has any.
|
||||
ditto -c -k --norsrc --noextattr --noqtn --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}"
|
||||
if unzip -Z1 "${{ matrix.asset }}" | grep -E '(^|/)(\._|__MACOSX/)'; then
|
||||
echo "::error::macOS metadata files in ${{ matrix.asset }}"; exit 1
|
||||
fi
|
||||
fi ;;
|
||||
esac
|
||||
ls -l "${{ matrix.asset }}"
|
||||
- name: Notarized
|
||||
# A zip of programs can't be stapled: Gatekeeper asks Apple the first time each one runs.
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
codesign --verify --strict "dist/${{ matrix.dir }}/$binary"
|
||||
done
|
||||
packaging/macos/notarize.sh "dist/${{ matrix.asset }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: dist/${{ matrix.asset }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: The server build (the game without sound or controllers, as horizon-engine)
|
||||
if: matrix.server_asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
|
||||
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
|
||||
# and with code mods like the client.
|
||||
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
|
||||
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
|
||||
folder="dist/${{ matrix.server_dir }}"
|
||||
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
|
||||
"$folder/horizon-engine" --help | grep -q -- '--resources'
|
||||
[[ "$("$folder/horizon-server" --version)" == "horizon-server $HORIZON_VERSION" ]]
|
||||
cd dist
|
||||
rm -f "${{ matrix.server_asset }}"
|
||||
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
|
||||
ls -l "${{ matrix.server_asset }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: matrix.server_asset
|
||||
with:
|
||||
name: ${{ matrix.server_asset }}
|
||||
path: dist/${{ matrix.server_asset }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: The signing keychain, locked again
|
||||
if: always() && runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
|
||||
run: packaging/macos/keychain.sh lock
|
||||
# Every platform's archive, and macOS's smoke test (game-build.yml).
|
||||
uses: ./.github/workflows/game-build.yml
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head }}
|
||||
version: ${{ needs.decide.outputs.version }}
|
||||
|
||||
sign:
|
||||
needs: [decide, build]
|
||||
|
||||
@@ -61,6 +61,16 @@ fn f32_tag(v: f32) -> Tag {
|
||||
Tag::Float(v.to_bits())
|
||||
}
|
||||
|
||||
/// `TheEndGatewayBlockEntity.loadAdditional`: BlockPos.CODEC and Level.isInSpawnableBounds.
|
||||
pub fn gateway_exit(tag: &Tag) -> Option<[i32; 3]> {
|
||||
let Tag::IntArray(v) = tag else { return None };
|
||||
let p: [i32; 3] = v.as_slice().try_into().ok()?;
|
||||
((-30_000_000..30_000_000).contains(&p[0])
|
||||
&& (-20_000_000..20_000_000).contains(&p[1])
|
||||
&& (-30_000_000..30_000_000).contains(&p[2]))
|
||||
.then_some(p)
|
||||
}
|
||||
|
||||
/// What a block entity reads and writes, in `saveAdditional` order.
|
||||
struct Fields<'a> {
|
||||
input: &'a Tag,
|
||||
@@ -424,10 +434,7 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
|
||||
}
|
||||
"minecraft:end_gateway" => {
|
||||
f.long("Age", 0);
|
||||
f.keep("exit_portal", |t| {
|
||||
is_pos(t)
|
||||
&& matches!(t, Tag::IntArray(v) if v[1].abs() <= 30_000_000 && v[0].abs() <= 30_000_000 && v[2].abs() <= 30_000_000)
|
||||
});
|
||||
f.keep("exit_portal", |t| gateway_exit(t).is_some());
|
||||
if flag(tag.get("ExactTeleport")) == Some(true) {
|
||||
f.put("ExactTeleport", boolean(true));
|
||||
}
|
||||
|
||||
@@ -173,6 +173,22 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
if let Some(e) = o.get("click_event").and_then(Value::as_object) {
|
||||
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
|
||||
style.click = match e.get("action").and_then(Value::as_str) {
|
||||
// ClickEvent.OpenUrl.CODEC: ExtraCodecs.UNTRUSTED_URI allows http and
|
||||
// https only, the scheme in any case (Util.parseAndValidateUntrustedUri).
|
||||
Some("open_url") => text("url")
|
||||
.filter(|u| {
|
||||
u.split_once(':').is_some_and(|(scheme, _)| {
|
||||
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
|
||||
})
|
||||
})
|
||||
.map(crate::text::ClickEvent::OpenUrl),
|
||||
// ClickEvent.ChangePage.CODEC: ExtraCodecs.POSITIVE_INT.
|
||||
Some("change_page") => e
|
||||
.get("page")
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|p| i32::try_from(p).ok())
|
||||
.filter(|p| *p >= 1)
|
||||
.map(crate::text::ClickEvent::ChangePage),
|
||||
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
|
||||
Some("copy_to_clipboard") => {
|
||||
text("value").map(crate::text::ClickEvent::CopyToClipboard)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! The client's lowest layer, below commands and gameplay: chat text and
|
||||
//! translations, SNBT and NBT paths, text components, the scoreboard, boss
|
||||
//! bars, game rules, block entity NBT tables, and the registry, particle and
|
||||
//! sound source tables commands name. horizon-client re-exports each module
|
||||
//! under its old path (`horizon_client::text`, ...).
|
||||
//! sound source tables commands name, and the `pack.mcmeta` filter regex.
|
||||
//! horizon-client re-exports each module under its old path
|
||||
//! (`horizon_client::text`, ...).
|
||||
#![forbid(unsafe_code)]
|
||||
pub mod block_entity_nbt;
|
||||
pub mod bossbar;
|
||||
@@ -14,6 +15,8 @@ pub mod key_display;
|
||||
pub mod lang;
|
||||
pub mod nbt_path;
|
||||
pub mod particle_type;
|
||||
pub mod pattern;
|
||||
pub mod potions;
|
||||
pub mod registries;
|
||||
pub mod scoreboard;
|
||||
pub mod snbt;
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,249 @@
|
||||
//! The `MobEffects` and `Potions` registries (code in vanilla, ported as
|
||||
//! tables): each effect's colour and category, each potion's effects, and the
|
||||
//! potion colour mix. horizon-client re-exports them from `brewing`.
|
||||
use std::{collections::HashMap, sync::OnceLock};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Category {
|
||||
Beneficial,
|
||||
Harmful,
|
||||
Neutral,
|
||||
}
|
||||
/// A `MobEffect`: id (without namespace), colour, category, instantaneous.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct EffectDef {
|
||||
pub id: &'static str,
|
||||
pub color: u32,
|
||||
pub category: Category,
|
||||
pub instant: bool,
|
||||
}
|
||||
const fn fx(id: &'static str, color: u32, category: Category) -> EffectDef {
|
||||
EffectDef {
|
||||
id,
|
||||
color,
|
||||
category,
|
||||
instant: false,
|
||||
}
|
||||
}
|
||||
use Category::{Beneficial as B, Harmful as H, Neutral as N};
|
||||
/// `MobEffects` in registration order.
|
||||
pub const EFFECTS: &[EffectDef] = &[
|
||||
fx("speed", 3402751, B),
|
||||
fx("slowness", 9154528, H),
|
||||
fx("haste", 14270531, B),
|
||||
fx("mining_fatigue", 4866583, H),
|
||||
fx("strength", 16762624, B),
|
||||
EffectDef {
|
||||
id: "instant_health",
|
||||
color: 16262179,
|
||||
category: B,
|
||||
instant: true,
|
||||
},
|
||||
EffectDef {
|
||||
id: "instant_damage",
|
||||
color: 11101546,
|
||||
category: H,
|
||||
instant: true,
|
||||
},
|
||||
fx("jump_boost", 16646020, B),
|
||||
fx("nausea", 5578058, H),
|
||||
fx("regeneration", 13458603, B),
|
||||
fx("resistance", 9520880, B),
|
||||
fx("fire_resistance", 16750848, B),
|
||||
fx("water_breathing", 10017472, B),
|
||||
fx("invisibility", 16185078, B),
|
||||
fx("blindness", 2039587, H),
|
||||
fx("night_vision", 12779366, B),
|
||||
fx("hunger", 5797459, H),
|
||||
fx("weakness", 4738376, H),
|
||||
fx("poison", 8889187, H),
|
||||
fx("wither", 7561558, H),
|
||||
fx("health_boost", 16284963, B),
|
||||
fx("absorption", 2445989, B),
|
||||
EffectDef {
|
||||
id: "saturation",
|
||||
color: 16262179,
|
||||
category: B,
|
||||
instant: true,
|
||||
},
|
||||
fx("glowing", 9740385, N),
|
||||
fx("levitation", 13565951, H),
|
||||
fx("luck", 5882118, B),
|
||||
fx("unluck", 12624973, H),
|
||||
fx("slow_falling", 15978425, B),
|
||||
fx("conduit_power", 1950417, B),
|
||||
fx("dolphins_grace", 8954814, B),
|
||||
fx("bad_omen", 745784, N),
|
||||
fx("hero_of_the_village", 4521796, B),
|
||||
fx("darkness", 2696993, H),
|
||||
fx("trial_omen", 1484454, N),
|
||||
fx("raid_omen", 14565464, N),
|
||||
fx("wind_charged", 12438015, H),
|
||||
fx("weaving", 7891290, H),
|
||||
fx("oozing", 10092451, H),
|
||||
fx("infested", 9214860, H),
|
||||
fx("breath_of_the_nautilus", 65518, B),
|
||||
];
|
||||
pub fn effect(id: &str) -> Option<&'static EffectDef> {
|
||||
let id = id.trim_start_matches("minecraft:");
|
||||
EFFECTS.iter().find(|e| e.id == id)
|
||||
}
|
||||
/// Namespaced effect ids for mob effect storage.
|
||||
pub fn namespaced(id: &'static str) -> &'static str {
|
||||
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
IDS.get_or_init(|| {
|
||||
EFFECTS
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
e.id,
|
||||
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.get(id)
|
||||
.copied()
|
||||
.unwrap_or(id)
|
||||
}
|
||||
/// A `Potion`: id, name suffix (`item.minecraft.potion.effect.<name>`) and
|
||||
/// effects as (effect, duration ticks, amplifier).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct PotionDef {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub effects: &'static [(&'static str, i32, i32)],
|
||||
}
|
||||
const fn p(
|
||||
id: &'static str,
|
||||
name: &'static str,
|
||||
effects: &'static [(&'static str, i32, i32)],
|
||||
) -> PotionDef {
|
||||
PotionDef { id, name, effects }
|
||||
}
|
||||
/// `Potions` in registration order.
|
||||
pub const POTIONS: &[PotionDef] = &[
|
||||
p("water", "water", &[]),
|
||||
p("mundane", "mundane", &[]),
|
||||
p("thick", "thick", &[]),
|
||||
p("awkward", "awkward", &[]),
|
||||
p("night_vision", "night_vision", &[("night_vision", 3600, 0)]),
|
||||
p(
|
||||
"long_night_vision",
|
||||
"night_vision",
|
||||
&[("night_vision", 9600, 0)],
|
||||
),
|
||||
p("invisibility", "invisibility", &[("invisibility", 3600, 0)]),
|
||||
p(
|
||||
"long_invisibility",
|
||||
"invisibility",
|
||||
&[("invisibility", 9600, 0)],
|
||||
),
|
||||
p("leaping", "leaping", &[("jump_boost", 3600, 0)]),
|
||||
p("long_leaping", "leaping", &[("jump_boost", 9600, 0)]),
|
||||
p("strong_leaping", "leaping", &[("jump_boost", 1800, 1)]),
|
||||
p(
|
||||
"fire_resistance",
|
||||
"fire_resistance",
|
||||
&[("fire_resistance", 3600, 0)],
|
||||
),
|
||||
p(
|
||||
"long_fire_resistance",
|
||||
"fire_resistance",
|
||||
&[("fire_resistance", 9600, 0)],
|
||||
),
|
||||
p("swiftness", "swiftness", &[("speed", 3600, 0)]),
|
||||
p("long_swiftness", "swiftness", &[("speed", 9600, 0)]),
|
||||
p("strong_swiftness", "swiftness", &[("speed", 1800, 1)]),
|
||||
p("slowness", "slowness", &[("slowness", 1800, 0)]),
|
||||
p("long_slowness", "slowness", &[("slowness", 4800, 0)]),
|
||||
p("strong_slowness", "slowness", &[("slowness", 400, 3)]),
|
||||
p(
|
||||
"turtle_master",
|
||||
"turtle_master",
|
||||
&[("slowness", 400, 3), ("resistance", 400, 2)],
|
||||
),
|
||||
p(
|
||||
"long_turtle_master",
|
||||
"turtle_master",
|
||||
&[("slowness", 800, 3), ("resistance", 800, 2)],
|
||||
),
|
||||
p(
|
||||
"strong_turtle_master",
|
||||
"turtle_master",
|
||||
&[("slowness", 400, 5), ("resistance", 400, 3)],
|
||||
),
|
||||
p(
|
||||
"water_breathing",
|
||||
"water_breathing",
|
||||
&[("water_breathing", 3600, 0)],
|
||||
),
|
||||
p(
|
||||
"long_water_breathing",
|
||||
"water_breathing",
|
||||
&[("water_breathing", 9600, 0)],
|
||||
),
|
||||
p("healing", "healing", &[("instant_health", 1, 0)]),
|
||||
p("strong_healing", "healing", &[("instant_health", 1, 1)]),
|
||||
p("harming", "harming", &[("instant_damage", 1, 0)]),
|
||||
p("strong_harming", "harming", &[("instant_damage", 1, 1)]),
|
||||
p("poison", "poison", &[("poison", 900, 0)]),
|
||||
p("long_poison", "poison", &[("poison", 1800, 0)]),
|
||||
p("strong_poison", "poison", &[("poison", 432, 1)]),
|
||||
p("regeneration", "regeneration", &[("regeneration", 900, 0)]),
|
||||
p(
|
||||
"long_regeneration",
|
||||
"regeneration",
|
||||
&[("regeneration", 1800, 0)],
|
||||
),
|
||||
p(
|
||||
"strong_regeneration",
|
||||
"regeneration",
|
||||
&[("regeneration", 450, 1)],
|
||||
),
|
||||
p("strength", "strength", &[("strength", 3600, 0)]),
|
||||
p("long_strength", "strength", &[("strength", 9600, 0)]),
|
||||
p("strong_strength", "strength", &[("strength", 1800, 1)]),
|
||||
p("weakness", "weakness", &[("weakness", 1800, 0)]),
|
||||
p("long_weakness", "weakness", &[("weakness", 4800, 0)]),
|
||||
p("luck", "luck", &[("luck", 6000, 0)]),
|
||||
p("slow_falling", "slow_falling", &[("slow_falling", 1800, 0)]),
|
||||
p(
|
||||
"long_slow_falling",
|
||||
"slow_falling",
|
||||
&[("slow_falling", 4800, 0)],
|
||||
),
|
||||
p("wind_charged", "wind_charged", &[("wind_charged", 3600, 0)]),
|
||||
p("weaving", "weaving", &[("weaving", 3600, 0)]),
|
||||
p("oozing", "oozing", &[("oozing", 3600, 0)]),
|
||||
p("infested", "infested", &[("infested", 3600, 0)]),
|
||||
];
|
||||
pub fn potion(id: &str) -> Option<&'static PotionDef> {
|
||||
let id = id.trim_start_matches("minecraft:");
|
||||
POTIONS.iter().find(|p| p.id == id)
|
||||
}
|
||||
/// `PotionContents.BASE_POTION_COLOR` (ARGB -13083194) as RGB.
|
||||
pub const BASE_POTION_COLOR: u32 = 0x385DC6;
|
||||
/// `PotionContents.getColor`: amplifier-weighted mix of visible effect colours.
|
||||
pub fn potion_color(id: Option<&str>) -> u32 {
|
||||
let Some(def) = id.and_then(potion) else {
|
||||
return BASE_POTION_COLOR;
|
||||
};
|
||||
let (mut r, mut g, mut b, mut total) = (0u32, 0u32, 0u32, 0u32);
|
||||
for (e, _, amp) in def.effects {
|
||||
let Some(e) = effect(e) else { continue };
|
||||
let w = *amp as u32 + 1;
|
||||
r += w * (e.color >> 16 & 255);
|
||||
g += w * (e.color >> 8 & 255);
|
||||
b += w * (e.color & 255);
|
||||
total += w;
|
||||
}
|
||||
match (
|
||||
r.checked_div(total),
|
||||
g.checked_div(total),
|
||||
b.checked_div(total),
|
||||
) {
|
||||
(Some(r), Some(g), Some(b)) => (r << 16) | (g << 8) | b,
|
||||
_ => BASE_POTION_COLOR,
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,10 @@ pub fn named_color(name: &str) -> Option<u32> {
|
||||
/// Click actions carried by locally constructed text components.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ClickEvent {
|
||||
/// `ClickEvent.OpenUrl`: an http or https link.
|
||||
OpenUrl(String),
|
||||
/// `ClickEvent.ChangePage`: a book's page, counted from 1.
|
||||
ChangePage(i32),
|
||||
SuggestCommand(String),
|
||||
CopyToClipboard(String),
|
||||
/// A local file to open with the system handler.
|
||||
@@ -529,6 +533,8 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
}
|
||||
if let Some(click) = &style.click {
|
||||
let event = match click {
|
||||
ClickEvent::OpenUrl(url) => json!({ "action": "open_url", "url": url }),
|
||||
ClickEvent::ChangePage(page) => json!({ "action": "change_page", "page": page }),
|
||||
ClickEvent::SuggestCommand(command) => {
|
||||
json!({ "action": "suggest_command", "command": command })
|
||||
}
|
||||
|
||||
@@ -580,7 +580,7 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
|
||||
match e {
|
||||
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
|
||||
// ServerExplosion.hurtEntities: within twice the radius every hit does damage.
|
||||
MobEvent::Explode(at, radius) => {
|
||||
MobEvent::Explode(at, radius, _) => {
|
||||
let d = Vec3::new(at[0], at[1], at[2])
|
||||
.distance_squared(p.position)
|
||||
.sqrt();
|
||||
@@ -1050,12 +1050,15 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
off_hand: None,
|
||||
head: None,
|
||||
armor_cover: 0.0,
|
||||
invisible: false,
|
||||
spectator: false,
|
||||
gold_armor: false,
|
||||
hurt_by: None,
|
||||
last_hurt: None,
|
||||
velocity: Vec3::ZERO,
|
||||
yaw: 0.0,
|
||||
sprinting: false,
|
||||
riding: false,
|
||||
hero: false,
|
||||
effects: Vec::new(),
|
||||
uuid: PLAYER_UUID,
|
||||
@@ -1071,6 +1074,8 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
for (i, m) in mobs.iter().enumerate() {
|
||||
print(&mut out, 0, i, m, s.creative, world.game_time);
|
||||
}
|
||||
// No player has a chest open in the scenarios.
|
||||
let lid_openers = std::collections::HashMap::new();
|
||||
// The MobWorld every step sees, over a snapshot of the mobs.
|
||||
macro_rules! mob_world {
|
||||
($snapshot:expr) => {
|
||||
@@ -1082,6 +1087,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
world: &world,
|
||||
registry,
|
||||
facts: &facts,
|
||||
fluid_tables: &motion.fluids,
|
||||
movement: &motion.blocks,
|
||||
collision: &collision,
|
||||
data: &data,
|
||||
@@ -1099,12 +1105,14 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
block_tags: &items.block_tags,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
mob_drops: true,
|
||||
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
|
||||
end: false,
|
||||
world_seed: 0,
|
||||
// FlatLevelSource.getSeaLevel.
|
||||
sea_level: -63,
|
||||
items: Some(&items),
|
||||
lid_openers: &lid_openers,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -437,6 +437,8 @@ pub struct EntityView {
|
||||
/// mainhand, offhand, head, chest, legs, feet, body.
|
||||
pub equipment: [Option<ItemView>; 7],
|
||||
pub vehicle: Option<Box<EntityView>>,
|
||||
/// Player: what it looks at within 100 blocks (`PlayerPredicate.lookingAt`).
|
||||
pub looking_at: Option<Box<EntityView>>,
|
||||
pub effects: Vec<(String, i32, i32)>,
|
||||
/// Player: game mode name.
|
||||
pub gamemode: Option<String>,
|
||||
@@ -458,7 +460,7 @@ fn range_f(v: &Value, x: f64) -> bool {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn range_i(v: &Value, x: i64) -> bool {
|
||||
pub fn range_i(v: &Value, x: i64) -> bool {
|
||||
range_f(v, x as f64)
|
||||
}
|
||||
/// A registry id, `#tag`, or list matched against one id.
|
||||
@@ -473,6 +475,51 @@ pub fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// `PotionsPredicate`: the potion in a `HolderSet`, plus a `CollectionPredicate`
|
||||
/// over the potion's effects (`contains`, `count`, `size`).
|
||||
pub fn potions(v: &Value, potion: Option<&str>, w: &dyn World) -> bool {
|
||||
if v.is_null() {
|
||||
return true;
|
||||
}
|
||||
if !v["potions"].is_null() && !potion.is_some_and(|p| id_matches(&v["potions"], "potion", p, w))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let c = &v["effects"];
|
||||
if c.is_null() {
|
||||
return true;
|
||||
}
|
||||
let effects: Vec<(String, i32, i32)> = potion
|
||||
.and_then(crate::brewing::potion)
|
||||
.map(|d| {
|
||||
d.effects
|
||||
.iter()
|
||||
.map(|(id, dur, amp)| (format!("minecraft:{id}"), *amp, *dur))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// `MobEffectsPredicate.test`: the one listed effect, amplifier and duration.
|
||||
let test = |pred: &Value, e: &(String, i32, i32)| {
|
||||
pred.as_object().is_some_and(|m| {
|
||||
m.get(&e.0).is_some_and(|want| {
|
||||
range_i(&want["amplifier"], i64::from(e.1))
|
||||
&& range_i(&want["duration"], i64::from(e.2))
|
||||
&& want["ambient"].as_bool().is_none_or(|b| !b)
|
||||
&& want["visible"].as_bool().is_none_or(|b| b)
|
||||
})
|
||||
})
|
||||
};
|
||||
c["contains"]
|
||||
.as_array()
|
||||
.is_none_or(|l| l.iter().all(|p| effects.iter().any(|e| test(p, e))))
|
||||
&& c["count"].as_array().is_none_or(|l| {
|
||||
l.iter().all(|entry| {
|
||||
let n = effects.iter().filter(|e| test(&entry["test"], e)).count();
|
||||
range_i(&entry["count"], n as i64)
|
||||
})
|
||||
})
|
||||
&& range_i(&c["size"], effects.len() as i64)
|
||||
}
|
||||
/// `ItemPredicate`.
|
||||
pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
|
||||
if v.is_null() {
|
||||
@@ -614,6 +661,18 @@ fn components(expected: &Value, have: &[(String, String)]) -> bool {
|
||||
})
|
||||
})
|
||||
}
|
||||
/// `DistancePredicate.matches` between two points.
|
||||
pub fn distance(v: &Value, a: [f64; 3], b: [f64; 3]) -> bool {
|
||||
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
range_f(&v["x"], d[0].abs())
|
||||
&& range_f(&v["y"], d[1].abs())
|
||||
&& range_f(&v["z"], d[2].abs())
|
||||
&& range_f(&v["horizontal"], (d[0] * d[0] + d[2] * d[2]).sqrt())
|
||||
&& range_f(
|
||||
&v["absolute"],
|
||||
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(),
|
||||
)
|
||||
}
|
||||
/// `EntityPredicate` (26.x keyed sub-predicates).
|
||||
pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> bool {
|
||||
if v.is_null() {
|
||||
@@ -629,21 +688,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
"minecraft:stepping_on" => {
|
||||
location(p, [e.position[0], e.position[1] - 0.5, e.position[2]], w)
|
||||
}
|
||||
"minecraft:distance" | "distance" => {
|
||||
let d = [
|
||||
e.position[0] - origin[0],
|
||||
e.position[1] - origin[1],
|
||||
e.position[2] - origin[2],
|
||||
];
|
||||
range_f(&p["x"], d[0].abs())
|
||||
&& range_f(&p["y"], d[1].abs())
|
||||
&& range_f(&p["z"], d[2].abs())
|
||||
&& range_f(&p["horizontal"], (d[0] * d[0] + d[2] * d[2]).sqrt())
|
||||
&& range_f(
|
||||
&p["absolute"],
|
||||
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(),
|
||||
)
|
||||
}
|
||||
"minecraft:distance" | "distance" => distance(p, e.position, origin),
|
||||
"minecraft:flags" | "flags" => {
|
||||
let f = |k: &str, have: bool| p[k].as_bool().is_none_or(|want| want == have);
|
||||
f("is_on_fire", e.on_fire)
|
||||
@@ -678,11 +723,16 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
})
|
||||
})
|
||||
}),
|
||||
"minecraft:type_specific/player" => p["gamemode"].as_array().is_none_or(|modes| {
|
||||
e.gamemode
|
||||
.as_deref()
|
||||
.is_some_and(|g| modes.iter().any(|m| m.as_str() == Some(g)))
|
||||
}),
|
||||
"minecraft:type_specific/player" => {
|
||||
p["gamemode"].as_array().is_none_or(|modes| {
|
||||
e.gamemode
|
||||
.as_deref()
|
||||
.is_some_and(|g| modes.iter().any(|m| m.as_str() == Some(g)))
|
||||
}) && (p["looking_at"].is_null()
|
||||
|| e.looking_at
|
||||
.as_ref()
|
||||
.is_some_and(|t| entity(&p["looking_at"], t, w, origin)))
|
||||
}
|
||||
"minecraft:type_specific/lightning" => {
|
||||
range_i(&p["blocks_set_on_fire"], 0)
|
||||
&& (p["entity_struck"].is_null()
|
||||
@@ -703,6 +753,52 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
}
|
||||
true
|
||||
}
|
||||
/// A damage event as `DamagePredicate` sees it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DamageView {
|
||||
/// The damage type id (`minecraft:arrow`).
|
||||
pub kind: String,
|
||||
/// `DamageSource.getDirectEntity` and `getEntity`.
|
||||
pub direct: Option<EntityView>,
|
||||
pub source: Option<EntityView>,
|
||||
/// `DamageSource.isDirect`: the direct entity is the causing one.
|
||||
pub is_direct: bool,
|
||||
/// The damage before and after the shield took its share.
|
||||
pub original: f32,
|
||||
pub actual: f32,
|
||||
pub blocked: bool,
|
||||
}
|
||||
/// `DamagePredicate`, with its `DamageSourcePredicate` as `type`.
|
||||
pub fn damage(v: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> bool {
|
||||
if v.is_null() {
|
||||
return true;
|
||||
}
|
||||
let by = |key: &str, e: &Option<EntityView>| {
|
||||
v[key].is_null() || e.as_ref().is_some_and(|e| entity(&v[key], e, w, origin))
|
||||
};
|
||||
if !range_f(&v["dealt"], f64::from(d.original))
|
||||
|| !range_f(&v["taken"], f64::from(d.actual))
|
||||
|| !by("source_entity", &d.source)
|
||||
|| v["blocked"].as_bool().is_some_and(|b| b != d.blocked)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let ty = &v["type"];
|
||||
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);
|
||||
w.in_tag("damage_type", tag, &d.kind) == t["expected"].as_bool().unwrap_or(true)
|
||||
});
|
||||
let direct_ok = ty["direct_entity"].is_null()
|
||||
|| d.direct
|
||||
.as_ref()
|
||||
.is_some_and(|e| entity(&ty["direct_entity"], e, w, origin));
|
||||
let source_ok = ty["source_entity"].is_null()
|
||||
|| d.source
|
||||
.as_ref()
|
||||
.is_some_and(|e| entity(&ty["source_entity"], e, w, origin));
|
||||
tags_ok && direct_ok && source_ok && ty["is_direct"].as_bool().is_none_or(|b| b == d.is_direct)
|
||||
}
|
||||
/// The entities a loot-style condition list may refer to.
|
||||
pub struct Entities<'a> {
|
||||
pub this: Option<&'a EntityView>,
|
||||
|
||||
@@ -36,8 +36,9 @@ enum Animate {
|
||||
spawn_particles: bool,
|
||||
},
|
||||
/// Leaves: optional falling-leaf particle and chance; a tinted leaf takes
|
||||
/// the biome foliage colour unless a fixed colour is given (azalea).
|
||||
Leaves(Option<(T, f32)>, Option<u32>),
|
||||
/// the biome foliage colour unless a fixed colour is given (azalea); and
|
||||
/// whether they have the poplar ambient sound (`AmbientLeavesBlockSoundPlayer`).
|
||||
Leaves(Option<(T, f32)>, Option<u32>, bool),
|
||||
NetherPortal,
|
||||
EnchantingTable,
|
||||
EndRod([i32; 3]),
|
||||
@@ -48,8 +49,9 @@ enum Animate {
|
||||
CandleCake,
|
||||
BrewingStand,
|
||||
EnderChest,
|
||||
/// FallingBlock with its dust colour.
|
||||
Falling(u32),
|
||||
/// FallingBlock with its dust colour; sand also has the ambient desert sound
|
||||
/// (`SandBlock.animateTick`).
|
||||
Falling(u32, bool),
|
||||
/// A beehive or bee nest full of honey (`BeehiveBlock.animateTick`).
|
||||
Beehive,
|
||||
RedstoneOre,
|
||||
@@ -109,17 +111,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
spawn_particles: false,
|
||||
},
|
||||
"oak_leaves" | "birch_leaves" | "jungle_leaves" | "acacia_leaves" | "dark_oak_leaves"
|
||||
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None),
|
||||
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None),
|
||||
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None),
|
||||
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None),
|
||||
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None),
|
||||
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None),
|
||||
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None, false),
|
||||
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None, false),
|
||||
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None, false),
|
||||
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None, true),
|
||||
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None, true),
|
||||
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None, true),
|
||||
// The azalea blocks drop tinted leaves of the fixed colour -9399763.
|
||||
"azalea_leaves" | "flowering_azalea_leaves" => {
|
||||
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D))
|
||||
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D), false)
|
||||
}
|
||||
"spruce_leaves" => Animate::Leaves(None, None),
|
||||
"spruce_leaves" => Animate::Leaves(None, None, false),
|
||||
"nether_portal" => Animate::NetherPortal,
|
||||
"enchanting_table" => Animate::EnchantingTable,
|
||||
"end_rod" => Animate::EndRod(facing()),
|
||||
@@ -128,16 +130,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
"mycelium" => Animate::Mycelium,
|
||||
"brewing_stand" => Animate::BrewingStand,
|
||||
"ender_chest" => Animate::EnderChest,
|
||||
"sand" => Animate::Falling(0xDBD3A0),
|
||||
"red_sand" => Animate::Falling(0xA95821),
|
||||
"gravel" => Animate::Falling(0x807C7B),
|
||||
"sand" => Animate::Falling(0xDBD3A0, true),
|
||||
"red_sand" => Animate::Falling(0xA95821, true),
|
||||
"gravel" => Animate::Falling(0x807C7B, false),
|
||||
// AnvilBlock/ConcretePowderBlock.getDustColor is the map colour;
|
||||
// DragonEggBlock's is black.
|
||||
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7),
|
||||
"dragon_egg" => Animate::Falling(0x000000),
|
||||
_ if name.ends_with("_concrete_powder") => Animate::Falling(concrete_powder_color(
|
||||
name.trim_end_matches("_concrete_powder"),
|
||||
)),
|
||||
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7, false),
|
||||
"dragon_egg" => Animate::Falling(0x000000, false),
|
||||
_ if name.ends_with("_concrete_powder") => Animate::Falling(
|
||||
concrete_powder_color(name.trim_end_matches("_concrete_powder")),
|
||||
false,
|
||||
),
|
||||
"beehive" | "bee_nest" if prop("honey_level").as_deref() == Some("5") => Animate::Beehive,
|
||||
"redstone_ore" | "deepslate_redstone_ore" if lit => Animate::RedstoneOre,
|
||||
"redstone_wire" => {
|
||||
@@ -171,8 +174,8 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
"end_portal" => Animate::EndPortal,
|
||||
"wet_sponge" => Animate::WetSponge,
|
||||
// BrushableBlock has no dust colour of its own, so the dust is the map colour.
|
||||
"suspicious_sand" => Animate::Falling(0xF7E9A3),
|
||||
"suspicious_gravel" => Animate::Falling(0x707070),
|
||||
"suspicious_sand" => Animate::Falling(0xF7E9A3, false),
|
||||
"suspicious_gravel" => Animate::Falling(0x707070, false),
|
||||
"sculk_sensor" | "calibrated_sculk_sensor"
|
||||
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
|
||||
{
|
||||
@@ -239,6 +242,10 @@ pub struct AnimateTables {
|
||||
power_transmitter: Vec<bool>,
|
||||
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
|
||||
dried_ghast_sounds: Vec<bool>,
|
||||
/// `#triggers_ambient_desert_sand_block_sounds` (sand, red sand).
|
||||
desert_sand_sounds: Vec<bool>,
|
||||
/// `#required_for_poplar_leaf_ambience` (overworld natural logs).
|
||||
poplar_leaf_logs: Vec<bool>,
|
||||
/// `FireBlock.canBurn`: states with ignite odds above zero (none waterlogged).
|
||||
burnable: Vec<bool>,
|
||||
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
|
||||
@@ -314,6 +321,8 @@ impl AnimateTables {
|
||||
power_provider: by_state("minecraft:enchantment_power_provider"),
|
||||
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
|
||||
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
|
||||
desert_sand_sounds: by_state("minecraft:triggers_ambient_desert_sand_block_sounds"),
|
||||
poplar_leaf_logs: by_state("minecraft:required_for_poplar_leaf_ambience"),
|
||||
burnable,
|
||||
ambient,
|
||||
})
|
||||
@@ -402,6 +411,10 @@ impl AnimateContext<'_> {
|
||||
fn add(engine: &mut ParticleEngine, ty: T, p: [f64; 3], v: [f64; 3]) {
|
||||
engine.add(&ParticleOptions::simple(ty), p, v);
|
||||
}
|
||||
/// `Level.addAlwaysVisibleParticle`: still shown under the Minimal option now and then.
|
||||
fn add_visible(engine: &mut ParticleEngine, ty: T, p: [f64; 3], v: [f64; 3]) {
|
||||
engine.add_with(&ParticleOptions::simple(ty), p, v, false, true);
|
||||
}
|
||||
fn sound(engine: &mut ParticleEngine, name: &'static str, p: [f64; 3], volume: f32, pitch: f32) {
|
||||
engine.sounds.push((name, p, volume, pitch));
|
||||
}
|
||||
@@ -777,7 +790,7 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::Leaves(falling, fixed_tint) => {
|
||||
Animate::Leaves(falling, fixed_tint, poplar) => {
|
||||
let below = [pos[0], pos[1] - 1, pos[2]];
|
||||
// LeavesBlock.makeDrippingWaterParticles.
|
||||
if (ctx.raining_at)([pos[0], pos[1] + 1, pos[2]]) && next_int(engine.random(), 15) == 1
|
||||
@@ -789,6 +802,9 @@ fn block_animate_tick(
|
||||
add(engine, T::DrippingWater, p, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
if poplar {
|
||||
leaves_ambient_sound(tables, engine, ctx, state, pos);
|
||||
}
|
||||
// FallingParticlesLeavesBlock.makeFallingLeavesParticles.
|
||||
if let Some((ty, chance)) = falling
|
||||
&& engine.random().next_float() < chance
|
||||
@@ -961,7 +977,7 @@ fn block_animate_tick(
|
||||
fy + 1.0 + f64::from(r.next_float()),
|
||||
fz + f64::from(r.next_float()),
|
||||
];
|
||||
add(engine, T::SulfurBubbles, p, [0.0; 3]);
|
||||
add_visible(engine, T::SulfurBubbles, p, [0.0; 3]);
|
||||
}
|
||||
if next_int(engine.random(), 10) == 0 {
|
||||
let name = "minecraft:block.potent_sulfur.noxious_gas";
|
||||
@@ -1081,7 +1097,7 @@ fn block_animate_tick(
|
||||
add(engine, T::Portal, p, v);
|
||||
}
|
||||
}
|
||||
Animate::Falling(color) => {
|
||||
Animate::Falling(color, sand) => {
|
||||
if next_int(engine.random(), 16) == 0 {
|
||||
let below = ctx.facts.get(ctx.state([pos[0], pos[1] - 1, pos[2]]));
|
||||
// FallingBlock.isFree: air, fire, liquids or replaceable.
|
||||
@@ -1098,6 +1114,14 @@ fn block_animate_tick(
|
||||
);
|
||||
}
|
||||
}
|
||||
// AmbientDesertBlockSoundsPlayer.playAmbientSandSounds: only for the air block.
|
||||
if sand
|
||||
&& ctx.state([pos[0], pos[1] + 1, pos[2]]) == ctx.world.air
|
||||
&& next_int(engine.random(), 2100) == 0
|
||||
&& sand_sound_nearby(tables, ctx, pos)
|
||||
{
|
||||
sound(engine, "minecraft:block.sand.idle", [fx, fy, fz], 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
Animate::Beehive => {
|
||||
// BeehiveBlock.animateTick: nextInt(1) + 1 attempts.
|
||||
@@ -1215,7 +1239,7 @@ fn block_animate_tick(
|
||||
}
|
||||
Animate::BubbleColumn { drag } => {
|
||||
if drag {
|
||||
add(engine, T::CurrentDown, [fx + 0.5, fy + 0.8, fz], [0.0; 3]);
|
||||
add_visible(engine, T::CurrentDown, [fx + 0.5, fy + 0.8, fz], [0.0; 3]);
|
||||
let r = engine.random();
|
||||
if next_int(r, 200) == 0 {
|
||||
let (volume, pitch) = (0.2 + r.next_float() * 0.2, 0.9 + r.next_float() * 0.15);
|
||||
@@ -1228,7 +1252,7 @@ fn block_animate_tick(
|
||||
);
|
||||
}
|
||||
} else {
|
||||
add(
|
||||
add_visible(
|
||||
engine,
|
||||
T::BubbleColumnUp,
|
||||
[fx + 0.5, fy, fz + 0.5],
|
||||
@@ -1240,7 +1264,7 @@ fn block_animate_tick(
|
||||
fy + f64::from(r.next_float()),
|
||||
fz + f64::from(r.next_float()),
|
||||
];
|
||||
add(engine, T::BubbleColumnUp, p, [0.0, 0.04, 0.0]);
|
||||
add_visible(engine, T::BubbleColumnUp, p, [0.0, 0.04, 0.0]);
|
||||
let r = engine.random();
|
||||
if next_int(r, 200) == 0 {
|
||||
let (volume, pitch) = (0.2 + r.next_float() * 0.2, 0.9 + r.next_float() * 0.15);
|
||||
@@ -1318,6 +1342,91 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AmbientLeavesBlockSoundPlayer.playAmbientLeavesSounds` for the poplar leaves:
|
||||
/// one log and three leaves of the same kind around the block.
|
||||
fn leaves_ambient_sound(
|
||||
tables: &AnimateTables,
|
||||
engine: &mut ParticleEngine,
|
||||
ctx: &AnimateContext,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
) {
|
||||
if next_int(engine.random(), 300) != 0 {
|
||||
return;
|
||||
}
|
||||
let Some(name) = ctx.registry.state(state).map(|s| &s.name) else {
|
||||
return;
|
||||
};
|
||||
let (mut logs, mut leaves) = (0, 0);
|
||||
for d in [[0, -1], [1, 0], [0, 1], [-1, 0]] {
|
||||
let neighbor = ctx.state([pos[0] + d[0], pos[1], pos[2] + d[1]]);
|
||||
if tables
|
||||
.poplar_leaf_logs
|
||||
.get(neighbor.0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
{
|
||||
logs += 1;
|
||||
}
|
||||
if ctx
|
||||
.registry
|
||||
.state(neighbor)
|
||||
.is_some_and(|s| &s.name == name)
|
||||
{
|
||||
leaves += 1;
|
||||
}
|
||||
if logs == 1 && leaves == 3 {
|
||||
let p = pos.map(f64::from);
|
||||
sound(engine, "minecraft:block.poplar_leaves.ambient", p, 1.0, 1.0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AmbientDesertBlockSoundsPlayer.shouldPlayAmbientSandSound`: sand in the
|
||||
/// surface column eight blocks away on all four sides.
|
||||
fn sand_sound_nearby(tables: &AnimateTables, ctx: &AnimateContext, pos: [i32; 3]) -> bool {
|
||||
let is_sand = |p: [i32; 3]| {
|
||||
tables
|
||||
.desert_sand_sounds
|
||||
.get(ctx.state(p).0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let (mut found, mut checked) = (0, 0);
|
||||
for d in [[0, -8], [8, 0], [0, 8], [-8, 0]] {
|
||||
let (x, z) = (pos[0] + d[0], pos[2] + d[1]);
|
||||
// columnContainsTriggeringBlock.
|
||||
let surface = ctx.world.world_surface(x, z, |s| ctx.facts.is_air(s)) - 1;
|
||||
let matches = if (surface - pos[1]).abs() > 5 {
|
||||
let mut y = pos[1] + 6;
|
||||
let mut above_air = ctx.is_air([x, y, z]);
|
||||
y -= 1;
|
||||
let mut hit = false;
|
||||
for _ in 0..10 {
|
||||
if above_air && is_sand([x, y, z]) {
|
||||
hit = true;
|
||||
break;
|
||||
}
|
||||
above_air = ctx.is_air([x, y, z]);
|
||||
y -= 1;
|
||||
}
|
||||
hit
|
||||
} else {
|
||||
ctx.is_air([x, surface + 1, z]) && is_sand([x, surface, z])
|
||||
};
|
||||
if matches {
|
||||
found += 1;
|
||||
if found > 3 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
checked += 1;
|
||||
if 4 - checked + found < 3 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `AbstractCandleBlock.addParticlesAndSound`.
|
||||
fn candle(engine: &mut ParticleEngine, p: [f64; 3]) {
|
||||
let r = engine.random();
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! `filter`, `unstitch` and `paletted_permutations` sources run in order
|
||||
//! against the resource tree; a later source replaces an earlier sprite of
|
||||
//! the same id and a filter removes the matching ones.
|
||||
use crate::packs::pattern::Pattern;
|
||||
use horizon_assets::palette::apply_palette;
|
||||
use horizon_client_core::pattern::Pattern;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
|
||||
@@ -179,7 +179,7 @@ fn agent() -> ureq::Agent {
|
||||
}
|
||||
|
||||
/// The discovery document, fetched once per game (a failure is retried next time).
|
||||
fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
|
||||
pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
|
||||
static CACHE: Mutex<Option<Arc<serde_json::Value>>> = Mutex::new(None);
|
||||
if let Some(d) = CACHE.lock().unwrap().clone() {
|
||||
return Ok(d);
|
||||
|
||||
@@ -149,6 +149,8 @@ pub fn draw_part(
|
||||
layer: 0,
|
||||
color,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -650,6 +652,8 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
layer: 0,
|
||||
color: c,
|
||||
light: call.light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -736,7 +740,7 @@ pub struct Scene<'a> {
|
||||
/// The skin sprite a `player_head` shows at a position (`SkullBlockRenderer`
|
||||
/// profile → skin): a downloaded `skins/<sha1>` slot or the profile's
|
||||
/// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
|
||||
pub skull: &'a dyn Fn([i32; 3]) -> Option<&'a str>,
|
||||
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),
|
||||
}
|
||||
@@ -1288,11 +1292,12 @@ impl BlockEntityRenderer {
|
||||
};
|
||||
// SkullBlockRenderer.getRenderType: the profile's skin when it
|
||||
// resolves, `SKIN_BY_TYPE` (steve) otherwise.
|
||||
let texture = if *kind == SkullType::Player {
|
||||
(scene.skull)(pos).unwrap_or(texture)
|
||||
let skin = if *kind == SkullType::Player {
|
||||
(scene.skull)(pos)
|
||||
} else {
|
||||
texture
|
||||
None
|
||||
};
|
||||
let texture = skin.as_deref().unwrap_or(texture);
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
|
||||
else {
|
||||
@@ -1716,6 +1721,8 @@ impl BeamPart {
|
||||
layer: 6,
|
||||
color,
|
||||
light: self.wrap,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1786,6 +1793,7 @@ fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>)
|
||||
layer,
|
||||
color: 0,
|
||||
light: 0,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ pub fn geometry(
|
||||
color: 0xffff_ffff,
|
||||
light: stage_light(stage),
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,257 +7,12 @@ use crate::{
|
||||
items::{ItemStack, Items},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
path::Path,
|
||||
sync::{Arc, OnceLock},
|
||||
};
|
||||
use std::{collections::HashSet, fs, path::Path, sync::Arc};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Category {
|
||||
Beneficial,
|
||||
Harmful,
|
||||
Neutral,
|
||||
}
|
||||
/// A `MobEffect`: id (without namespace), colour, category, instantaneous.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct EffectDef {
|
||||
pub id: &'static str,
|
||||
pub color: u32,
|
||||
pub category: Category,
|
||||
pub instant: bool,
|
||||
}
|
||||
const fn fx(id: &'static str, color: u32, category: Category) -> EffectDef {
|
||||
EffectDef {
|
||||
id,
|
||||
color,
|
||||
category,
|
||||
instant: false,
|
||||
}
|
||||
}
|
||||
use Category::{Beneficial as B, Harmful as H, Neutral as N};
|
||||
/// `MobEffects` in registration order.
|
||||
pub const EFFECTS: &[EffectDef] = &[
|
||||
fx("speed", 3402751, B),
|
||||
fx("slowness", 9154528, H),
|
||||
fx("haste", 14270531, B),
|
||||
fx("mining_fatigue", 4866583, H),
|
||||
fx("strength", 16762624, B),
|
||||
EffectDef {
|
||||
id: "instant_health",
|
||||
color: 16262179,
|
||||
category: B,
|
||||
instant: true,
|
||||
},
|
||||
EffectDef {
|
||||
id: "instant_damage",
|
||||
color: 11101546,
|
||||
category: H,
|
||||
instant: true,
|
||||
},
|
||||
fx("jump_boost", 16646020, B),
|
||||
fx("nausea", 5578058, H),
|
||||
fx("regeneration", 13458603, B),
|
||||
fx("resistance", 9520880, B),
|
||||
fx("fire_resistance", 16750848, B),
|
||||
fx("water_breathing", 10017472, B),
|
||||
fx("invisibility", 16185078, B),
|
||||
fx("blindness", 2039587, H),
|
||||
fx("night_vision", 12779366, B),
|
||||
fx("hunger", 5797459, H),
|
||||
fx("weakness", 4738376, H),
|
||||
fx("poison", 8889187, H),
|
||||
fx("wither", 7561558, H),
|
||||
fx("health_boost", 16284963, B),
|
||||
fx("absorption", 2445989, B),
|
||||
EffectDef {
|
||||
id: "saturation",
|
||||
color: 16262179,
|
||||
category: B,
|
||||
instant: true,
|
||||
},
|
||||
fx("glowing", 9740385, N),
|
||||
fx("levitation", 13565951, H),
|
||||
fx("luck", 5882118, B),
|
||||
fx("unluck", 12624973, H),
|
||||
fx("slow_falling", 15978425, B),
|
||||
fx("conduit_power", 1950417, B),
|
||||
fx("dolphins_grace", 8954814, B),
|
||||
fx("bad_omen", 745784, N),
|
||||
fx("hero_of_the_village", 4521796, B),
|
||||
fx("darkness", 2696993, H),
|
||||
fx("trial_omen", 1484454, N),
|
||||
fx("raid_omen", 14565464, N),
|
||||
fx("wind_charged", 12438015, H),
|
||||
fx("weaving", 7891290, H),
|
||||
fx("oozing", 10092451, H),
|
||||
fx("infested", 9214860, H),
|
||||
fx("breath_of_the_nautilus", 65518, B),
|
||||
];
|
||||
pub fn effect(id: &str) -> Option<&'static EffectDef> {
|
||||
let id = id.trim_start_matches("minecraft:");
|
||||
EFFECTS.iter().find(|e| e.id == id)
|
||||
}
|
||||
/// Namespaced effect ids for mob effect storage.
|
||||
pub fn namespaced(id: &'static str) -> &'static str {
|
||||
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
IDS.get_or_init(|| {
|
||||
EFFECTS
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
e.id,
|
||||
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.get(id)
|
||||
.copied()
|
||||
.unwrap_or(id)
|
||||
}
|
||||
/// A `Potion`: id, name suffix (`item.minecraft.potion.effect.<name>`) and
|
||||
/// effects as (effect, duration ticks, amplifier).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct PotionDef {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub effects: &'static [(&'static str, i32, i32)],
|
||||
}
|
||||
const fn p(
|
||||
id: &'static str,
|
||||
name: &'static str,
|
||||
effects: &'static [(&'static str, i32, i32)],
|
||||
) -> PotionDef {
|
||||
PotionDef { id, name, effects }
|
||||
}
|
||||
/// `Potions` in registration order.
|
||||
pub const POTIONS: &[PotionDef] = &[
|
||||
p("water", "water", &[]),
|
||||
p("mundane", "mundane", &[]),
|
||||
p("thick", "thick", &[]),
|
||||
p("awkward", "awkward", &[]),
|
||||
p("night_vision", "night_vision", &[("night_vision", 3600, 0)]),
|
||||
p(
|
||||
"long_night_vision",
|
||||
"night_vision",
|
||||
&[("night_vision", 9600, 0)],
|
||||
),
|
||||
p("invisibility", "invisibility", &[("invisibility", 3600, 0)]),
|
||||
p(
|
||||
"long_invisibility",
|
||||
"invisibility",
|
||||
&[("invisibility", 9600, 0)],
|
||||
),
|
||||
p("leaping", "leaping", &[("jump_boost", 3600, 0)]),
|
||||
p("long_leaping", "leaping", &[("jump_boost", 9600, 0)]),
|
||||
p("strong_leaping", "leaping", &[("jump_boost", 1800, 1)]),
|
||||
p(
|
||||
"fire_resistance",
|
||||
"fire_resistance",
|
||||
&[("fire_resistance", 3600, 0)],
|
||||
),
|
||||
p(
|
||||
"long_fire_resistance",
|
||||
"fire_resistance",
|
||||
&[("fire_resistance", 9600, 0)],
|
||||
),
|
||||
p("swiftness", "swiftness", &[("speed", 3600, 0)]),
|
||||
p("long_swiftness", "swiftness", &[("speed", 9600, 0)]),
|
||||
p("strong_swiftness", "swiftness", &[("speed", 1800, 1)]),
|
||||
p("slowness", "slowness", &[("slowness", 1800, 0)]),
|
||||
p("long_slowness", "slowness", &[("slowness", 4800, 0)]),
|
||||
p("strong_slowness", "slowness", &[("slowness", 400, 3)]),
|
||||
p(
|
||||
"turtle_master",
|
||||
"turtle_master",
|
||||
&[("slowness", 400, 3), ("resistance", 400, 2)],
|
||||
),
|
||||
p(
|
||||
"long_turtle_master",
|
||||
"turtle_master",
|
||||
&[("slowness", 800, 3), ("resistance", 800, 2)],
|
||||
),
|
||||
p(
|
||||
"strong_turtle_master",
|
||||
"turtle_master",
|
||||
&[("slowness", 400, 5), ("resistance", 400, 3)],
|
||||
),
|
||||
p(
|
||||
"water_breathing",
|
||||
"water_breathing",
|
||||
&[("water_breathing", 3600, 0)],
|
||||
),
|
||||
p(
|
||||
"long_water_breathing",
|
||||
"water_breathing",
|
||||
&[("water_breathing", 9600, 0)],
|
||||
),
|
||||
p("healing", "healing", &[("instant_health", 1, 0)]),
|
||||
p("strong_healing", "healing", &[("instant_health", 1, 1)]),
|
||||
p("harming", "harming", &[("instant_damage", 1, 0)]),
|
||||
p("strong_harming", "harming", &[("instant_damage", 1, 1)]),
|
||||
p("poison", "poison", &[("poison", 900, 0)]),
|
||||
p("long_poison", "poison", &[("poison", 1800, 0)]),
|
||||
p("strong_poison", "poison", &[("poison", 432, 1)]),
|
||||
p("regeneration", "regeneration", &[("regeneration", 900, 0)]),
|
||||
p(
|
||||
"long_regeneration",
|
||||
"regeneration",
|
||||
&[("regeneration", 1800, 0)],
|
||||
),
|
||||
p(
|
||||
"strong_regeneration",
|
||||
"regeneration",
|
||||
&[("regeneration", 450, 1)],
|
||||
),
|
||||
p("strength", "strength", &[("strength", 3600, 0)]),
|
||||
p("long_strength", "strength", &[("strength", 9600, 0)]),
|
||||
p("strong_strength", "strength", &[("strength", 1800, 1)]),
|
||||
p("weakness", "weakness", &[("weakness", 1800, 0)]),
|
||||
p("long_weakness", "weakness", &[("weakness", 4800, 0)]),
|
||||
p("luck", "luck", &[("luck", 6000, 0)]),
|
||||
p("slow_falling", "slow_falling", &[("slow_falling", 1800, 0)]),
|
||||
p(
|
||||
"long_slow_falling",
|
||||
"slow_falling",
|
||||
&[("slow_falling", 4800, 0)],
|
||||
),
|
||||
p("wind_charged", "wind_charged", &[("wind_charged", 3600, 0)]),
|
||||
p("weaving", "weaving", &[("weaving", 3600, 0)]),
|
||||
p("oozing", "oozing", &[("oozing", 3600, 0)]),
|
||||
p("infested", "infested", &[("infested", 3600, 0)]),
|
||||
];
|
||||
pub fn potion(id: &str) -> Option<&'static PotionDef> {
|
||||
let id = id.trim_start_matches("minecraft:");
|
||||
POTIONS.iter().find(|p| p.id == id)
|
||||
}
|
||||
/// `PotionContents.BASE_POTION_COLOR` (ARGB -13083194) as RGB.
|
||||
pub const BASE_POTION_COLOR: u32 = 0x385DC6;
|
||||
/// `PotionContents.getColor`: amplifier-weighted mix of visible effect colours.
|
||||
pub fn potion_color(id: Option<&str>) -> u32 {
|
||||
let Some(def) = id.and_then(potion) else {
|
||||
return BASE_POTION_COLOR;
|
||||
};
|
||||
let (mut r, mut g, mut b, mut total) = (0u32, 0u32, 0u32, 0u32);
|
||||
for (e, _, amp) in def.effects {
|
||||
let Some(e) = effect(e) else { continue };
|
||||
let w = *amp as u32 + 1;
|
||||
r += w * (e.color >> 16 & 255);
|
||||
g += w * (e.color >> 8 & 255);
|
||||
b += w * (e.color & 255);
|
||||
total += w;
|
||||
}
|
||||
match (
|
||||
r.checked_div(total),
|
||||
g.checked_div(total),
|
||||
b.checked_div(total),
|
||||
) {
|
||||
(Some(r), Some(g), Some(b)) => (r << 16) | (g << 8) | b,
|
||||
_ => BASE_POTION_COLOR,
|
||||
}
|
||||
}
|
||||
pub use horizon_client_core::potions::{
|
||||
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
|
||||
potion, potion_color,
|
||||
};
|
||||
/// `MobEffectUtil.formatDuration` at 20 TPS: m:ss or h:mm:ss.
|
||||
pub fn format_duration(ticks: i32) -> String {
|
||||
let secs = ticks / 20;
|
||||
|
||||
@@ -16,6 +16,9 @@ use crate::{
|
||||
#[path = "chat_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// `CommandHistory.PERSISTED_COMMANDS_FILE_NAME` and its size cap.
|
||||
const COMMAND_HISTORY_FILE: &str = "command_history.txt";
|
||||
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
|
||||
/// GuiMessageTag.systemSinglePlayer's indicator colour.
|
||||
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
|
||||
|
||||
@@ -80,6 +83,10 @@ impl ChatAbilities {
|
||||
ChatSource::Client => true,
|
||||
}
|
||||
}
|
||||
/// `ChatAbilities.hasAnyRestrictions`: the Chat option takes something.
|
||||
pub fn any_restriction(&self) -> bool {
|
||||
*self != Self::new(ChatVisibility::Full)
|
||||
}
|
||||
/// Whether the chat screen may send `input` (`hasAllowedInput`).
|
||||
pub fn allows(&self, input: &str) -> bool {
|
||||
if input.starts_with('/') {
|
||||
@@ -185,6 +192,10 @@ pub struct Chat {
|
||||
lines: Vec<Line>,
|
||||
/// Sent messages and commands (ChatComponent.recentChat).
|
||||
pub recent: Vec<String>,
|
||||
/// `CommandHistory`: the last commands, kept in `command_history.txt`
|
||||
/// in the game folder once `load_history` has set it.
|
||||
commands: Vec<String>,
|
||||
history_file: Option<std::path::PathBuf>,
|
||||
scroll: usize,
|
||||
new_since_scroll: bool,
|
||||
pub options: ChatOptions,
|
||||
@@ -310,6 +321,17 @@ impl Chat {
|
||||
self.lines.clear();
|
||||
self.scroll = 0;
|
||||
}
|
||||
/// `new CommandHistory(gameFolder)`: the commands kept from earlier runs
|
||||
/// start the chat's history (`ChatComponent`'s constructor).
|
||||
pub fn load_history(&mut self, game_folder: &std::path::Path) {
|
||||
let file = game_folder.join(COMMAND_HISTORY_FILE);
|
||||
if let Ok(text) = std::fs::read_to_string(&file) {
|
||||
self.commands = text.lines().map(str::to_owned).collect();
|
||||
self.recent.extend(self.commands.iter().cloned());
|
||||
}
|
||||
self.history_file = Some(file);
|
||||
}
|
||||
/// `ChatComponent.addRecentChat`: a command also goes to the saved history.
|
||||
pub fn add_recent(&mut self, message: &str) {
|
||||
if self.recent.last().map(String::as_str) != Some(message) {
|
||||
if self.recent.len() >= 100 {
|
||||
@@ -317,6 +339,21 @@ impl Chat {
|
||||
}
|
||||
self.recent.push(message.to_owned());
|
||||
}
|
||||
if message.starts_with('/') && self.commands.last().map(String::as_str) != Some(message) {
|
||||
if self.commands.len() >= MAX_PERSISTED_COMMAND_HISTORY {
|
||||
self.commands.remove(0);
|
||||
}
|
||||
self.commands.push(message.to_owned());
|
||||
if let Some(file) = &self.history_file {
|
||||
let mut text = self.commands.join("\n");
|
||||
text.push('\n');
|
||||
if let Err(e) = std::fs::write(file, text) {
|
||||
log::error!(
|
||||
"Failed to write {COMMAND_HISTORY_FILE}, command history will be missing: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn reset_scroll(&mut self) {
|
||||
self.scroll = 0;
|
||||
@@ -438,10 +475,71 @@ impl Chat {
|
||||
out.push(GuiDraw::Pop);
|
||||
out
|
||||
}
|
||||
/// Draws [`Chat::layout`] into the HUD batch.
|
||||
/// Draws [`Chat::layout`] into the HUD batch, and with the chat screen
|
||||
/// open the restricted prompt above the lines.
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
|
||||
let draws = self.layout(batch.height as i32, ticks, focused);
|
||||
hud_layout::paint(batch, atlas, &draws);
|
||||
if focused && self.abilities.any_restriction() {
|
||||
let prompt = self.restricted_prompt(&atlas.font, batch.height as i32);
|
||||
hud_layout::paint(batch, atlas, &prompt);
|
||||
}
|
||||
}
|
||||
/// `ChatComponent.extractRenderState` for `FOREGROUND_RESTRICTED`: a
|
||||
/// background row and the red, underlined "Chat is restricted" line in
|
||||
/// the row above the messages, clipped to the chat's width.
|
||||
fn restricted_prompt(&self, font: &crate::font::Font, height: i32) -> Vec<GuiDraw> {
|
||||
let o = &self.options;
|
||||
let scale = o.scale as f32;
|
||||
let max_width = hud_layout::ceil(o.width_px() as f32 / scale);
|
||||
let chat_bottom = hud_layout::floor((height - 40) as f32 / scale);
|
||||
let entry_height = o.line_height();
|
||||
let bottom_to_text =
|
||||
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
|
||||
let shown = self
|
||||
.lines
|
||||
.len()
|
||||
.saturating_sub(self.scroll)
|
||||
.min(self.lines_per_page(true));
|
||||
let top = chat_bottom - (shown as i32 + 1) * entry_height;
|
||||
let mut prompt = Text::translate("chat_screen.restricted", &[]).with_style(&Style {
|
||||
color: Some(text::RED),
|
||||
underlined: Some(true),
|
||||
..Style::default()
|
||||
});
|
||||
// ComponentRenderUtils.clipText.
|
||||
let mut width = 0.0;
|
||||
let fits = prompt
|
||||
.chars()
|
||||
.take_while(|(c, style)| {
|
||||
width += font.advance(*c, style);
|
||||
width <= max_width as f32
|
||||
})
|
||||
.count();
|
||||
if fits < prompt.chars().count() {
|
||||
prompt = prompt.slice(0, fits);
|
||||
}
|
||||
vec![
|
||||
GuiDraw::Push(vec![
|
||||
PoseOp::Scale(scale, scale),
|
||||
PoseOp::Translate(4.0, 0.0),
|
||||
]),
|
||||
hud_layout::fill(
|
||||
-2,
|
||||
top,
|
||||
max_width + 4 + 4,
|
||||
top + entry_height,
|
||||
hud_layout::as8(o.background_opacity as f32) << 24,
|
||||
),
|
||||
GuiDraw::Text {
|
||||
text: prompt,
|
||||
x: 0,
|
||||
y: top + entry_height - bottom_to_text - 1,
|
||||
color: hud_layout::white(o.opacity as f32 * 0.9 + 0.1),
|
||||
shadow: true,
|
||||
},
|
||||
GuiDraw::Pop,
|
||||
]
|
||||
}
|
||||
/// The click action under a GUI-space cursor while chat is focused.
|
||||
pub fn click_at(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! threads can mesh and light immutable snapshots while the game thread edits
|
||||
//! (copy-on-write through `Arc::make_mut`).
|
||||
use horizon_core::paletted::{PalettedContainer, Strategy};
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
@@ -155,7 +156,7 @@ pub struct Chunk {
|
||||
/// positions, applied once by LevelChunk.postProcessGeneration.
|
||||
pub postprocess: Vec<[i32; 3]>,
|
||||
/// Saved data Horizon keeps but does not simulate (save.rs).
|
||||
pub saved: Option<Arc<crate::save::ChunkExtras>>,
|
||||
pub saved: Option<Arc<ChunkExtras>>,
|
||||
/// Entities read from a save (vanilla entity NBT), spawned on arrival.
|
||||
pub entities: Vec<horizon_world::nbt::Tag>,
|
||||
/// Structure starts reaching into this chunk (`structures.References`):
|
||||
@@ -309,6 +310,76 @@ impl Chunk {
|
||||
}
|
||||
}
|
||||
|
||||
/// A proto chunk saved before it became final. Decoration writes into a
|
||||
/// chunk's neighbours, and decorated chunks wait for theirs, so the chunks
|
||||
/// past the finished ones hold writes that generating them again would lose
|
||||
/// (whole structure pieces, trees, ores). Vanilla saves its proto chunks too.
|
||||
pub struct SavedProto {
|
||||
/// Blocks, scheduled ticks (in `saved`) and post-processing marks.
|
||||
pub column: Chunk,
|
||||
/// Decorated itself (`features`), not only written to by its neighbours
|
||||
/// (`terrain`).
|
||||
pub decorated: bool,
|
||||
/// Block entity data in placement order, with `x`, `y` and `z`.
|
||||
pub block_entities: Vec<Tag>,
|
||||
/// Entities placed by structures, with absolute `Pos`.
|
||||
pub entities: Vec<Tag>,
|
||||
}
|
||||
|
||||
/// A container's loot table and what Horizon rolled from it. Vanilla keeps
|
||||
/// `LootTable` and `LootTableSeed` until the container is first opened;
|
||||
/// Horizon rolls when it generates or loads the chunk, and saves the table
|
||||
/// again while the container still holds exactly the rolled items.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RolledLoot {
|
||||
pub pos: (i32, i32, i32),
|
||||
pub table: String,
|
||||
/// The saved `LootTableSeed`, written back unless 0 (trySaveLootTable).
|
||||
pub seed: Option<i64>,
|
||||
/// The rolled `Items`.
|
||||
pub items: Tag,
|
||||
}
|
||||
impl RolledLoot {
|
||||
/// Puts the loot table back in place of `Items` while they are still the
|
||||
/// roll; false once the container holds anything else.
|
||||
pub fn restore(&self, map: &mut Compound) -> bool {
|
||||
if map.get(&"Items".into()) != Some(&self.items) {
|
||||
return false;
|
||||
}
|
||||
map.remove(&"Items".into());
|
||||
map.insert("LootTable".into(), Tag::String(self.table.as_str().into()));
|
||||
if let Some(seed) = self.seed.filter(|s| *s != 0) {
|
||||
map.insert("LootTableSeed".into(), Tag::Long(seed));
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Save data loaded with a chunk that Horizon keeps but does not simulate.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ChunkExtras {
|
||||
/// Chunk root keys written back as loaded (structures, PostProcessing,
|
||||
/// InhabitedTime, blending data, ...).
|
||||
pub root: Compound,
|
||||
/// Block entities Horizon does not model.
|
||||
pub block_entities: Vec<Tag>,
|
||||
/// The NBT of the modelled block entities as loaded, to restore the fields and
|
||||
/// detail Horizon does not model when it saves them (`block_entity_nbt::overlay`).
|
||||
pub block_entity_sources: Vec<((i32, i32, i32), Tag)>,
|
||||
/// Every saved block entity's position in the order of the save's list,
|
||||
/// the order `SerializableChunkData` puts them in the chunk's maps.
|
||||
pub block_entity_order: Vec<(i32, i32, i32)>,
|
||||
/// Scheduled ticks (`block_ticks`/`fluid_ticks`): delays relative to the
|
||||
/// save time. Registered with the level's tick scheduler on arrival and
|
||||
/// replaced with the scheduler's pending ticks when saving.
|
||||
pub block_ticks: Vec<horizon_core::ticks::SavedTick>,
|
||||
pub fluid_ticks: Vec<horizon_core::ticks::SavedTick>,
|
||||
/// Game time written as `LastUpdate`.
|
||||
pub last_update: i64,
|
||||
/// Containers still holding their loot table's roll.
|
||||
pub rolled_loot: Vec<RolledLoot>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -179,6 +179,8 @@ impl Mesh {
|
||||
layer: 4,
|
||||
color: u32::MAX,
|
||||
light: shade,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -54,6 +54,27 @@ fn strider_liquid_shape() -> VoxelShape {
|
||||
VoxelShape::from_cube([1, 2, 1], &[true, false]).expect("a valid half-block column")
|
||||
}
|
||||
|
||||
// Vanilla scopes this direction to Entity.move while a piston pushes it.
|
||||
thread_local! {
|
||||
static PISTON_NOCLIP: std::cell::Cell<Option<horizon_core::geometry::Direction>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Suppress the moving part of pistons moving in this direction during a push.
|
||||
pub fn with_piston_noclip<T>(
|
||||
direction: horizon_core::geometry::Direction,
|
||||
f: impl FnOnce() -> T,
|
||||
) -> T {
|
||||
struct Reset(Option<horizon_core::geometry::Direction>);
|
||||
impl Drop for Reset {
|
||||
fn drop(&mut self) {
|
||||
PISTON_NOCLIP.with(|value| value.set(self.0));
|
||||
}
|
||||
}
|
||||
let previous = PISTON_NOCLIP.with(|value| value.replace(Some(direction)));
|
||||
let _reset = Reset(previous);
|
||||
f()
|
||||
}
|
||||
|
||||
pub struct SceneCollisions<'w, 'r> {
|
||||
pub world: &'w World,
|
||||
pub binding: &'w BlockCollisionRegistry<'r>,
|
||||
@@ -140,8 +161,21 @@ impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCo
|
||||
border.collision_shape().map(Some).map_err(io::Error::other)
|
||||
}
|
||||
}
|
||||
impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
|
||||
/// `SceneCollisions::resolve` that also stops at the entity boxes.
|
||||
impl<'a, W> EntityCollisions<'a, W>
|
||||
where
|
||||
W: BlockCollisionWorld<Error = io::Error> + EntityMovementWorld<Error = io::Error>,
|
||||
{
|
||||
/// `EntityCollisions` for a body the world border also stops: the border
|
||||
/// and the body's own x and z (`WorldBorder.isInsideCloseToBorder`).
|
||||
pub fn with_border(blocks: W, border: &'a BorderGeometry, at: [f64; 2]) -> Self {
|
||||
Self {
|
||||
blocks,
|
||||
entities: &[],
|
||||
border: Some((border, at)),
|
||||
}
|
||||
}
|
||||
/// `SceneCollisions::resolve` that also stops at the entity boxes and,
|
||||
/// with a border, at the world border's walls.
|
||||
pub fn resolve(
|
||||
&mut self,
|
||||
bounds: Aabb,
|
||||
@@ -149,12 +183,11 @@ impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
) -> Result<Vec3> {
|
||||
let context = self.blocks.context(bounds);
|
||||
Ok(resolve_world_movement(
|
||||
self,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context,
|
||||
context: CollisionContext::position(bounds.min_position().y),
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
@@ -324,7 +357,7 @@ impl DynamicSupportWorld for SceneCollisions<'_, '_> {
|
||||
return Ok(None);
|
||||
};
|
||||
pistons
|
||||
.collision_shape(self, p, piston, None)
|
||||
.collision_shape(self, p, piston, PISTON_NOCLIP.with(|value| value.get()))
|
||||
.map(Some)
|
||||
.map_err(io::Error::other)
|
||||
}
|
||||
@@ -388,6 +421,42 @@ impl PowderSnowWorld for SceneCollisions<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A collision world without blocks: no chunk is resident, so only the
|
||||
/// world border stops a body.
|
||||
pub struct EmptyCollisions;
|
||||
|
||||
impl BlockCollisionWorld for EmptyCollisions {
|
||||
type Error = io::Error;
|
||||
type Chunk = ChunkPos;
|
||||
fn collision_chunk(&mut self, _: ChunkPos) -> io::Result<Option<ChunkPos>> {
|
||||
Ok(None)
|
||||
}
|
||||
fn collision_block(&mut self, _: ChunkPos, _: BlockPos) -> io::Result<CollisionBlock> {
|
||||
Err(io::Error::other("no chunk is resident"))
|
||||
}
|
||||
fn is_suffocating(&mut self, _: ChunkPos, _: CollisionBlock, _: BlockPos) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
fn context_shape(
|
||||
&mut self,
|
||||
_: CollisionBlock,
|
||||
_: BlockPos,
|
||||
_: &CollisionContext<'_>,
|
||||
) -> io::Result<VoxelShape> {
|
||||
Ok(VoxelShape::empty())
|
||||
}
|
||||
}
|
||||
|
||||
impl EntityMovementWorld for EmptyCollisions {
|
||||
type Error = io::Error;
|
||||
fn entity_collisions(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Vec<VoxelShape>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn movement_border(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Option<VoxelShape>> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Collision queries over any block lookup (test worlds without chunks):
|
||||
/// every chunk is present, nothing is suffocating, no entities collide.
|
||||
pub struct LookupCollisions<'w, 'r> {
|
||||
@@ -475,3 +544,70 @@ impl PowderSnowWorld for LookupCollisions<'_, '_> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod piston_noclip_tests {
|
||||
use super::*;
|
||||
use horizon_core::geometry::Direction;
|
||||
|
||||
#[test]
|
||||
fn piston_noclip_is_scoped_and_restored_after_unwind() {
|
||||
assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None);
|
||||
with_piston_noclip(Direction::East, || {
|
||||
assert_eq!(
|
||||
PISTON_NOCLIP.with(|value| value.get()),
|
||||
Some(Direction::East)
|
||||
);
|
||||
let _ =
|
||||
std::panic::catch_unwind(|| with_piston_noclip(Direction::West, || panic!("test")));
|
||||
assert_eq!(
|
||||
PISTON_NOCLIP.with(|value| value.get()),
|
||||
Some(Direction::East)
|
||||
);
|
||||
});
|
||||
assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod border_movement_tests {
|
||||
use super::*;
|
||||
|
||||
/// A border 10 wide around the origin: its wall's inner face is at x = 5.
|
||||
fn border() -> BorderGeometry {
|
||||
BorderGeometry::new([0.0, 0.0], 10.0, 29_999_984).expect("a border")
|
||||
}
|
||||
|
||||
/// `WorldBorder.isInsideCloseToBorder` and `WorldBorder.getCollisionShape`:
|
||||
/// a body inside close to the border collides with the border's walls.
|
||||
#[test]
|
||||
fn a_body_next_to_the_border_stops_at_its_wall() {
|
||||
// A body 0.6 wide, 0.4 blocks short of the wall, moved east into it.
|
||||
let bounds = Aabb::new(Vec3::new(4.0, 0.0, 0.0), Vec3::new(4.6, 1.8, 0.6));
|
||||
let border = border();
|
||||
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [4.3, 0.3]);
|
||||
let moved = collisions
|
||||
.resolve(bounds, Vec3::new(0.51, 0.0, 0.0), 0.0, false)
|
||||
.expect("the movement resolves");
|
||||
// The wall stops the body 0.4 blocks short of the whole move.
|
||||
assert!(
|
||||
(moved.x - 0.4).abs() < 1.0e-9,
|
||||
"the body was not stopped by the border: {moved:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Away from the border, the same body moves the whole way.
|
||||
#[test]
|
||||
fn a_body_away_from_the_border_moves_whole() {
|
||||
let bounds = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.6, 1.8, 0.6));
|
||||
let border = border();
|
||||
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [0.3, 0.3]);
|
||||
let moved = collisions
|
||||
.resolve(bounds, Vec3::new(0.51, 0.0, 0.0), 0.0, false)
|
||||
.expect("the movement resolves");
|
||||
assert!(
|
||||
(moved.x - 0.51).abs() < 1.0e-9,
|
||||
"the body did not move the whole way: {moved:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -98,9 +98,34 @@ pub enum BlockEntity {
|
||||
Campfire(Box<Campfire>),
|
||||
/// `SkullBlockEntity` of every skull and head block.
|
||||
Skull(Skull),
|
||||
/// `TheEndGatewayBlockEntity`: its age and configured destination.
|
||||
EndGateway(EndGateway),
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
|
||||
ChiseledBookshelf(Box<ChiseledBookshelf>),
|
||||
}
|
||||
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ChiseledBookshelf {
|
||||
pub items: [Option<ItemStack>; 6],
|
||||
pub last_slot: i32,
|
||||
}
|
||||
impl Default for ChiseledBookshelf {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
items: Default::default(),
|
||||
last_slot: -1,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `TheEndGatewayBlockEntity`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EndGateway {
|
||||
pub age: i64,
|
||||
pub exit_portal: Option<[i32; 3]>,
|
||||
pub exact_teleport: bool,
|
||||
}
|
||||
/// `CampfireBlockEntity`: four cooking slots, each with its
|
||||
/// `cookingProgress` and `cookingTime` (the recipe's).
|
||||
@@ -190,6 +215,9 @@ impl Skull {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SkullSkin {
|
||||
pub download: Option<(String, String)>,
|
||||
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
|
||||
/// an id only): the key `skins::Resolver` resolves it by.
|
||||
pub dynamic: Option<crate::skins::NameOrId>,
|
||||
pub fallback: &'static str,
|
||||
}
|
||||
impl SkullSkin {
|
||||
@@ -198,10 +226,21 @@ impl SkullSkin {
|
||||
let location = crate::skins::skin_location(&crate::skins::texture_hash(&url)?);
|
||||
Some((url, location))
|
||||
});
|
||||
// A `Dynamic` profile (a name or id only) resolves through the
|
||||
// services when the skull is drawn.
|
||||
let dynamic = crate::skins::dynamic_nbt(profile);
|
||||
// DefaultPlayerSkin.get(id), or getDefaultSkin (slim steve) without an id.
|
||||
let fallback = crate::skins::profile_uuid_nbt(profile)
|
||||
.or_else(|| {
|
||||
crate::skins::nbt_text(profile.get("name"))
|
||||
.map(|name| horizon_protocol::crypto::offline_uuid(&name))
|
||||
})
|
||||
.map_or("entity/player/slim/steve", crate::tab_list::default_skin);
|
||||
Self { download, fallback }
|
||||
Self {
|
||||
download,
|
||||
dynamic,
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every
|
||||
@@ -366,6 +405,9 @@ impl BlockEntity {
|
||||
if is_skull(name) {
|
||||
return Some(Self::Skull(Skull::default()));
|
||||
}
|
||||
if name == "minecraft:end_gateway" {
|
||||
return Some(Self::EndGateway(EndGateway::default()));
|
||||
}
|
||||
if name == "minecraft:daylight_detector" {
|
||||
return Some(Self::DaylightDetector);
|
||||
}
|
||||
@@ -381,6 +423,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
|
||||
return Some(Self::Campfire(Box::default()));
|
||||
}
|
||||
if name == "minecraft:chiseled_bookshelf" {
|
||||
return Some(Self::ChiseledBookshelf(Box::default()));
|
||||
}
|
||||
let size = match name {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
|
||||
n if n.ends_with("copper_chest") => 27,
|
||||
@@ -418,8 +463,10 @@ impl BlockEntity {
|
||||
Self::Beehive(_) => "minecraft:beehive",
|
||||
Self::CommandBlock(_) => "minecraft:command_block",
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
Self::EndGateway(_) => "minecraft:end_gateway",
|
||||
Self::DaylightDetector => "minecraft:daylight_detector",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -436,6 +483,7 @@ impl BlockEntity {
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => Vec::new(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
@@ -443,6 +491,7 @@ impl BlockEntity {
|
||||
Self::Banner(_) => Vec::new(),
|
||||
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
|
||||
}
|
||||
}
|
||||
pub fn slots(&self) -> &[Option<ItemStack>] {
|
||||
@@ -456,9 +505,11 @@ impl BlockEntity {
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
Self::ChiseledBookshelf(b) => &b.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
@@ -480,9 +531,11 @@ impl BlockEntity {
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
Self::ChiseledBookshelf(b) => &mut b.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
|
||||
@@ -105,9 +105,13 @@ impl Report {
|
||||
}
|
||||
}
|
||||
|
||||
/// The release this binary is (`v0.2.3`); for a release from before 0.2, its tag and the client's
|
||||
/// crate version; for a build from source, that version alone.
|
||||
/// The release this binary is (`v0.2.3`); a nightly's label (`Nightly 2026-10-28 · 0c02067`); for a
|
||||
/// release from before 0.2, its tag and the client's crate version; for a build from source, that
|
||||
/// version alone.
|
||||
pub fn version() -> String {
|
||||
if let Some(label) = horizon_core::release::nightly_label() {
|
||||
return label;
|
||||
}
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
match (horizon_core::release::RELEASE, release_tag()) {
|
||||
(Some(_), Some(tag)) => tag,
|
||||
@@ -117,8 +121,12 @@ pub fn version() -> String {
|
||||
}
|
||||
|
||||
/// The release this binary came with: the version release.yml built in (docs/versioning.md), else
|
||||
/// the folder the launcher installed it in, named after the tag (releases from before 0.2).
|
||||
/// the folder the launcher installed it in, named after the tag (releases from before 0.2). A
|
||||
/// nightly is its version as the launcher installs it (`nightly-2026-10-28-0c02067`).
|
||||
pub fn release_tag() -> Option<String> {
|
||||
if let Some(nightly) = horizon_core::release::nightly_version() {
|
||||
return Some(nightly);
|
||||
}
|
||||
if let Some(version) = horizon_core::release::RELEASE {
|
||||
return Some(format!("v{version}"));
|
||||
}
|
||||
@@ -525,6 +533,11 @@ mod tests {
|
||||
#[test]
|
||||
fn a_release_reports_its_built_in_version() {
|
||||
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
|
||||
if let Some(label) = horizon_core::release::nightly_label() {
|
||||
assert_eq!(super::version(), label);
|
||||
assert_eq!(release_tag(), horizon_core::release::nightly_version());
|
||||
return;
|
||||
}
|
||||
match horizon_core::release::RELEASE {
|
||||
Some(version) => {
|
||||
assert_eq!(release_tag(), Some(format!("v{version}")));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! add/update with hidden effects, per-tick application of the periodic
|
||||
//! effects (regeneration, poison, wither, hunger, saturation, instant
|
||||
//! effects), and the derived flags the movement and damage code read.
|
||||
use crate::brewing;
|
||||
use horizon_client_core::potions;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EffectInstance {
|
||||
@@ -183,7 +183,7 @@ impl PlayerEffects {
|
||||
pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self {
|
||||
let mut effects = Self::default();
|
||||
for (id, duration, amplifier, ambient, particles) in saved {
|
||||
if let Some(def) = brewing::effect(id) {
|
||||
if let Some(def) = potions::effect(id) {
|
||||
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
|
||||
e.ambient = *ambient;
|
||||
e.visible = *particles;
|
||||
@@ -306,10 +306,10 @@ impl PlayerEffects {
|
||||
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
|
||||
let mut lasting = Vec::new();
|
||||
let mut now = Vec::new();
|
||||
if let Some(def) = potion.and_then(brewing::potion) {
|
||||
if let Some(def) = potion.and_then(potions::potion) {
|
||||
for (id, duration, amp) in def.effects {
|
||||
let id = brewing::effect(id).map_or("speed", |e| e.id);
|
||||
if brewing::effect(id).is_some_and(|e| e.instant) {
|
||||
let id = potions::effect(id).map_or("speed", |e| e.id);
|
||||
if potions::effect(id).is_some_and(|e| e.instant) {
|
||||
now.extend(instant(id, *amp, 1.0));
|
||||
} else {
|
||||
// withScaledDuration: max(floor(d * scale), 1).
|
||||
|
||||
@@ -771,9 +771,24 @@ pub struct ArmorStand {
|
||||
pub name_visible: bool,
|
||||
/// Game time of the last hit (the wobble and the second-hit window).
|
||||
pub last_hit: i64,
|
||||
/// `DATA_HEAD_POSE` to `DATA_RIGHT_LEG_POSE`: head, body, left arm, right
|
||||
/// arm, left leg, right leg, in degrees (`POSE_KEYS` order).
|
||||
pub pose: [[f32; 3]; 6],
|
||||
}
|
||||
impl ArmorStand {
|
||||
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
|
||||
/// The `Pose` compound's keys.
|
||||
pub const POSE_KEYS: [&'static str; 6] =
|
||||
["Head", "Body", "LeftArm", "RightArm", "LeftLeg", "RightLeg"];
|
||||
/// `ArmorStand.DEFAULT_*_POSE`.
|
||||
pub const DEFAULT_POSE: [[f32; 3]; 6] = [
|
||||
[0.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 0.0],
|
||||
[-10.0, 0.0, -10.0],
|
||||
[-15.0, 0.0, 10.0],
|
||||
[-1.0, 0.0, -1.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
];
|
||||
pub fn new(id: u32, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
fire_ticks: 0,
|
||||
@@ -792,6 +807,7 @@ impl ArmorStand {
|
||||
custom_name: None,
|
||||
name_visible: false,
|
||||
last_hit: i64::MIN / 2,
|
||||
pose: Self::DEFAULT_POSE,
|
||||
}
|
||||
}
|
||||
pub fn environment_tick(
|
||||
|
||||
@@ -14,7 +14,8 @@ struct Globals {
|
||||
// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png.
|
||||
end_portal_sprites: array<vec4<f32>, 2>,
|
||||
// x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000)
|
||||
// y: Nether cardinal lighting, z: how many dynamic_lights are set.
|
||||
// y: Nether cardinal lighting, z: how many dynamic_lights are set,
|
||||
// w: the glint scroll clock (`Glint::scroll_ticks`).
|
||||
time: vec4<f32>,
|
||||
crumbling_sprite: vec4<f32>,
|
||||
// Selection outline parameters (read by terrain.wgsl).
|
||||
@@ -27,6 +28,12 @@ struct Globals {
|
||||
@group(0) @binding(2) var atlas_sampler: sampler;
|
||||
@group(0) @binding(3) var lightmap: texture_2d<f32>;
|
||||
@group(0) @binding(4) var lightmap_sampler: sampler;
|
||||
// enchanted_glint_item on its own repeating texture (vanilla's GlintSampler).
|
||||
@group(0) @binding(5) var glint_tex: texture_2d<f32>;
|
||||
@group(0) @binding(6) var glint_sampler: sampler;
|
||||
// The armour layers' GlintSampler: vanilla samples enchanted_glint_armor with
|
||||
// the same repeating, linearly filtered sampler as the item glint.
|
||||
@group(0) @binding(7) var glint_armor_tex: texture_2d<f32>;
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@@ -44,6 +51,9 @@ struct Out {
|
||||
// entity.vsh's lightMapColor, multiplied in after the overlay.
|
||||
@location(9) light_color: vec4<f32>,
|
||||
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
|
||||
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
|
||||
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 13 (armour).
|
||||
@location(11) glint_uv: vec2<f32>,
|
||||
}
|
||||
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
|
||||
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
|
||||
@@ -103,18 +113,23 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
out.spherical = select(length(position), 0.0, overlay >= 2u && overlay <= 4u);
|
||||
out.cylindrical = select(max(length(position.xz), abs(position.y)), 0.0, overlay >= 2u && overlay <= 4u);
|
||||
out.overlay = overlay;
|
||||
out.glint_uv = vec2(0.0);
|
||||
if overlay == 4u { out.spherical = length(position); }
|
||||
return out;
|
||||
}
|
||||
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
|
||||
return entity_vertex(position, uv, overlay, color, light, normal);
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
|
||||
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
|
||||
var out = entity_vertex(position, uv, overlay, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
return out;
|
||||
}
|
||||
// Retained bolt-local geometry, translated using the same f64 camera
|
||||
// subtraction as the former CPU path before adding each local f32 position.
|
||||
// The bolt offset is instance attribute 7: 6 is the entity vertex's glint UV.
|
||||
@vertex fn vs_lightning(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>,
|
||||
@location(6) offset: vec3<f32>) -> Out {
|
||||
@location(7) offset: vec3<f32>) -> Out {
|
||||
return entity_vertex(offset + position, uv, overlay, color, light, normal);
|
||||
}
|
||||
struct CloudInfo {
|
||||
@@ -166,6 +181,27 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
if d >= end { return 1.0; }
|
||||
return (d - start) / (end - start);
|
||||
}
|
||||
// TextureTransform.setupGlintTexturing(0.16), ARMOR_ENTITY_GLINT_TEXTURING.
|
||||
// globals.time.w is (Util.getMillis() * glintSpeed * 8) modulo 330000, the
|
||||
// common period of both offsets.
|
||||
fn armor_glint_uv(model: vec2<f32>) -> vec2<f32> {
|
||||
let ticks = globals.time.w;
|
||||
let offset0 = (ticks % 110000.0) / 110000.0;
|
||||
let offset1 = (ticks % 30000.0) / 30000.0;
|
||||
let angle = 0.17453292;
|
||||
let c = cos(angle);
|
||||
let s = sin(angle);
|
||||
let scaled = model * 0.16;
|
||||
return vec2(scaled.x * c - scaled.y * s - offset0,
|
||||
scaled.x * s + scaled.y * c + offset1);
|
||||
}
|
||||
// RenderPipelines.GLINT: the source colour is scaled by itself, so `GlintAlpha`
|
||||
// is squared along with the sample.
|
||||
fn glint_over(color: vec4<f32>, glint: vec4<f32>) -> vec4<f32> {
|
||||
let alpha = globals.fog_ends.w;
|
||||
let g = alpha * glint;
|
||||
return vec4(color.rgb + g.rgb * g.rgb, max(color.a, alpha));
|
||||
}
|
||||
// CRUMBLING_UV_BEGIN
|
||||
fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>, dimensions: vec2<u32>) -> vec2<f32> {
|
||||
let size = vec2<f32>(dimensions);
|
||||
@@ -204,21 +240,29 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
color = vec4(mix(in.overlay_color.rgb, color.rgb, in.overlay_color.a), color.a);
|
||||
color *= in.light_color;
|
||||
if in.overlay == 7u || in.overlay == 8u || in.overlay == 33u {
|
||||
// Enchantment glint (overlay 7, 8 with the hurt tint): the purple
|
||||
// enchanted_glint sheen drifting diagonally over the item's shape.
|
||||
let px = in.uv * vec2<f32>(textureDimensions(atlas));
|
||||
let t = globals.misc.y * 0.05;
|
||||
let band = sin((px.x + px.y) * 0.9 - t * 6.0) * 0.5 + 0.5;
|
||||
let band2 = sin((px.x - px.y) * 0.6 + t * 4.0) * 0.5 + 0.5;
|
||||
let shimmer = pow(band * 0.6 + band2 * 0.4, 2.0);
|
||||
let glint = vec3(0.50, 0.25, 0.80) * (0.25 + 0.75 * shimmer);
|
||||
color = vec4(min(color.rgb + glint * 0.6, vec3(1.0)), color.a);
|
||||
// 33: a translucent item with the glint.
|
||||
// Enchantment glint: item.fsh's GLINT path, sampled from
|
||||
// enchanted_glint_item at the `GLINT_TEXTURING` UV.
|
||||
color = glint_over(color, textureSample(glint_tex, glint_sampler, in.glint_uv));
|
||||
} else if in.overlay == 13u {
|
||||
// Enchanted armour: enchanted_glint_armor at the scrolling model UV.
|
||||
color = glint_over(
|
||||
color,
|
||||
textureSample(glint_armor_tex, glint_sampler, armor_glint_uv(in.glint_uv)),
|
||||
);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
// 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.
|
||||
@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);
|
||||
}
|
||||
|
||||
fn celestial_color(in: Out) -> vec4<f32> {
|
||||
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
}
|
||||
@@ -237,6 +281,20 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
|
||||
}
|
||||
|
||||
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
|
||||
// the UV and repeated within itself, times the layer colour 0xFF808080, unlit; the pipeline adds
|
||||
// it to the frame. The light bytes carry the sprite's origin and the colour bytes its size, both
|
||||
// as breaking::stage_light packs them.
|
||||
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
|
||||
let bytes = round(in.color * 255.0);
|
||||
let size = vec2(bytes.x + bytes.y * 256.0, bytes.z + bytes.w * 256.0) / 65535.0;
|
||||
let color = textureSample(atlas, atlas_sampler, crumbling_uv(in.uv, in.wrap, size, textureDimensions(atlas)));
|
||||
if color.a < 0.1 { discard; }
|
||||
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 * (128.0 / 255.0), globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
}
|
||||
|
||||
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
|
||||
@fragment fn fs_water_mask() -> @location(0) vec4<f32> {
|
||||
return vec4(0.0);
|
||||
|
||||
@@ -186,6 +186,8 @@ mod tests {
|
||||
color: u32::MAX,
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
};
|
||||
let prior = vertex([0.0; 3]);
|
||||
let body = [vertex([8.0, -1.0, -1.0]), vertex([10.0, 1.0, 1.0])];
|
||||
|
||||
@@ -339,6 +339,18 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
|
||||
map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8));
|
||||
}
|
||||
"minecraft:panda" => {
|
||||
use crate::mobs::panda::GENES;
|
||||
for (key, gene) in [
|
||||
("MainGene", m.extra.variant),
|
||||
("HiddenGene", m.extra.variant2),
|
||||
] {
|
||||
map.insert(
|
||||
key.into(),
|
||||
string(GENES[usize::from(gene).min(GENES.len() - 1)]),
|
||||
);
|
||||
}
|
||||
}
|
||||
"minecraft:piglin" | "minecraft:piglin_brute" | "minecraft:hoglin" => {
|
||||
// Only Piglin.addAdditionalSaveData writes CannotHunt.
|
||||
if id == "minecraft:piglin_brute" {
|
||||
@@ -577,6 +589,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.insert("anger".into(), Tag::compound(Vec::<(&str, Tag)>::new()));
|
||||
}
|
||||
}
|
||||
// Armadillo.addAdditionalSaveData: the time left to the next scute.
|
||||
"minecraft:armadillo" => {
|
||||
map.insert("scute_time".into(), Tag::Int(m.extra.egg_time));
|
||||
}
|
||||
"minecraft:ravager" => {
|
||||
map.insert("AttackTick".into(), Tag::Int(m.extra.counter));
|
||||
map.insert("StunTick".into(), Tag::Int(m.extra.counter2));
|
||||
@@ -1090,6 +1106,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.color =
|
||||
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Panda {
|
||||
let gene = |key| text(tag.get(key)).map_or(0, |n| crate::mobs::panda::gene_from_name(&n));
|
||||
m.extra.variant = gene("MainGene");
|
||||
m.extra.variant2 = gene("HiddenGene");
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Wolf {
|
||||
let health = m.health;
|
||||
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
|
||||
@@ -1198,6 +1219,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
st.vibration = crate::sculk::Data::from_nbt(listener);
|
||||
}
|
||||
}
|
||||
// Armadillo.readAdditionalSaveData: keeps the constructor's time when absent.
|
||||
if m.species() == crate::mobs::Species::Armadillo
|
||||
&& let Some(t) = number(tag.get("scute_time"))
|
||||
{
|
||||
m.extra.egg_time = t as i32;
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Ravager {
|
||||
m.extra.counter = number(tag.get("AttackTick")).unwrap_or(0) as i32;
|
||||
m.extra.counter2 = number(tag.get("StunTick")).unwrap_or(0) as i32;
|
||||
@@ -1822,6 +1849,15 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
}
|
||||
put(&mut tag, "Health", Tag::Float(a.health.to_bits()));
|
||||
put(&mut tag, "Fire", Tag::Short(a.fire_ticks as i16));
|
||||
// ArmorStand.addAdditionalSaveData: the `Pose` parts that are not default.
|
||||
let pose: Vec<(&str, Tag)> = ArmorStand::POSE_KEYS
|
||||
.into_iter()
|
||||
.zip(a.pose)
|
||||
.zip(ArmorStand::DEFAULT_POSE)
|
||||
.filter(|((_, rotation), default)| rotation != default)
|
||||
.map(|((key, rotation), _)| (key, floats(&rotation)))
|
||||
.collect();
|
||||
put(&mut tag, "Pose", Tag::compound(pose));
|
||||
// LivingEntity.addAdditionalSaveData and ArmorStand's own fields.
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
for (key, value) in [
|
||||
@@ -1861,6 +1897,19 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
|
||||
a.invisible = flag(tag, "Invisible");
|
||||
a.marker = flag(tag, "Marker");
|
||||
a.on_ground = flag(tag, "OnGround");
|
||||
// ArmorStand.readAdditionalSaveData: each part of `Pose` that is a list of
|
||||
// three numbers, the others staying at their defaults.
|
||||
if let Some(pose) = tag.get("Pose") {
|
||||
for (rotation, key) in a.pose.iter_mut().zip(ArmorStand::POSE_KEYS) {
|
||||
if let Some(Tag::List(values)) = pose.get(key)
|
||||
&& values.len() == 3
|
||||
{
|
||||
for (r, v) in rotation.iter_mut().zip(values) {
|
||||
*r = float(Some(v)).unwrap_or(0.0) as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(a)
|
||||
}
|
||||
/// A boat entity (`minecraft:<wood>_boat`, `minecraft:bamboo_chest_raft`...):
|
||||
|
||||
@@ -48,6 +48,8 @@ pub fn flames(
|
||||
color: 0xffff_ffff,
|
||||
light: 0xf000 | (u32::from(scene_light >> 4) * 16),
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
remaining -= 0.45;
|
||||
|
||||
+252
-146
@@ -22,7 +22,9 @@ mod bundles;
|
||||
mod campfire;
|
||||
mod campfire_douse;
|
||||
mod cartography;
|
||||
mod cat_spawner;
|
||||
mod chat;
|
||||
mod chiseled_bookshelf;
|
||||
mod client_ambient;
|
||||
mod clipboard;
|
||||
mod combat;
|
||||
@@ -65,6 +67,7 @@ mod functions;
|
||||
mod game_mode;
|
||||
mod gamemode_switcher;
|
||||
mod ghasts;
|
||||
mod glowing;
|
||||
mod golems;
|
||||
mod growth;
|
||||
mod hand;
|
||||
@@ -127,11 +130,13 @@ mod packs;
|
||||
mod pad;
|
||||
mod pad_menu;
|
||||
mod paintings;
|
||||
mod pandas;
|
||||
mod passengers;
|
||||
mod persist;
|
||||
mod perspective;
|
||||
mod pickups;
|
||||
mod piglins;
|
||||
mod piston_push;
|
||||
mod player_equip;
|
||||
mod postprocess;
|
||||
mod potions;
|
||||
@@ -739,7 +744,7 @@ impl LevelAccess for GameLevel<'_> {
|
||||
&self,
|
||||
pos: BlockPos,
|
||||
state: BlockStateId,
|
||||
_direction: Direction,
|
||||
direction: Direction,
|
||||
) -> i32 {
|
||||
if let Some(s) = self.registry.state(state)
|
||||
&& s.name == "minecraft:detector_rail"
|
||||
@@ -772,6 +777,11 @@ impl LevelAccess for GameLevel<'_> {
|
||||
if let Some(signal) = books::lectern_signal(self.world, pos) {
|
||||
return signal;
|
||||
}
|
||||
if let Some(signal) =
|
||||
machines::shelf_signal(self.world, self.registry, state, pos, direction)
|
||||
{
|
||||
return signal;
|
||||
}
|
||||
machines::container_signal(self.world, self.behaviours, self.items, self.registry, pos)
|
||||
}
|
||||
fn block_entity_signal(
|
||||
@@ -1281,6 +1291,8 @@ struct Game<'a> {
|
||||
pending_pack_reload: bool,
|
||||
/// A server's resource packs (game/server_packs.rs).
|
||||
server_packs: server_packs::ServerPackState,
|
||||
/// The link a chat click asks to open (`ConfirmLinkScreen`).
|
||||
link_prompt: Option<String>,
|
||||
dialogs: dialogs::DialogState,
|
||||
world_renderer_ready: bool,
|
||||
collision: &'a BlockCollisionRegistry<'a>,
|
||||
@@ -1388,6 +1400,8 @@ struct Game<'a> {
|
||||
/// Runs of `entity_vertices` by entity, for a shader pack's per-entity draws; recorded only
|
||||
/// while `pack_ids` is set (game/shader_packs.rs).
|
||||
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
|
||||
/// The glowing entities' silhouettes this frame (game/glowing.rs).
|
||||
glowing_vertices: std::cell::RefCell<Vec<Vertex>>,
|
||||
/// The shader pack's entity and block entity ids, while one draws entities.
|
||||
pack_ids: Option<shader_packs::PackIds>,
|
||||
started: Instant,
|
||||
@@ -1523,6 +1537,8 @@ struct Game<'a> {
|
||||
/// `ClientAvatarState`: walked distance and view bob.
|
||||
avatar: horizon_client::camera::AvatarBob,
|
||||
flash: u32,
|
||||
/// `ClientLevel.rainSoundTime`: ticks of rain since the last rain sound.
|
||||
rain_sound_time: i32,
|
||||
/// Bolts that struck here and sky flashes they made, in total (`status`).
|
||||
lightning_seen: [u32; 2],
|
||||
/// The head of the bed while sleeping; the visual timer also survives waking.
|
||||
@@ -1605,10 +1621,19 @@ struct Game<'a> {
|
||||
/// Map textures waiting for the next HUD batch to upload them.
|
||||
map_patches: Vec<horizon_client::font::Patch>,
|
||||
text_displays: std::collections::HashMap<i32, text_displays::RemoteTextDisplay>,
|
||||
/// The fence knots a server tracks to this client, by entity id, at their
|
||||
/// block (`LeashFenceKnotEntity`).
|
||||
remote_knots: HashMap<u32, [i32; 3]>,
|
||||
/// The entity ids a hosted server gives the knots its mobs hang on, by
|
||||
/// block, while a lead is tied there.
|
||||
knot_ids: HashMap<[i32; 3], u32>,
|
||||
text_display_vertices: usize,
|
||||
/// Player skin downloads, and which skin each atlas skin slot holds
|
||||
/// (least recently shown first).
|
||||
skin_downloads: RefCell<horizon_client::skins::Downloader>,
|
||||
/// `ProfileResolver.Cached`: `Dynamic` profiles (name- or id-only heads
|
||||
/// and mannequins) resolve through the services in the background.
|
||||
skin_profiles: RefCell<horizon_client::skins::Resolver>,
|
||||
skin_slots: Vec<(String, usize)>,
|
||||
/// Atlas trim slots in use: (sprite name, slot), least recently drawn first.
|
||||
trim_slots: Vec<(String, usize)>,
|
||||
@@ -2510,6 +2535,15 @@ impl Game<'_> {
|
||||
no_physics,
|
||||
&self.res.motion().fluids,
|
||||
);
|
||||
// Entity.applyEffectsFromBlocks: bubble columns lift or drag the item.
|
||||
horizon_client::motion::bubble_column_push(
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
item.bounds(),
|
||||
&mut item.velocity,
|
||||
);
|
||||
cells.moved(i, item.previous, item.position);
|
||||
// ItemEntity.tick: merge every 2 ticks while moving between
|
||||
// blocks, every 40 otherwise, before the lifetime check.
|
||||
@@ -2780,9 +2814,13 @@ impl Game<'_> {
|
||||
self.process_level_events()
|
||||
}
|
||||
fn attack_block(&mut self, pos: [i32; 3]) -> Result<()> {
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.attack_block(level, ticks, BlockPos::new(pos[0], pos[1], pos[2]));
|
||||
let note = self.with_level(|b, level, ticks| {
|
||||
b.attack_block(level, ticks, BlockPos::new(pos[0], pos[1], pos[2]))
|
||||
});
|
||||
// NoteBlock.attack awards the statistic.
|
||||
if note {
|
||||
self.stats.custom("play_noteblock", 1);
|
||||
}
|
||||
self.process_level_events()
|
||||
}
|
||||
/// Track which chunks are ticking (Ticks needs chunk load/unload).
|
||||
@@ -2978,20 +3016,29 @@ impl Game<'_> {
|
||||
b.run_block_events(level, ticks);
|
||||
b.tick_block_entities(level, ticks);
|
||||
});
|
||||
// `PistonMovingBlockEntity.tick` pushes what its block just ran into,
|
||||
// with the progress it is about to take.
|
||||
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
|
||||
self.push_piston_entities(pushes);
|
||||
self.process_level_events()?;
|
||||
self.sync_moving_pistons();
|
||||
self.process_level_events()
|
||||
Ok(())
|
||||
}
|
||||
/// Copies the moving-piston block entities into the world, where the
|
||||
/// collision queries and the renderer read them.
|
||||
fn sync_moving_pistons(&mut self) {
|
||||
let pistons = &mut self.world.moving_pistons;
|
||||
pistons.clear();
|
||||
pistons.extend(
|
||||
self.block_ticks
|
||||
.moving_pistons
|
||||
.iter()
|
||||
.map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state())),
|
||||
);
|
||||
let previous = std::mem::take(&mut self.world.moving_pistons);
|
||||
let current: Vec<_> = self
|
||||
.block_ticks
|
||||
.moving_pistons
|
||||
.iter()
|
||||
.map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state()))
|
||||
.collect();
|
||||
self.world.moving_piston_fallback = previous
|
||||
.into_iter()
|
||||
.filter(|(pos, _)| !current.iter().any(|(next, _)| next == pos))
|
||||
.collect();
|
||||
self.world.moving_pistons = current;
|
||||
}
|
||||
/// `ServerLevel.tick`'s scheduled block then fluid ticks, one at a time:
|
||||
/// the side effects of each (a shrieker summoning a warden, drops, game
|
||||
@@ -3093,6 +3140,11 @@ impl Game<'_> {
|
||||
pos,
|
||||
data,
|
||||
} => self.consume_teleport_particles([pos.x, pos.y, pos.z], data),
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2018,
|
||||
pos,
|
||||
data,
|
||||
} => self.enderman_teleport_particles([pos.x, pos.y, pos.z], data),
|
||||
LevelEvent::LevelEvent { id: 2003, pos, .. } => {
|
||||
self.eye_break_particles([pos.x, pos.y, pos.z]);
|
||||
}
|
||||
@@ -3443,15 +3495,9 @@ impl Game<'_> {
|
||||
self.open_ender_chest(key);
|
||||
// EnderChestBlock.useWithoutItem
|
||||
self.stats.custom("open_enderchest", 1);
|
||||
self.container_opener_event(key, true);
|
||||
let at = [
|
||||
f64::from(key.0) + 0.5,
|
||||
f64::from(key.1) + 0.5,
|
||||
f64::from(key.2) + 0.5,
|
||||
];
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
self.audio
|
||||
.play("minecraft:block.ender_chest.open", Some(at), 0.5, pitch);
|
||||
if self.container_opener_event(key, true) {
|
||||
self.container_edge_sound(key, true);
|
||||
}
|
||||
} else if &*name == "minecraft:beacon" && self.open_station(pos, &name) {
|
||||
self.world.block_entities.get_or_insert_with(key, || {
|
||||
BlockEntity::for_block(&name).unwrap_or(BlockEntity::Chest(Vec::new()))
|
||||
@@ -3467,7 +3513,7 @@ impl Game<'_> {
|
||||
];
|
||||
self.resolve_container_maps(key, centre);
|
||||
self.container = Some(key);
|
||||
self.container_opener_event(key, true);
|
||||
let first = self.container_opener_event(key, true);
|
||||
self.post_block_game_event(
|
||||
"minecraft:container_open",
|
||||
[key.0, key.1, key.2],
|
||||
@@ -3491,26 +3537,8 @@ impl Game<'_> {
|
||||
if self.guarded_by_piglins(&name) {
|
||||
self.anger_piglins(true);
|
||||
}
|
||||
let sound = if storage::is_shulker_box(&name) {
|
||||
Some("minecraft:block.shulker_box.open")
|
||||
} else if name.contains("chest") {
|
||||
Some("minecraft:block.chest.open")
|
||||
} else if &*name == "minecraft:barrel" {
|
||||
Some("minecraft:block.barrel.open")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(sound) = sound {
|
||||
self.audio.play(
|
||||
sound,
|
||||
Some([
|
||||
f64::from(key.0) + 0.5,
|
||||
f64::from(key.1) + 0.5,
|
||||
f64::from(key.2) + 0.5,
|
||||
]),
|
||||
0.5,
|
||||
1.0,
|
||||
);
|
||||
if first {
|
||||
self.container_edge_sound(key, true);
|
||||
}
|
||||
} else if self.open_station(pos, &name) {
|
||||
// Workstation menus: game/stations.rs.
|
||||
@@ -3978,6 +4006,12 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(swings) = self.use_chiseled_bookshelf(hit, held.as_ref()) {
|
||||
if swings {
|
||||
self.swing_arm();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if self.use_composter(hit.pos, held.as_ref()) {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
@@ -3994,6 +4028,10 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if self.use_beehive(hit.pos, held.as_ref())? {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if self.use_campfire_douse(hit.pos, held.as_ref())? {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
@@ -4350,6 +4388,8 @@ impl Game<'_> {
|
||||
self.release_used_item(&stack, ticks, hand_changed);
|
||||
return;
|
||||
}
|
||||
// ServerPlayer.updateUsingItem: USING_ITEM every tick of the use.
|
||||
self.trigger_using_item(&stack);
|
||||
let Some(info) = self.res.items().get(&stack.item).cloned() else {
|
||||
self.using = None;
|
||||
return;
|
||||
@@ -5050,9 +5090,14 @@ impl Game<'_> {
|
||||
if let Some(host) = &self.lan {
|
||||
host.hub.entity_event(host.host_entity, 3, feet);
|
||||
}
|
||||
self.screen = Screen::Dead;
|
||||
self.capture(false);
|
||||
self.ui.death_time = 0;
|
||||
if self.ui.rules.bool("immediate_respawn") {
|
||||
// Gui.setScreen: without the death screen the player respawns at once.
|
||||
self.respawn_player()?;
|
||||
} else {
|
||||
self.screen = Screen::Dead;
|
||||
self.capture(false);
|
||||
self.ui.death_time = 0;
|
||||
}
|
||||
} else if dead {
|
||||
// LivingEntity.tickDeath: 20 ticks after dying the player is
|
||||
// removed, and Player.remove's inventoryMenu.removed drops what
|
||||
@@ -5524,6 +5569,7 @@ impl Game<'_> {
|
||||
};
|
||||
self.draw_dialog(&mut batch);
|
||||
self.draw_pack_prompt(&mut batch);
|
||||
self.draw_link_prompt(&mut batch);
|
||||
// Gui.extractRenderState: the debug overlay comes after the screen
|
||||
// and the toasts, so it is over everything, menus included.
|
||||
self.draw_debug_overlay(&mut batch, size);
|
||||
@@ -5563,12 +5609,12 @@ impl Game<'_> {
|
||||
// The ToastManager draws over every screen: a server's packs
|
||||
// download while the connection screen shows.
|
||||
if self.remote_overlay(&mut batch) {
|
||||
self.draw_pack_toasts(&mut batch);
|
||||
self.draw_pack_toasts(&mut batch, false);
|
||||
return batch;
|
||||
}
|
||||
if let Some(reason) = self.loading_reason() {
|
||||
self.draw_level_loading(&mut batch, reason);
|
||||
self.draw_pack_toasts(&mut batch);
|
||||
self.draw_pack_toasts(&mut batch, false);
|
||||
return batch;
|
||||
}
|
||||
// Hud.extractRenderState: F1 (keyToggleGui) hides camera overlays, the
|
||||
@@ -5585,7 +5631,11 @@ impl Game<'_> {
|
||||
let delay = horizon_client::attack_strength::delay(self.main_hand_attack_speed());
|
||||
let strength = self.hand_strength.scale(0.0, self.main_hand_attack_speed());
|
||||
// The 3D crosshair (F3) takes the 2D one's place.
|
||||
if self.camera_type.first_person() && !spectator && !self.debug_crosshair() {
|
||||
// Hud.extractCrosshair: a spectator only sees it over something to open.
|
||||
if self.camera_type.first_person()
|
||||
&& (!spectator || self.spectator_crosshair())
|
||||
&& !self.debug_crosshair()
|
||||
{
|
||||
let attack = (indicator == 1).then_some(strength);
|
||||
let full = attack.is_some_and(|a| {
|
||||
hud_layout::full_attack_indicator(a, delay, self.crosshair_living())
|
||||
@@ -5786,8 +5836,9 @@ impl Game<'_> {
|
||||
Screen::Playing | Screen::Options | Screen::Win => {}
|
||||
}
|
||||
self.draw_saving_indicator(&mut batch);
|
||||
self.draw_toasts(&mut batch);
|
||||
self.draw_pack_toasts(&mut batch);
|
||||
// ToastManager.extractRenderState: F1 hides the toasts too.
|
||||
self.draw_toasts(&mut batch, !show_hud);
|
||||
self.draw_pack_toasts(&mut batch, !show_hud);
|
||||
self.remote_hud(&mut batch);
|
||||
self.draw_sign_editor(&mut batch);
|
||||
self.draw_book(&mut batch);
|
||||
@@ -5842,9 +5893,11 @@ impl Game<'_> {
|
||||
| BlockEntity::Vault(_)
|
||||
| BlockEntity::Beehive(_)
|
||||
| BlockEntity::CommandBlock(_)
|
||||
| BlockEntity::EndGateway(_)
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::DaylightDetector
|
||||
| BlockEntity::Campfire(_),
|
||||
| BlockEntity::Campfire(_)
|
||||
| BlockEntity::ChiseledBookshelf(_),
|
||||
) => {}
|
||||
None => {
|
||||
v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i));
|
||||
@@ -6114,9 +6167,11 @@ impl Game<'_> {
|
||||
| BlockEntity::Vault(_)
|
||||
| BlockEntity::Beehive(_)
|
||||
| BlockEntity::CommandBlock(_)
|
||||
| BlockEntity::EndGateway(_)
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::DaylightDetector
|
||||
| BlockEntity::Campfire(_),
|
||||
| BlockEntity::Campfire(_)
|
||||
| BlockEntity::ChiseledBookshelf(_),
|
||||
) => {}
|
||||
None if self.mount_active() => self.draw_mount_background(batch, ox, oy),
|
||||
None => {
|
||||
@@ -6195,41 +6250,15 @@ impl Game<'_> {
|
||||
if let Some(key) = self.container.take() {
|
||||
// A cart's or chest boat's items go back to it.
|
||||
self.close_cart_container(key);
|
||||
self.container_opener_event(key, false);
|
||||
let last = self.container_opener_event(key, false);
|
||||
self.post_block_game_event(
|
||||
"minecraft:container_close",
|
||||
[key.0, key.1, key.2],
|
||||
Some(horizon_client::mobs::Who::Player),
|
||||
None,
|
||||
);
|
||||
let name = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(key.0, key.1, key.2))
|
||||
.name
|
||||
.clone();
|
||||
let sound = if storage::is_shulker_box(&name) {
|
||||
Some("minecraft:block.shulker_box.close")
|
||||
} else if &*name == "minecraft:ender_chest" {
|
||||
Some("minecraft:block.ender_chest.close")
|
||||
} else if name.contains("chest") {
|
||||
Some("minecraft:block.chest.close")
|
||||
} else if &*name == "minecraft:barrel" {
|
||||
Some("minecraft:block.barrel.close")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(sound) = sound {
|
||||
self.audio.play(
|
||||
sound,
|
||||
Some([
|
||||
f64::from(key.0) + 0.5,
|
||||
f64::from(key.1) + 0.5,
|
||||
f64::from(key.2) + 0.5,
|
||||
]),
|
||||
0.5,
|
||||
1.0,
|
||||
);
|
||||
if last {
|
||||
self.container_edge_sound(key, false);
|
||||
}
|
||||
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
|
||||
}
|
||||
@@ -6749,6 +6778,7 @@ impl Game<'_> {
|
||||
self.player_menu_sound(sound);
|
||||
}
|
||||
let crafted = std::mem::take(&mut menu.crafted);
|
||||
let brewed = std::mem::take(&mut menu.brewed);
|
||||
let equipped = std::mem::take(&mut menu.equipped);
|
||||
for (i, stack) in after.into_iter().enumerate() {
|
||||
if let Some(g) = game_of[i] {
|
||||
@@ -6764,6 +6794,9 @@ impl Game<'_> {
|
||||
self.player_drop(stack);
|
||||
}
|
||||
self.award_crafted(&crafted);
|
||||
for stack in &brewed {
|
||||
self.trigger_brewed_potion(stack);
|
||||
}
|
||||
if furnace_before > furnace_after
|
||||
&& self.remote.is_none()
|
||||
&& let (Some(pos), Some(p)) = (self.container, self.player.as_ref())
|
||||
@@ -7802,6 +7835,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8260,6 +8295,15 @@ impl Game<'_> {
|
||||
None => horizon_client::mobs::Damage::of(horizon_client::mobs::DamageKind::Generic),
|
||||
};
|
||||
let landed = self.hurt_by_player(index, amount, source);
|
||||
if landed {
|
||||
let victim = self.mob_entity(&self.mobs[index]);
|
||||
self.player_hit(
|
||||
&victim,
|
||||
creatures::damage_type(source.kind, true),
|
||||
None,
|
||||
amount,
|
||||
);
|
||||
}
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
if p.attack_log.len() >= 64 {
|
||||
p.attack_log.remove(0);
|
||||
@@ -8353,15 +8397,24 @@ impl Game<'_> {
|
||||
// The hurt or death sound the mob raised (getHurtSound / getDeathSound,
|
||||
// its voice pitch from its own random).
|
||||
let queued = std::mem::take(&mut self.mobs[index].outbox);
|
||||
let mut infested = false;
|
||||
for event in queued {
|
||||
if let horizon_client::mobs::MobEvent::Sound(s, at, volume, pitch) = event {
|
||||
self.audio
|
||||
.play(&format!("minecraft:{s}"), Some(at), volume, pitch);
|
||||
} else {
|
||||
// Left for the end of the tick.
|
||||
self.mobs[index].outbox.push(event);
|
||||
match event {
|
||||
horizon_client::mobs::MobEvent::Sound(s, at, volume, pitch) => {
|
||||
self.audio
|
||||
.play(&format!("minecraft:{s}"), Some(at), volume, pitch);
|
||||
}
|
||||
horizon_client::mobs::MobEvent::Infested { .. } => infested = true,
|
||||
other => {
|
||||
// Left for the end of the tick.
|
||||
self.mobs[index].outbox.push(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
if infested {
|
||||
let id = self.mobs[index].id;
|
||||
self.mob_infested(id);
|
||||
}
|
||||
self.weapon_hit(index);
|
||||
// Player.attack: causeFoodExhaustion(0.1F) only when the hit hurt
|
||||
// (inside `if (wasHurt)`), after damageStatsAndHearts.
|
||||
@@ -8449,6 +8502,8 @@ impl Game<'_> {
|
||||
layer: 2,
|
||||
color,
|
||||
light: 0,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -8648,6 +8703,7 @@ impl Game<'_> {
|
||||
self.spawn_spiral = Default::default();
|
||||
self.player_spawn = None;
|
||||
self.creatures.reset_trader();
|
||||
self.creatures.cat_tick = 0;
|
||||
self.portals = Default::default();
|
||||
self.experience = Experience {
|
||||
level: self.level.xp_level,
|
||||
@@ -9330,6 +9386,8 @@ impl Game<'_> {
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
// BlockTransformer.transformBlock: ITEM_USED_ON_BLOCK before the swap.
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, stack);
|
||||
self.level_set(pos, new)?;
|
||||
// BlockTransformer.TransformParticle.send.
|
||||
self.transform_particle(&particle, pos);
|
||||
@@ -9602,6 +9660,8 @@ impl Game<'_> {
|
||||
if let Some(waxed) = old.and_then(|old| self.res.transformers().waxed(registry, &old))
|
||||
&& let Ok(state) = registry.resolve(&waxed)
|
||||
{
|
||||
// HoneycombItem.useOn: ITEM_USED_ON_BLOCK before the swap.
|
||||
self.trigger_block("minecraft:item_used_on_block", hit.pos, stack);
|
||||
self.level_set(hit.pos, state)?;
|
||||
self.level_event_at(3003, hit.pos, 0);
|
||||
self.audio.play(
|
||||
@@ -10320,40 +10380,7 @@ impl Game<'_> {
|
||||
if self.flash > 0 {
|
||||
self.flash -= 1;
|
||||
}
|
||||
let (rain, _) = self.world.weather.levels(1.0);
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return;
|
||||
};
|
||||
// WeatherEffectRenderer.tickRainParticles: occasional rain sounds.
|
||||
if rain > 0.0 && self.world.random.next_int_bound(3).unwrap_or(1) < (rain * 3.0) as i32 {
|
||||
let r = &mut self.world.random;
|
||||
let (dx, dz) = (
|
||||
r.next_int_bound(21).unwrap_or(10) - 10,
|
||||
r.next_int_bound(21).unwrap_or(10) - 10,
|
||||
);
|
||||
let (x, z) = (eye[0].floor() as i32 + dx, eye[2].floor() as i32 + dz);
|
||||
let top = self.rain_height(x, z);
|
||||
if self.precipitation(x, top, z) == Some(false)
|
||||
&& (top - eye[1] as i32).abs() < 10
|
||||
&& self.world.random.next_int_bound(5).unwrap_or(1) == 0
|
||||
{
|
||||
let above = f64::from(top) > eye[1] + 1.0;
|
||||
let (name, volume, pitch) = if above {
|
||||
("minecraft:weather.rain.above", 0.1, 0.5)
|
||||
} else {
|
||||
("minecraft:weather.rain", 0.2, 1.0)
|
||||
};
|
||||
// The Horizon Weather option off silences the rain too.
|
||||
if self.settings.horizon.weather {
|
||||
self.audio.play(
|
||||
name,
|
||||
Some([f64::from(x) + 0.5, f64::from(top), f64::from(z) + 0.5]),
|
||||
volume,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The rain sounds come with the rain splashes (`tick_particles`).
|
||||
}
|
||||
/// WeatherEffectRenderer: rain and snow columns within the weather radius.
|
||||
fn weather_geometry(
|
||||
@@ -10435,11 +10462,29 @@ impl Game<'_> {
|
||||
rain.extend(snow_vertices);
|
||||
rain
|
||||
}
|
||||
/// A translated message for the player the game acts for: `overlay` is
|
||||
/// `sendOverlayMessage` (the action bar), else `sendSystemMessage` (chat).
|
||||
/// A guest gets a packet, so its own client translates it.
|
||||
fn tell_sleeper(&mut self, key: &str, fallback: &str, overlay: bool) {
|
||||
if self.lan_acting.is_some() {
|
||||
self.guest_packets
|
||||
.push(horizon_protocol::packets::play::Clientbound::SystemChat(
|
||||
horizon_protocol::packets::play::SystemChat {
|
||||
content: horizon_protocol::text::translatable(key, &[]),
|
||||
overlay,
|
||||
},
|
||||
));
|
||||
} else if overlay {
|
||||
self.message = Some((self.translate(key, fallback), Instant::now()));
|
||||
} else {
|
||||
self.system_message(horizon_client::text::Text::translate(key, &[]));
|
||||
}
|
||||
}
|
||||
/// ServerPlayer.startSleepInBed: in reach, not obstructed, night or thunder,
|
||||
/// and (outside creative) no monsters within 8 blocks; the bed becomes the
|
||||
/// respawn point either way.
|
||||
fn try_sleep(&mut self, pos: [i32; 3]) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
let Some((position, yaw)) = self.player.as_ref().map(|p| (p.position, p.yaw)) else {
|
||||
return;
|
||||
};
|
||||
let time = self.world.day_time.rem_euclid(24000);
|
||||
@@ -10462,15 +10507,16 @@ impl Game<'_> {
|
||||
// ServerPlayer.bedInRange / isReachableBedBlock: within 3 blocks
|
||||
// sideways and 2 vertically of the head or the foot.
|
||||
let reachable = |bed: [i32; 3]| {
|
||||
(p.position.x - f64::from(bed[0]) - 0.5).abs() <= 3.0
|
||||
&& (p.position.y - f64::from(bed[1])).abs() <= 2.0
|
||||
&& (p.position.z - f64::from(bed[2]) - 0.5).abs() <= 3.0
|
||||
(position.x - f64::from(bed[0]) - 0.5).abs() <= 3.0
|
||||
&& (position.y - f64::from(bed[1])).abs() <= 2.0
|
||||
&& (position.z - f64::from(bed[2]) - 0.5).abs() <= 3.0
|
||||
};
|
||||
if !reachable(head) && !reachable(foot) {
|
||||
self.message = Some((
|
||||
"You may not rest now; the bed is too far away".into(),
|
||||
Instant::now(),
|
||||
));
|
||||
self.tell_sleeper(
|
||||
"block.minecraft.bed.too_far_away",
|
||||
"You may not rest now; the bed is too far away",
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// ServerPlayer.bedBlocked: Player.freeAt (not suffocating) for the
|
||||
@@ -10483,7 +10529,11 @@ impl Game<'_> {
|
||||
f.full_collision && f.solid_render
|
||||
});
|
||||
if blocked {
|
||||
self.message = Some(("This bed is obstructed".into(), Instant::now()));
|
||||
self.tell_sleeper(
|
||||
"block.minecraft.bed.obstructed",
|
||||
"This bed is obstructed",
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// In reach (bedInRange), day or night: setRespawnPosition(
|
||||
@@ -10492,9 +10542,16 @@ impl Game<'_> {
|
||||
// and finds where to stand up (findRespawnAndUseSpawnBlock).
|
||||
// Straw beds never set it (`straw_bed_rule`).
|
||||
if self.bed_sets_spawn(state) {
|
||||
self.respawn_config = Some(respawn_anchor::RespawnConfig::wrapped(
|
||||
self.dim, head, p.yaw, false,
|
||||
));
|
||||
let config = respawn_anchor::RespawnConfig::wrapped(self.dim, head, yaw, false);
|
||||
// ServerPlayer.setRespawnPosition: the chat message only when the
|
||||
// point moved (`isSamePosition`).
|
||||
let moved = self
|
||||
.respawn_config
|
||||
.is_none_or(|c| (c.dim, c.pos) != (config.dim, config.pos));
|
||||
self.respawn_config = Some(config);
|
||||
if moved {
|
||||
self.tell_sleeper("block.minecraft.set_spawn", "Respawn point set", false);
|
||||
}
|
||||
// The fallback becomes the world spawn: a guest's is none of its
|
||||
// own, so it follows `setworldspawn`.
|
||||
self.spawn = if self.lan_acting.is_some() {
|
||||
@@ -10509,10 +10566,11 @@ impl Game<'_> {
|
||||
};
|
||||
}
|
||||
if !night {
|
||||
self.message = Some((
|
||||
"You can sleep only at night or during thunderstorms".into(),
|
||||
Instant::now(),
|
||||
));
|
||||
self.tell_sleeper(
|
||||
"block.minecraft.bed.no_sleep",
|
||||
"You can sleep only at night or during thunderstorms",
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Creative players skip the monster check (`!this.isCreative()`).
|
||||
@@ -10520,18 +10578,26 @@ impl Game<'_> {
|
||||
&& self.mobs.iter().any(|m| {
|
||||
m.hostile()
|
||||
&& m.alive()
|
||||
&& (m.position.x - p.position.x).abs() < 8.0
|
||||
&& (m.position.y - p.position.y).abs() < 5.0
|
||||
&& (m.position.z - p.position.z).abs() < 8.0
|
||||
&& (m.position.x - position.x).abs() < 8.0
|
||||
&& (m.position.y - position.y).abs() < 5.0
|
||||
&& (m.position.z - position.z).abs() < 8.0
|
||||
});
|
||||
if monsters {
|
||||
self.message = Some((
|
||||
"You may not rest now; there are monsters nearby".into(),
|
||||
Instant::now(),
|
||||
));
|
||||
self.tell_sleeper(
|
||||
"block.minecraft.bed.not_safe",
|
||||
"You may not rest now; there are monsters nearby",
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.message = Some(("Respawn point set".into(), Instant::now()));
|
||||
// ServerPlayer.startSleepInBed: a percentage above 100 cannot skip the night.
|
||||
if self.ui.rules.int("players_sleeping_percentage") > 100 {
|
||||
self.tell_sleeper(
|
||||
"sleep.not_possible",
|
||||
"No amount of rest can pass this night",
|
||||
true,
|
||||
);
|
||||
}
|
||||
self.stop_mining();
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
@@ -11276,6 +11342,7 @@ impl Game<'_> {
|
||||
let mut entities = std::mem::take(&mut self.entity_vertices);
|
||||
entities.clear();
|
||||
self.see_through_vertices.clear();
|
||||
self.glowing_vertices.get_mut().clear();
|
||||
if !title {
|
||||
entities = horizon_client::spans::time("entity_geometry", || {
|
||||
self.entity_geometry(camera, alpha, entities)
|
||||
@@ -11343,6 +11410,7 @@ impl Game<'_> {
|
||||
// map_patches any earlier leaves them a frame behind.
|
||||
hud.patches.append(&mut self.map_patches);
|
||||
let entity_spans = std::mem::take(self.entity_spans.get_mut());
|
||||
let glowing = std::mem::take(self.glowing_vertices.get_mut());
|
||||
let frame = Frame {
|
||||
weather: &weather,
|
||||
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
|
||||
@@ -11392,6 +11460,8 @@ impl Game<'_> {
|
||||
debug_lines_on_top: &debug_lines_on_top,
|
||||
debug_crosshair: &debug_crosshair,
|
||||
hud: &hud,
|
||||
glint_alpha: self.settings.file.double("glintStrength") as f32,
|
||||
glint_ticks: self.glint_ticks(),
|
||||
clouds: (!title
|
||||
&& self.dim == Dim::Overworld
|
||||
&& horizon_client::clouds::visible(&self.settings.clouds, sky.cloud_color[3]))
|
||||
@@ -11409,6 +11479,7 @@ impl Game<'_> {
|
||||
}),
|
||||
entities: &entities,
|
||||
entity_spans: &entity_spans,
|
||||
glowing: &glowing,
|
||||
dynamic_lights: &self.dynamic_lights,
|
||||
lightning: horizon_client::gfx::lightning::Input {
|
||||
bolts: &bolts,
|
||||
@@ -11464,6 +11535,7 @@ impl Game<'_> {
|
||||
// Restore storage even when rendering fails (for example, a lost surface).
|
||||
self.entity_vertices = entities;
|
||||
*self.entity_spans.get_mut() = entity_spans;
|
||||
*self.glowing_vertices.get_mut() = glowing;
|
||||
self.see_through_vertices = see_through;
|
||||
result
|
||||
}
|
||||
@@ -12341,6 +12413,11 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.advancements_scroll(scaled(raw_x) as f32, d);
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.link_prompt.is_some() => {
|
||||
if state == ElementState::Pressed && button == MouseButton::Left {
|
||||
self.link_prompt_click();
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. }
|
||||
if self.server_packs.prompt.is_some() =>
|
||||
{
|
||||
@@ -12570,6 +12647,13 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
}
|
||||
// ConfirmScreen.keyPressed: Escape answers no.
|
||||
WindowEvent::KeyboardInput { event, .. } if self.link_prompt.is_some() => {
|
||||
if event.state == ElementState::Pressed
|
||||
&& event.physical_key == PhysicalKey::Code(KeyCode::Escape)
|
||||
{
|
||||
self.answer_link_prompt(false);
|
||||
}
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.server_packs.prompt.is_some() => {
|
||||
if event.state == ElementState::Pressed
|
||||
&& event.physical_key == PhysicalKey::Code(KeyCode::Escape)
|
||||
@@ -13103,6 +13187,7 @@ impl<'a> Game<'a> {
|
||||
world_loader: None,
|
||||
pending_pack_reload: false,
|
||||
server_packs: Default::default(),
|
||||
link_prompt: None,
|
||||
dialogs: Default::default(),
|
||||
world_renderer_ready: false,
|
||||
collision: res.collision(),
|
||||
@@ -13179,6 +13264,7 @@ impl<'a> Game<'a> {
|
||||
luminance: None,
|
||||
see_through_vertices: Vec::new(),
|
||||
entity_spans: Default::default(),
|
||||
glowing_vertices: Default::default(),
|
||||
pack_ids: None,
|
||||
health_hud: hud_layout::HealthHud::default(),
|
||||
started: now,
|
||||
@@ -13269,6 +13355,7 @@ impl<'a> Game<'a> {
|
||||
dragging: false,
|
||||
avatar: Default::default(),
|
||||
flash: 0,
|
||||
rain_sound_time: 0,
|
||||
lightning_seen: [0; 2],
|
||||
sleeping: None,
|
||||
sleep_timer: 0,
|
||||
@@ -13323,12 +13410,15 @@ impl<'a> Game<'a> {
|
||||
dragon_model: prepared.dragon_model,
|
||||
map_patches: Vec::new(),
|
||||
text_displays: Default::default(),
|
||||
remote_knots: Default::default(),
|
||||
knot_ids: Default::default(),
|
||||
text_display_vertices: 0,
|
||||
skin_downloads: RefCell::new(horizon_client::skins::Downloader::new(
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join("assets/skins"),
|
||||
)),
|
||||
skin_profiles: RefCell::new(horizon_client::skins::Resolver::new()),
|
||||
skin_slots: Vec::new(),
|
||||
trim_slots: Vec::new(),
|
||||
cart_models: prepared.cart_models,
|
||||
@@ -13411,7 +13501,7 @@ impl<'a> Game<'a> {
|
||||
admin_feedback: Vec::new(),
|
||||
console_broadcast: None,
|
||||
cmds: Default::default(),
|
||||
ui: Default::default(),
|
||||
ui: chat::UiState::new(),
|
||||
combat: Default::default(),
|
||||
guest_combat: Default::default(),
|
||||
mount: Default::default(),
|
||||
@@ -14070,6 +14160,12 @@ fn build_renderer(
|
||||
&assets.sprite_mips,
|
||||
&assets.animations,
|
||||
(&atlas.pixels, atlas.width, atlas.height),
|
||||
(&atlas.glint.0, atlas.glint.1, atlas.glint.2),
|
||||
(
|
||||
&atlas.glint_armor.0,
|
||||
atlas.glint_armor.1,
|
||||
atlas.glint_armor.2,
|
||||
),
|
||||
(&pack.forcefield.2, pack.forcefield.0),
|
||||
&gui.panorama,
|
||||
prepared,
|
||||
@@ -14096,6 +14192,7 @@ fn loading_renderer(
|
||||
) -> Result<Renderer> {
|
||||
static FORCEFIELD: [u8; 4] = [0; 4];
|
||||
static EMPTY_HUD: [u8; 4] = [0, 0, 0, 255];
|
||||
static EMPTY_GLINT: [u8; 4] = [0; 4];
|
||||
let empty_panorama = horizon_client::gfx::panorama::Faces {
|
||||
size: 1,
|
||||
pixels: [0u8; 4].repeat(6),
|
||||
@@ -14106,6 +14203,10 @@ fn loading_renderer(
|
||||
Some(atlas) => (atlas.pixels.as_slice(), atlas.width, atlas.height),
|
||||
None => (EMPTY_HUD.as_slice(), 1, 1),
|
||||
};
|
||||
let (glint_pixels, glint_width, glint_height) = match atlas {
|
||||
Some(atlas) => (atlas.glint.0.as_slice(), atlas.glint.1, atlas.glint.2),
|
||||
None => (EMPTY_GLINT.as_slice(), 1, 1),
|
||||
};
|
||||
// A glyph rasterized into an earlier texture is not in these pixels; the
|
||||
// new texture gets it again when the character is drawn (see
|
||||
// `build_renderer`).
|
||||
@@ -14122,6 +14223,8 @@ fn loading_renderer(
|
||||
&[],
|
||||
&[],
|
||||
(pixels, width, height),
|
||||
(glint_pixels, glint_width, glint_height),
|
||||
(glint_pixels, glint_width, glint_height),
|
||||
(&FORCEFIELD[..], 1),
|
||||
panorama,
|
||||
None,
|
||||
@@ -14169,9 +14272,12 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
hud,
|
||||
glint_alpha: 0.75,
|
||||
glint_ticks: 0.0,
|
||||
nether_lighting: false,
|
||||
entities: &[],
|
||||
entity_spans: &[],
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
clouds: None,
|
||||
lightning: Default::default(),
|
||||
|
||||
@@ -9,6 +9,7 @@ use horizon_client::{
|
||||
text::{self, Style, Text},
|
||||
toasts::Slide,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, VecDeque},
|
||||
@@ -21,6 +22,9 @@ pub(super) struct Toast {
|
||||
frame: Frame,
|
||||
title: Text,
|
||||
slide: Slide,
|
||||
/// `ToastInstance.firstSlotIndex`: the 32-pixel slot it stacks in once it
|
||||
/// leaves the queue.
|
||||
slot: Option<usize>,
|
||||
/// A `RecipeToast`: the crafting station and result of each display
|
||||
/// unlocked while it lasts (`recipeItems`); empty for an advancement.
|
||||
recipes: Vec<(String, ItemStack)>,
|
||||
@@ -36,6 +40,7 @@ impl Toast {
|
||||
frame: Frame::Task,
|
||||
title: Text::literal(""),
|
||||
slide: Slide::default(),
|
||||
slot: None,
|
||||
recipes: items,
|
||||
changed: true,
|
||||
last_changed: 0,
|
||||
@@ -82,6 +87,8 @@ pub(super) struct AdvState {
|
||||
pub(super) quiet: bool,
|
||||
/// The inventory last seen by `inventory_changed`.
|
||||
pub(super) last_inventory: u64,
|
||||
/// `ServerPlayer.levitationStartPos` and `levitationStartTime`.
|
||||
pub(super) levitation: Option<([f64; 3], u64)>,
|
||||
/// While the game acts for a network player (`as_guest`): the
|
||||
/// advancements whose progress changed (`progressChanged`), the recipes
|
||||
/// unlocked and the announcements, for their client and everyone's.
|
||||
@@ -180,6 +187,14 @@ impl adv::World for View<'_, '_> {
|
||||
"block" => items.block_tags.get(tag).is_some_and(|t| t.contains(id)),
|
||||
"entity_type" => self.0.res.mob_data().entity_has(tag, id),
|
||||
"worldgen/biome" => self.0.res.mob_data().biome_has(tag, id),
|
||||
"damage_type" => self
|
||||
.0
|
||||
.res
|
||||
.stations()
|
||||
.enchantments
|
||||
.damage_tags
|
||||
.get(tag)
|
||||
.is_some_and(|t| t.contains(id)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -524,6 +539,7 @@ impl Game<'_> {
|
||||
frame: d.frame,
|
||||
title: text_of(&d.title),
|
||||
slide: Slide::default(),
|
||||
slot: None,
|
||||
recipes: Vec::new(),
|
||||
changed: false,
|
||||
last_changed: 0,
|
||||
@@ -647,6 +663,28 @@ impl Game<'_> {
|
||||
};
|
||||
adv::conditions(&c["player"], &ctx, w)
|
||||
});
|
||||
// ServerPlayer.onEffectAdded and onEffectsRemoved keep the start of a
|
||||
// levitation; tick fires LEVITATION while there is one.
|
||||
let levitating = self
|
||||
.potions
|
||||
.effects
|
||||
.list
|
||||
.iter()
|
||||
.any(|e| e.id == "levitation");
|
||||
match (levitating, self.adv.levitation) {
|
||||
(true, None) => {
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
self.adv.levitation = at.map(|at| (at, self.ticks));
|
||||
}
|
||||
(false, Some(_)) => self.adv.levitation = None,
|
||||
_ => {}
|
||||
}
|
||||
if let Some((start, since)) = self.adv.levitation {
|
||||
self.trigger_levitation(start, (self.ticks - since) as i64);
|
||||
}
|
||||
if self.ticks.is_multiple_of(20) {
|
||||
let me = self.player_entity();
|
||||
self.trigger("minecraft:location", |c, w| {
|
||||
@@ -681,6 +719,123 @@ impl Game<'_> {
|
||||
adv::conditions(&c["player"], &pctx, w) && adv::conditions(&c[&key], &ectx, w)
|
||||
});
|
||||
}
|
||||
/// `LevitationTrigger`: how far the player rose from where it started
|
||||
/// levitating, and for how long.
|
||||
fn trigger_levitation(&mut self, start: [f64; 3], duration: i64) {
|
||||
let me = self.player_entity();
|
||||
self.trigger("minecraft:levitation", 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["distance"].is_null() || adv::distance(&c["distance"], start, me.position))
|
||||
&& adv::range_i(&c["duration"], duration)
|
||||
});
|
||||
}
|
||||
/// `ServerPlayer.updateUsingItem`: USING_ITEM for the item in use, with
|
||||
/// what the player looks at (`PlayerPredicate.lookingAt`).
|
||||
pub(super) fn trigger_using_item(&mut self, stack: &ItemStack) {
|
||||
if self.remote.is_some() || self.no_player_here() {
|
||||
return;
|
||||
}
|
||||
let mut me = self.player_entity();
|
||||
me.looking_at = self.looking_at_entity().map(Box::new);
|
||||
let it = item_view(stack);
|
||||
self.trigger("minecraft:using_item", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w) && adv::item(&c["item"], &it, w)
|
||||
});
|
||||
}
|
||||
/// The mob a 100 block ray from the eyes meets first, if no block is in
|
||||
/// the way (`ProjectileUtil.getEntityHitResult` and `hasLineOfSight`).
|
||||
fn looking_at_entity(&self) -> Option<EntityView> {
|
||||
let eye = self.eye_position()?;
|
||||
let dir = super::view_vector(self.yaw, self.pitch);
|
||||
let (m, _) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.filter_map(|m| Some((m, super::ray_box(eye, dir, 100.0, m.bounds())?)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
// `LivingEntity.hasLineOfSight`: eye to the target's eye height,
|
||||
// `ClipContext.Block.COLLIDER`, no fluids, within 128 blocks.
|
||||
let from = Vec3::new(eye[0], eye[1], eye[2]);
|
||||
let to = Vec3::new(m.position.x, m.eye_y(), m.position.z);
|
||||
let world = self.mob_world_base(&[]);
|
||||
let clear = to.distance(from) <= 128.0 && world.clear_between(from, to, false);
|
||||
clear.then(|| self.mob_entity(m))
|
||||
}
|
||||
/// `PlayerHurtEntityTrigger`: the player, the damage and the victim.
|
||||
pub(super) fn trigger_player_hurt_entity(
|
||||
&mut self,
|
||||
victim: &EntityView,
|
||||
damage: &adv::DamageView,
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let (victim, damage) = (victim.clone(), damage.clone());
|
||||
self.trigger("minecraft:player_hurt_entity", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let vctx = adv::Entities {
|
||||
this: Some(&victim),
|
||||
..pctx
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::damage(&c["damage"], &damage, w, me.position)
|
||||
&& adv::conditions(&c["entity"], &vctx, w)
|
||||
});
|
||||
}
|
||||
/// The player's hit on `victim` as `LivingEntity.hurtServer` reports it:
|
||||
/// `kind` damage of `amount` from `direct` (the player's own hand when
|
||||
/// `None`), nothing blocked.
|
||||
pub(super) fn player_hit(
|
||||
&mut self,
|
||||
victim: &EntityView,
|
||||
kind: &str,
|
||||
direct: Option<EntityView>,
|
||||
amount: f32,
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
self.trigger_player_hurt_entity(
|
||||
victim,
|
||||
&adv::DamageView {
|
||||
kind: kind.to_owned(),
|
||||
is_direct: direct.is_none(),
|
||||
direct: direct.or_else(|| Some(me.clone())),
|
||||
source: Some(me),
|
||||
original: amount,
|
||||
actual: amount,
|
||||
blocked: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
/// `EntityHurtPlayerTrigger`: the player and the damage it took.
|
||||
pub(super) fn trigger_entity_hurt_player(&mut self, damage: &adv::DamageView) {
|
||||
let me = self.player_entity();
|
||||
let damage = damage.clone();
|
||||
self.trigger("minecraft:entity_hurt_player", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::damage(&c["damage"], &damage, w, me.position)
|
||||
});
|
||||
}
|
||||
/// A player condition plus one item (`item` key).
|
||||
pub(super) fn trigger_item(&mut self, name: &str, stack: &ItemStack) {
|
||||
let me = self.player_entity();
|
||||
@@ -708,6 +863,22 @@ impl Game<'_> {
|
||||
let it = item_view(stack);
|
||||
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
|
||||
}
|
||||
/// `BrewedPotionTrigger`: the potion contents of a stack taken from a
|
||||
/// brewing stand.
|
||||
pub(super) fn trigger_brewed_potion(&mut self, stack: &ItemStack) {
|
||||
let me = self.player_entity();
|
||||
let potion = stack.potion().map(str::to_owned);
|
||||
self.trigger("minecraft:brewed_potion", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::potions(&c["potion"], potion.as_deref(), w)
|
||||
});
|
||||
}
|
||||
/// `TradeTrigger`: the villager and the item the trade gave.
|
||||
pub(super) fn trigger_trade(&mut self, villager: &EntityView, stack: &ItemStack) {
|
||||
let me = self.player_entity();
|
||||
@@ -729,6 +900,35 @@ impl Game<'_> {
|
||||
&& adv::item(&c["item"], &it, w)
|
||||
});
|
||||
}
|
||||
/// `FishingRodHookedTrigger`: the rod, what the hook is in (or the hook)
|
||||
/// and the items it caught.
|
||||
pub(super) fn trigger_fishing_rod_hooked(
|
||||
&mut self,
|
||||
rod: &ItemStack,
|
||||
hooked: &EntityView,
|
||||
items: &[ItemStack],
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let rod = item_view(rod);
|
||||
let hooked = hooked.clone();
|
||||
let items: Vec<ItemView> = items.iter().map(item_view).collect();
|
||||
self.trigger("minecraft:fishing_rod_hooked", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let ectx = adv::Entities {
|
||||
this: Some(&hooked),
|
||||
..pctx
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::item(&c["rod"], &rod, w)
|
||||
&& adv::conditions(&c["entity"], &ectx, w)
|
||||
&& (c["item"].is_null() || items.iter().any(|i| adv::item(&c["item"], i, w)))
|
||||
});
|
||||
}
|
||||
/// `EnterBlockTrigger`: the block, its state properties and the player.
|
||||
pub(super) fn trigger_enter_block(&mut self, state: horizon_world::registry::BlockStateId) {
|
||||
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
|
||||
@@ -827,58 +1027,105 @@ impl Game<'_> {
|
||||
c["from"].as_str().is_none_or(|f| f == from) && c["to"].as_str().is_none_or(|t| t == to)
|
||||
});
|
||||
}
|
||||
/// `ToastManager` with the `AdvancementToast` and the `RecipeToast`,
|
||||
/// one at a time.
|
||||
pub(super) fn draw_toasts(&mut self, batch: &mut HudBatch) {
|
||||
/// `ToastManager.SLOT_COUNT`.
|
||||
pub(super) const TOAST_SLOTS: usize = 5;
|
||||
/// `ToastManager.update` and `extractRenderState` for the `AdvancementToast`s and
|
||||
/// `RecipeToast`s: up to five stack in 32-pixel slots, the rest queue. `hidden` (F1)
|
||||
/// keeps them timing out without drawing them.
|
||||
pub(super) fn draw_toasts(&mut self, batch: &mut HudBatch, hidden: bool) {
|
||||
let multiplier = f64::from(self.settings.notification_display_time);
|
||||
let Some(t) = self.adv.toasts.front_mut() else {
|
||||
return;
|
||||
};
|
||||
let first = t.slide.origin.is_none();
|
||||
let origin = *t.slide.origin.get_or_insert_with(Instant::now);
|
||||
let now = origin.elapsed().as_millis() as i64;
|
||||
let mut slide = std::mem::take(&mut t.slide);
|
||||
let (sound, finished) = slide.update(now, |fully| t.wants_show(fully, multiplier));
|
||||
t.slide = slide;
|
||||
if finished {
|
||||
self.adv.toasts.pop_front();
|
||||
let mut sound_played = false;
|
||||
let mut i = 0;
|
||||
while i < self.adv.toasts.len() {
|
||||
let t = &mut self.adv.toasts[i];
|
||||
if t.slot.is_none() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let origin = *t.slide.origin.get_or_insert_with(Instant::now);
|
||||
let now = origin.elapsed().as_millis() as i64;
|
||||
let mut slide = std::mem::take(&mut t.slide);
|
||||
let (sound, finished) = slide.update(now, |fully| t.wants_show(fully, multiplier));
|
||||
t.slide = slide;
|
||||
if finished {
|
||||
self.adv.toasts.remove(i);
|
||||
continue;
|
||||
}
|
||||
// Only the first visibility change of an update plays its sound.
|
||||
if let Some(show) = sound
|
||||
&& !std::mem::replace(&mut sound_played, true)
|
||||
{
|
||||
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
|
||||
self.audio
|
||||
.play(&format!("minecraft:{event}"), None, 1.0, 1.0);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
// The queued toasts take the free slots in order; a challenge's own sound
|
||||
// plays once as it leaves the queue.
|
||||
let mut used = self.toast_slots_used();
|
||||
let mut challenge_sound = false;
|
||||
for t in &mut self.adv.toasts {
|
||||
if t.slot.is_some() {
|
||||
continue;
|
||||
}
|
||||
let Some(slot) = (0..Self::TOAST_SLOTS).find(|s| used & (1 << s) == 0) else {
|
||||
break;
|
||||
};
|
||||
used |= 1 << slot;
|
||||
t.slot = Some(slot);
|
||||
challenge_sound |= !t.is_recipe() && t.frame == Frame::Challenge;
|
||||
}
|
||||
if challenge_sound {
|
||||
self.audio
|
||||
.play("minecraft:ui.toast.challenge_complete", None, 1.0, 1.0);
|
||||
}
|
||||
if hidden {
|
||||
return;
|
||||
}
|
||||
for i in 0..self.adv.toasts.len() {
|
||||
if let Some(slot) = self.adv.toasts[i].slot {
|
||||
self.draw_toast(batch, i, 32.0 * slot as f32, multiplier);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The slots held by shown advancement, recipe and pack toasts, as bits.
|
||||
pub(super) fn toast_slots_used(&self) -> u32 {
|
||||
let packs = self.server_packs.toast_slots();
|
||||
self.adv
|
||||
.toasts
|
||||
.iter()
|
||||
.filter_map(|t| t.slot)
|
||||
.chain(packs)
|
||||
.fold(0, |used, slot| used | 1 << slot)
|
||||
}
|
||||
/// The `AdvancementToast` or `RecipeToast` at `index` in its slot at `y`.
|
||||
fn draw_toast(&mut self, batch: &mut HudBatch, index: usize, y: f32, multiplier: f64) {
|
||||
let t = &self.adv.toasts[index];
|
||||
let x = batch.width - 160.0 * t.slide.portion;
|
||||
let fully_for = t.slide.fully_for;
|
||||
let (title, frame, icon) = (t.title.clone(), t.frame, t.icon.clone());
|
||||
let recipe = t
|
||||
.is_recipe()
|
||||
.then(|| t.recipes[t.displayed_recipe(fully_for, multiplier)].clone());
|
||||
// ToastManager.update: the sound of the new visibility, and a
|
||||
// challenge's own sound as it leaves the queue.
|
||||
if let Some(show) = sound {
|
||||
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
|
||||
self.audio
|
||||
.play(&format!("minecraft:{event}"), None, 1.0, 1.0);
|
||||
}
|
||||
if first && recipe.is_none() && frame == Frame::Challenge {
|
||||
self.audio
|
||||
.play("minecraft:ui.toast.challenge_complete", None, 1.0, 1.0);
|
||||
}
|
||||
if let Some((station, item)) = recipe {
|
||||
// RecipeToast: black description under a purple title, the
|
||||
// station at 0.6 scale in the corner over the cycling result.
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.sprite(atlas, "gui/sprites/toast/recipe", x, 0.0);
|
||||
batch.sprite(atlas, "gui/sprites/toast/recipe", x, y);
|
||||
let title = self.translate("recipe.toast.title", "New Recipe(s) Unlocked!");
|
||||
let description = self.translate("recipe.toast.description", "Check your recipe book");
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.text(atlas, &title, x + 30.0, 7.0, 0xFF50_0050, false);
|
||||
batch.text(atlas, &description, x + 30.0, 18.0, 0xFF00_0000, false);
|
||||
batch.text(atlas, &title, x + 30.0, y + 7.0, 0xFF50_0050, false);
|
||||
batch.text(atlas, &description, x + 30.0, y + 18.0, 0xFF00_0000, false);
|
||||
let start = batch.vertices.len();
|
||||
self.draw_item_model(batch, &ItemStack::new(&station, 1), x + 3.0, 3.0);
|
||||
batch.scale_since(start, x, 0.0, 0.6, 0.6);
|
||||
self.draw_item_model(batch, &item, x + 8.0, 8.0);
|
||||
self.draw_item_model(batch, &ItemStack::new(&station, 1), x + 3.0, y + 3.0);
|
||||
batch.scale_since(start, x, y, 0.6, 0.6);
|
||||
self.draw_item_model(batch, &item, x + 8.0, y + 8.0);
|
||||
return;
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.sprite(atlas, "gui/sprites/toast/advancement", x, 0.0);
|
||||
batch.sprite(atlas, "gui/sprites/toast/advancement", x, y);
|
||||
let (key, color) = match frame {
|
||||
Frame::Task => ("advancements.toast.task", 0xFF00_FFFF),
|
||||
Frame::Goal => ("advancements.toast.goal", 0xFF00_FFFF),
|
||||
@@ -888,12 +1135,12 @@ impl Game<'_> {
|
||||
atlas,
|
||||
&Text::translate(key, &[]),
|
||||
x + 30.0,
|
||||
7.0,
|
||||
y + 7.0,
|
||||
color,
|
||||
false,
|
||||
);
|
||||
batch.text_styled(atlas, &title, x + 30.0, 18.0, u32::MAX, false);
|
||||
self.draw_item(batch, &ItemStack::new(&icon, 1), x + 8.0, 8.0);
|
||||
batch.text_styled(atlas, &title, x + 30.0, y + 18.0, u32::MAX, false);
|
||||
self.draw_item(batch, &ItemStack::new(&icon, 1), x + 8.0, y + 8.0);
|
||||
}
|
||||
}
|
||||
/// `Advancement.name`: the bracketed title in the frame's colour, with the
|
||||
@@ -1074,6 +1321,7 @@ impl Game<'_> {
|
||||
frame: d.frame,
|
||||
title: text_of(&d.title),
|
||||
slide: Slide::default(),
|
||||
slot: None,
|
||||
recipes: Vec::new(),
|
||||
changed: false,
|
||||
last_changed: 0,
|
||||
|
||||
@@ -568,6 +568,22 @@ impl Game<'_> {
|
||||
"generate_structures": options.structures,
|
||||
"bonus_chest": options.bonus_chest(),
|
||||
}),
|
||||
Mode::CreateFlat {
|
||||
name,
|
||||
seed,
|
||||
options,
|
||||
flat,
|
||||
..
|
||||
} => json!({
|
||||
"mode": "customize-flat",
|
||||
"fields": { "name": name, "seed": seed },
|
||||
"superflat": options.flat,
|
||||
"biome": flat.settings().biome,
|
||||
"layers": flat.settings().layers.iter().map(|layer| json!({
|
||||
"block": layer.block,
|
||||
"height": layer.height,
|
||||
})).collect::<Vec<_>>(),
|
||||
}),
|
||||
Mode::Edit {
|
||||
world,
|
||||
name,
|
||||
|
||||
@@ -161,7 +161,7 @@ impl Game<'_> {
|
||||
}
|
||||
// Allay.duplicateAllay: entity event 18, three spawnHeartParticle.
|
||||
let id = self.mobs[index].id;
|
||||
self.particles_around_mob(id, "heart", horizon_client::particles::Burst::ALLAY);
|
||||
self.mob_event_particles(id, 18);
|
||||
self.audio
|
||||
.play("minecraft:block.amethyst_block.chime", Some(at), 2.0, 1.0);
|
||||
self.consume_held();
|
||||
|
||||
@@ -627,21 +627,8 @@ impl Game<'_> {
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0));
|
||||
let mut poses = Poses::new(model);
|
||||
// ArmorStand.DEFAULT_*_POSE and ArmorStandModel part visibility.
|
||||
let deg = |v: f32| v.to_radians();
|
||||
for (name, rot) in [
|
||||
("left_arm", [-10.0, 0.0, -10.0]),
|
||||
("right_arm", [-15.0, 0.0, 10.0]),
|
||||
("left_leg", [-1.0, 0.0, -1.0]),
|
||||
("right_leg", [1.0, 0.0, 1.0]),
|
||||
] {
|
||||
if poses.has(name) {
|
||||
let pose = poses.get(name);
|
||||
pose[3] = deg(rot[0]);
|
||||
pose[4] = deg(rot[1]);
|
||||
pose[5] = deg(rot[2]);
|
||||
}
|
||||
}
|
||||
// ArmorStandModel.setupAnim: the stand's pose and part visibility.
|
||||
apply_pose(&mut poses, &a.pose);
|
||||
// Item layers pose from the hidden arms too (`visible` only skips drawing).
|
||||
let posed = poses.clone();
|
||||
let mut hide = |name: &str| {
|
||||
@@ -711,7 +698,23 @@ impl Game<'_> {
|
||||
let color = undyed.map_or(0xFFFF_FFFF, |c| {
|
||||
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
|
||||
});
|
||||
let layer_poses = Poses::new(part);
|
||||
// ArmorStandRenderer's HumanoidArmorLayer: the armour glint
|
||||
// when `stack.hasFoil()`, as on a living entity.
|
||||
let item_glint = self
|
||||
.res
|
||||
.items()
|
||||
.enchantments
|
||||
.props
|
||||
.get(&stack.item)
|
||||
.is_some_and(|p| p.glint);
|
||||
let overlay = if stack.has_foil(item_glint) {
|
||||
horizon_client::mobs::render::ARMOR_GLINT
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// ArmorStandArmorModel.setupAnim: the same pose as the stand.
|
||||
let mut layer_poses = Poses::new(part);
|
||||
apply_pose(&mut layer_poses, &a.pose);
|
||||
horizon_client::mobs::render::draw(
|
||||
part,
|
||||
base,
|
||||
@@ -719,7 +722,7 @@ impl Game<'_> {
|
||||
uv,
|
||||
color,
|
||||
packed,
|
||||
0,
|
||||
overlay,
|
||||
out,
|
||||
);
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
@@ -734,7 +737,7 @@ impl Game<'_> {
|
||||
trim,
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
overlay,
|
||||
out,
|
||||
);
|
||||
}
|
||||
@@ -743,6 +746,33 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The `Pose` rotations on the parts of an armor stand model or its armor
|
||||
/// (`ArmorStandArmorModel.setupAnim`); the body also turns the sticks.
|
||||
fn apply_pose(poses: &mut Poses, pose: &[[f32; 3]; 6]) {
|
||||
let parts: [&[&str]; 6] = [
|
||||
&["head"],
|
||||
&[
|
||||
"body",
|
||||
"right_body_stick",
|
||||
"left_body_stick",
|
||||
"shoulder_stick",
|
||||
],
|
||||
&["left_arm"],
|
||||
&["right_arm"],
|
||||
&["left_leg"],
|
||||
&["right_leg"],
|
||||
];
|
||||
for (names, rotation) in parts.into_iter().zip(pose) {
|
||||
for name in names {
|
||||
if poses.has(name) {
|
||||
let part = poses.get(name);
|
||||
part[3] = rotation[0].to_radians();
|
||||
part[4] = rotation[1].to_radians();
|
||||
part[5] = rotation[2].to_radians();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn overlaps(a: Aabb, b: Aabb) -> bool {
|
||||
let (a0, a1, b0, b1) = (
|
||||
a.min_position(),
|
||||
@@ -877,6 +907,7 @@ impl Game<'_> {
|
||||
lights: Some(horizon_client::block_entity_render::diffuse_lights(
|
||||
self.dim == super::Dim::Nether,
|
||||
)),
|
||||
glint: self.glint(),
|
||||
};
|
||||
let profile = stack
|
||||
.extra
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
//! player breaks the hive or fire reaches it.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
beehive::{self, Beehive, HiveHost, Release},
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
mobs::{Mob, Who, bee},
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -194,19 +196,139 @@ impl Game<'_> {
|
||||
}
|
||||
self.anger_nearby_bees(at);
|
||||
}
|
||||
/// `BeehiveBlock.useItemOn` with `held` in the acting hand: shears take
|
||||
/// the honeycombs and a glass bottle a honey bottle from a full hive
|
||||
/// (honey level 5), then the honey level resets and, unless a campfire
|
||||
/// smokes the hive, the bees come out angry. True when it took the click.
|
||||
pub(super) fn use_beehive(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> Result<bool> {
|
||||
let Some(held) = held else { return Ok(false) };
|
||||
let shears = &*held.item == "minecraft:shears";
|
||||
if !shears && &*held.item != "minecraft:glass_bottle" {
|
||||
return Ok(false);
|
||||
}
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !matches!(
|
||||
&*self.res.facts().get(state).name,
|
||||
"minecraft:beehive" | "minecraft:bee_nest"
|
||||
) {
|
||||
return Ok(false);
|
||||
}
|
||||
let prop = |key: &str| {
|
||||
self.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get(key).cloned())
|
||||
};
|
||||
if prop("honey_level").and_then(|v| v.parse::<i32>().ok()) != Some(5) {
|
||||
return Ok(false);
|
||||
}
|
||||
let facing = beehive::facing_step(prop("facing").as_deref());
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
if shears {
|
||||
// dropHoneycomb (harvest/beehive), the shear sound, hurtAndBreak.
|
||||
for stack in self.sequence_loot(
|
||||
"minecraft:harvest/beehive",
|
||||
"minecraft:harvest/beehive",
|
||||
Some(held),
|
||||
) {
|
||||
self.pop_resource(pos, stack);
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:block.beehive.shear", at, 1.0, 1.0);
|
||||
self.damage_held(1);
|
||||
self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None);
|
||||
} else {
|
||||
// shrink(1), then the honey bottle in the hand, the inventory or
|
||||
// on the ground.
|
||||
self.audio.play("minecraft:item.bottle.fill", at, 1.0, 1.0);
|
||||
let cell = self.held_mut();
|
||||
if let Some(s) = cell.as_mut() {
|
||||
s.count -= 1;
|
||||
if s.count == 0 {
|
||||
*cell = None;
|
||||
}
|
||||
}
|
||||
let bottle = ItemStack::new("minecraft:honey_bottle", 1);
|
||||
if self.held().is_none() {
|
||||
*self.held_mut() = Some(bottle);
|
||||
} else if let Some(rest) = self.give(bottle)
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40);
|
||||
}
|
||||
self.post_block_game_event("minecraft:fluid_pickup", pos, self.sculk_actor(), None);
|
||||
}
|
||||
self.stats.add("used", &held.item, 1);
|
||||
let smokey = beehive::smokey(&self.mob_world_base(&[]), pos);
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
if !smokey
|
||||
&& matches!(self.world.block_entities.get(&key), Some(BlockEntity::Beehive(h)) if !h.bees.is_empty())
|
||||
{
|
||||
self.anger_nearby_bees(pos);
|
||||
}
|
||||
// resetHoneyLevel.
|
||||
if let Some(reset) = self.with_props(state, &[("honey_level", "0")]) {
|
||||
self.level_set(pos, reset)?;
|
||||
}
|
||||
if !smokey
|
||||
&& let Some(BlockEntity::Beehive(mut hive)) = self.world.block_entities.take(&key)
|
||||
{
|
||||
// emptyAllLivingFromHive: the bees within 4 blocks of the player
|
||||
// go for them.
|
||||
let released = hive.release_all(
|
||||
pos,
|
||||
facing,
|
||||
None,
|
||||
Release::Emergency,
|
||||
&mut GameHive { game: self },
|
||||
);
|
||||
self.world
|
||||
.block_entities
|
||||
.put(key, BlockEntity::Beehive(hive));
|
||||
if let Some(p) = self.player_view().map(|p| p.position) {
|
||||
for m in self.mobs.iter_mut().filter(|m| released.contains(&m.id)) {
|
||||
if m.distance_sqr(p) <= 16.0 {
|
||||
m.ai.target = Some(Who::Player);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ServerPlayerGameMode.useItemOn: the action was consumed.
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, held);
|
||||
Ok(true)
|
||||
}
|
||||
/// `BeehiveBlock.angerNearbyBees`: bees within 8 blocks (6 up and down)
|
||||
/// without a target go for a player in the same box.
|
||||
fn anger_nearby_bees(&mut self, at: [i32; 3]) {
|
||||
/// without a target go for a random player in the same box, the window's
|
||||
/// own or a guest (`None` is the window's player).
|
||||
pub(super) fn anger_nearby_bees(&mut self, at: [i32; 3]) {
|
||||
let min = Vec3::new(
|
||||
f64::from(at[0]) - 8.0,
|
||||
f64::from(at[1]) - 6.0,
|
||||
f64::from(at[2]) - 8.0,
|
||||
);
|
||||
let area = Aabb::new(min, Vec3::new(min.x + 17.0, min.y + 13.0, min.z + 17.0));
|
||||
if !self
|
||||
let mut players: Vec<Option<u32>> = Vec::new();
|
||||
if self
|
||||
.player_view()
|
||||
.is_some_and(|p| p.bounds.intersects(area))
|
||||
{
|
||||
players.push(None);
|
||||
}
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
players.extend(
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
|
||||
.filter(|g| super::lan_mobs::guest_bounds(g).intersects(area))
|
||||
.map(|g| Some(g.conn)),
|
||||
);
|
||||
}
|
||||
if players.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut bees: Vec<usize> = (0..self.mobs.len())
|
||||
@@ -219,9 +341,20 @@ impl Game<'_> {
|
||||
.collect();
|
||||
bees.retain(|&i| self.mobs[i].ai.target.is_none());
|
||||
for i in bees {
|
||||
// Util.getRandom over the one player.
|
||||
let _ = self.world.random.next_int_bound(1);
|
||||
// Util.getRandom over the players.
|
||||
let pick = self
|
||||
.world
|
||||
.random
|
||||
.next_int_bound(players.len() as i32)
|
||||
.unwrap_or(0) as usize;
|
||||
self.mobs[i].ai.target = Some(Who::Player);
|
||||
// The player a mob sees is its guest binding, else the window's.
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
match players[pick] {
|
||||
Some(conn) => host.mob_players.insert(self.mobs[i].id, conn),
|
||||
None => host.mob_players.remove(&self.mobs[i].id),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,23 +76,24 @@ impl Game<'_> {
|
||||
/// A player opened (`open`) or closed the container at `key`: the opener
|
||||
/// count block event (`ContainerOpenersCounter` / `ShulkerBoxBlockEntity`
|
||||
/// `startOpen`/`stopOpen`) for chests, ender chests and shulker boxes,
|
||||
/// on both halves of a double chest (`CompoundContainer`).
|
||||
pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
/// on both halves of a double chest (`CompoundContainer`). True when the
|
||||
/// count went from 0 to 1 or back to 0: the only moments the container
|
||||
/// plays its open or close sound.
|
||||
pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) -> bool {
|
||||
// Spectators never count as openers.
|
||||
if !self.spectator() {
|
||||
self.container_openers_changed(key, open);
|
||||
}
|
||||
!self.spectator() && self.container_openers_changed(key, open)
|
||||
}
|
||||
/// `container_opener_event` for a player who is not a spectator (a guest).
|
||||
pub(super) fn container_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
pub(super) fn container_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
|
||||
let pos = [key.0, key.1, key.2];
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]).0;
|
||||
match self.be_anims.ticker(state) {
|
||||
Some(Ticker::Lid) => {
|
||||
self.lid_openers_changed(key, open);
|
||||
let edge = self.lid_openers_changed(key, open);
|
||||
if let Some(other) = self.chest_other_half(key) {
|
||||
self.lid_openers_changed(other, open);
|
||||
}
|
||||
edge
|
||||
}
|
||||
_ if self
|
||||
.res
|
||||
@@ -102,26 +103,27 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
== "minecraft:barrel" =>
|
||||
{
|
||||
self.barrel_openers_changed(key, open);
|
||||
self.barrel_openers_changed(key, open)
|
||||
}
|
||||
Some(Ticker::Shulker { .. }) => {
|
||||
let shulker = self.be_anims.shulkers.entry(pos).or_default();
|
||||
let (count, _) = if open {
|
||||
let (count, edge) = if open {
|
||||
shulker.start_open()
|
||||
} else {
|
||||
shulker.stop_open()
|
||||
};
|
||||
self.block_entity_event(pos, 1, count);
|
||||
self.lan_block_event(pos, 1, count);
|
||||
edge
|
||||
}
|
||||
_ => {}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// `ContainerOpenersCounter.incrementOpeners` / `decrementOpeners` of the
|
||||
/// chest at `key` (the host and the guests are counted), then
|
||||
/// `ChestBlockEntity.signalOpenCount`: block event 1 with the count, here
|
||||
/// and for the guests.
|
||||
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
/// and for the guests. True when the count crossed zero.
|
||||
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
|
||||
let (before, count) = self.count_opener(key, open);
|
||||
let pos = [key.0, key.1, key.2];
|
||||
self.block_entity_event(pos, 1, count);
|
||||
@@ -139,12 +141,15 @@ impl Game<'_> {
|
||||
b.update_neighbors_at(level, ticks, p.offset(0, -1, 0), block);
|
||||
});
|
||||
}
|
||||
(before == 0) != (count == 0)
|
||||
}
|
||||
/// `BarrelBlockEntity.openersCounter`: the first opener opens the barrel
|
||||
/// (`open=true`), the last one to leave closes it (`updateBlockState`).
|
||||
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
/// True when the count crossed zero.
|
||||
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
|
||||
let (before, count) = self.count_opener(key, open);
|
||||
if (before == 0) != (count == 0) {
|
||||
let edge = (before == 0) != (count == 0);
|
||||
if edge {
|
||||
self.set_bool_prop(
|
||||
key,
|
||||
"open",
|
||||
@@ -152,6 +157,7 @@ impl Game<'_> {
|
||||
horizon_world::level::flags::UPDATE_ALL,
|
||||
);
|
||||
}
|
||||
edge
|
||||
}
|
||||
/// One opener more (`open`) or less at `key`: the count before and after
|
||||
/// (`ContainerOpenersCounter.incrementOpeners` / `decrementOpeners`).
|
||||
@@ -725,21 +731,38 @@ impl Game<'_> {
|
||||
/// the player head at `pos`. The skin download is started here; while it
|
||||
/// is in flight (or when it cannot be got) the profile's default skin
|
||||
/// stands in. A head with no profile answers None: `SKIN_BY_TYPE`.
|
||||
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<&str> {
|
||||
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<String> {
|
||||
let Some(BlockEntity::Skull(skull)) =
|
||||
self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let skin = skull.skin.as_ref()?;
|
||||
if let Some((url, location)) = &skin.download {
|
||||
self.skin_downloads
|
||||
.borrow_mut()
|
||||
.state(skins::Kind::Skin, url);
|
||||
if self.gui.atlas.sprites.contains_key(location) {
|
||||
// The texture a packed or resolved `textures` property fetches: the
|
||||
// download of a static profile, or the one the services answer for a
|
||||
// `Dynamic` (name- or id-only) profile.
|
||||
let download = skin.download.clone().or_else(|| {
|
||||
let key = skin.dynamic.as_ref()?;
|
||||
let skins::Resolve::Done(Some(profile)) = self.skin_profiles.borrow_mut().state(key)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let (value, signature) = profile.textures()?;
|
||||
let url = skins::unpack(&value, signature, &skins::TEXTURE_URIS).skin?;
|
||||
let location = skins::skin_location(&skins::texture_hash(&url)?);
|
||||
Some((url, location))
|
||||
});
|
||||
if let Some((url, location)) = download {
|
||||
let mut downloads = self.skin_downloads.borrow_mut();
|
||||
if self.gui.atlas.sprites.contains_key(&location) {
|
||||
let _ = downloads.state(skins::Kind::Skin, &url);
|
||||
return Some(location);
|
||||
}
|
||||
// Its slot was evicted: offer the image again.
|
||||
if downloads.state(skins::Kind::Skin, &url) == skins::Download::Ready {
|
||||
downloads.again(&location);
|
||||
}
|
||||
}
|
||||
Some(skin.fallback)
|
||||
Some(skin.fallback.to_owned())
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use horizon_client::{
|
||||
containers::{BlockEntity, Lectern},
|
||||
first_person::Hand,
|
||||
hud::{HudBatch, rgba},
|
||||
item_data::WrittenBook,
|
||||
item_data::{StackData, WrittenBook},
|
||||
items::ItemStack,
|
||||
menu::{Kind, Menu},
|
||||
text::Text,
|
||||
@@ -59,14 +59,86 @@ pub(super) enum BookScreen {
|
||||
page_edit: ui::EditBox,
|
||||
},
|
||||
View {
|
||||
pages: Vec<String>,
|
||||
pages: Vec<Text>,
|
||||
page: usize,
|
||||
lectern: Option<(i32, i32, i32)>,
|
||||
},
|
||||
}
|
||||
/// The pages a book screen reads: a written book's components (so their
|
||||
/// click events stay), else a writable book's text.
|
||||
fn view_pages(data: Option<&StackData>) -> Vec<Text> {
|
||||
let Some(data) = data else { return Vec::new() };
|
||||
let Some(w) = &data.written else {
|
||||
return data.writable_pages.iter().map(Text::literal).collect();
|
||||
};
|
||||
let tagged = w.page_tags.len() == w.pages.len();
|
||||
w.pages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, page)| match tagged.then(|| &w.page_tags[i]) {
|
||||
Some(tag @ horizon_world::nbt::Tag::Compound(_)) => {
|
||||
horizon_client::entity_nbt::name_text(tag)
|
||||
}
|
||||
_ => Text::literal(page),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
impl Game<'_> {
|
||||
fn book_lines(&self, page: &str) -> Vec<Text> {
|
||||
self.gui.atlas.font.split(&Text::literal(page), 114.0)
|
||||
self.page_lines(&Text::literal(page))
|
||||
}
|
||||
fn page_lines(&self, page: &Text) -> Vec<Text> {
|
||||
self.gui.atlas.font.split(page, 114.0)
|
||||
}
|
||||
/// `BookViewScreen.mouseClicked`: the click event of the text under the
|
||||
/// cursor on the page.
|
||||
fn book_link_at(&self, page: &Text, left: f32) -> Option<horizon_client::text::ClickEvent> {
|
||||
let (mx, my) = self.cursor;
|
||||
let row = ((my - 32.0) / 9.0).floor();
|
||||
if row < 0.0 || row >= LINES as f32 {
|
||||
return None;
|
||||
}
|
||||
let lines = self.page_lines(page);
|
||||
let line = lines.get(row as usize)?.resolve_legacy();
|
||||
let mut x = mx - (left + 36.0);
|
||||
if x < 0.0 {
|
||||
return None;
|
||||
}
|
||||
for (c, style) in line.chars() {
|
||||
x -= self.gui.atlas.font.advance(c, style);
|
||||
if x < 0.0 {
|
||||
return style.click.clone();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `BookViewScreen.handleClickEvent`: a page link turns to its page
|
||||
/// (`forcePage`), a web link goes through the confirm-link flow.
|
||||
fn book_follow_link(
|
||||
&mut self,
|
||||
link: horizon_client::text::ClickEvent,
|
||||
count: usize,
|
||||
lectern: Option<(i32, i32, i32)>,
|
||||
) {
|
||||
use horizon_client::text::ClickEvent;
|
||||
match link {
|
||||
ClickEvent::ChangePage(target) => {
|
||||
let target = (target - 1).clamp(0, count.saturating_sub(1) as i32);
|
||||
let Some(BookScreen::View { page, .. }) = self.book.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Some(key) = lectern {
|
||||
// LecternScreen.forcePage: the menu's button 100 + page.
|
||||
if target != *page as i32 {
|
||||
self.lectern_button(key, 100 + target);
|
||||
}
|
||||
} else if target != *page as i32 {
|
||||
*page = target as usize;
|
||||
}
|
||||
}
|
||||
ClickEvent::OpenUrl(url) => self.click_url(url),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `WritableBookItem.use` / `WrittenBookItem.use` (`Player.openItemGui`).
|
||||
/// A server's player writes in its own client's editor
|
||||
@@ -115,11 +187,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
"minecraft:written_book" => BookScreen::View {
|
||||
pages: stack
|
||||
.data()
|
||||
.and_then(|d| d.written.as_ref())
|
||||
.map(|w| w.pages.clone())
|
||||
.unwrap_or_default(),
|
||||
pages: view_pages(stack.data()),
|
||||
page: 0,
|
||||
lectern: None,
|
||||
},
|
||||
@@ -215,6 +283,7 @@ impl Game<'_> {
|
||||
author,
|
||||
generation: 0,
|
||||
pages,
|
||||
page_tags: Vec::new(),
|
||||
});
|
||||
*stack = written;
|
||||
} else {
|
||||
@@ -685,11 +754,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
};
|
||||
let Some(book) = &l.book else { return false };
|
||||
let data = book.data();
|
||||
let pages = data
|
||||
.and_then(|d| d.written.as_ref().map(|w| w.pages.clone()))
|
||||
.or_else(|| data.map(|d| d.writable_pages.clone()))
|
||||
.unwrap_or_default();
|
||||
let pages = view_pages(book.data());
|
||||
let page = (l.page.max(0) as usize).min(pages.len().saturating_sub(1));
|
||||
self.stats.custom("interact_with_lectern", 1);
|
||||
self.open_book(BookScreen::View {
|
||||
@@ -811,11 +876,7 @@ impl Game<'_> {
|
||||
page,
|
||||
}) = self.book.as_mut()
|
||||
{
|
||||
let data = book.as_ref().and_then(ItemStack::data);
|
||||
*pages = data
|
||||
.and_then(|d| d.written.as_ref().map(|w| w.pages.clone()))
|
||||
.or_else(|| data.map(|d| d.writable_pages.clone()))
|
||||
.unwrap_or_default();
|
||||
*pages = view_pages(book.as_ref().and_then(ItemStack::data));
|
||||
*page = (*page).min(pages.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
@@ -955,7 +1016,10 @@ impl Game<'_> {
|
||||
lectern,
|
||||
}) => {
|
||||
let (page, count, lectern) = (*page, pages.len(), *lectern);
|
||||
if page + 1 < count && inside(left + 116.0, 159.0, 23.0, 13.0) {
|
||||
// The text's click event first, then the buttons.
|
||||
if let Some(link) = pages.get(page).and_then(|t| self.book_link_at(t, left)) {
|
||||
self.book_follow_link(link, count, lectern);
|
||||
} else if page + 1 < count && inside(left + 116.0, 159.0, 23.0, 13.0) {
|
||||
self.turn_page(true);
|
||||
} else if page > 0 && inside(left + 43.0, 159.0, 23.0, 13.0) {
|
||||
self.turn_page(false);
|
||||
@@ -1198,7 +1262,7 @@ impl Game<'_> {
|
||||
} => {
|
||||
indicator(batch, *page, pages.len());
|
||||
if let Some(text) = pages.get(*page) {
|
||||
for (i, line) in self.book_lines(text).iter().take(LINES).enumerate() {
|
||||
for (i, line) in self.page_lines(text).iter().take(LINES).enumerate() {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
line,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! `CatSpawner`: every 1200 ticks a cat may appear near a random player,
|
||||
//! in a village with enough occupied beds or inside a swamp hut.
|
||||
use super::{
|
||||
Dim, Game,
|
||||
raids::{section, sections_to_village},
|
||||
};
|
||||
use horizon_client::mobs::{Species, spawn_rules};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
|
||||
const TICK_DELAY: i32 = 1200;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `CatSpawner.tick` (the overworld's custom spawner).
|
||||
pub(super) fn tick_cat_spawner(&mut self) {
|
||||
if self.dim != Dim::Overworld {
|
||||
return;
|
||||
}
|
||||
self.creatures.cat_tick -= 1;
|
||||
if self.creatures.cat_tick > 0 {
|
||||
return;
|
||||
}
|
||||
self.creatures.cat_tick = TICK_DELAY;
|
||||
// ServerLevel.getRandomPlayer: one of the living players.
|
||||
let alive: Vec<Vec3> = self
|
||||
.level_players()
|
||||
.into_iter()
|
||||
.filter(|p| p.alive)
|
||||
.map(|p| p.position)
|
||||
.collect();
|
||||
if alive.is_empty() {
|
||||
return;
|
||||
}
|
||||
let pick = self
|
||||
.world
|
||||
.random
|
||||
.next_int_bound(alive.len() as i32)
|
||||
.unwrap_or(0);
|
||||
let p = alive[pick as usize];
|
||||
let mut offset = || {
|
||||
let n = 8 + self.world.random.next_int_bound(24).unwrap_or(0);
|
||||
if self.world.random.next_boolean() {
|
||||
-n
|
||||
} else {
|
||||
n
|
||||
}
|
||||
};
|
||||
let x = offset();
|
||||
let z = offset();
|
||||
let pos = [
|
||||
p.x.floor() as i32 + x,
|
||||
p.y.floor() as i32,
|
||||
p.z.floor() as i32 + z,
|
||||
];
|
||||
// hasChunksAt(pos +- 10)
|
||||
let loaded = ((pos[0] - 10) >> 4..=(pos[0] + 10) >> 4).all(|cx| {
|
||||
((pos[2] - 10) >> 4..=(pos[2] + 10) >> 4)
|
||||
.all(|cz| self.world.chunks.contains_key(&(cx, cz)))
|
||||
});
|
||||
if !loaded {
|
||||
return;
|
||||
}
|
||||
let ok = spawn_rules::is_spawn_position_ok(
|
||||
Species::Cat,
|
||||
&self.mob_world_base(&[]),
|
||||
pos[0],
|
||||
pos[1],
|
||||
pos[2],
|
||||
);
|
||||
if !ok {
|
||||
return;
|
||||
}
|
||||
if sections_to_village(&self.village_centers(), section(pos)) <= 2 {
|
||||
self.spawn_cat_in_village(pos);
|
||||
} else {
|
||||
let hut = self.structure_tag("minecraft:cats_spawn_in");
|
||||
if self.structures_at(pos).iter().any(|s| hut.contains(s)) {
|
||||
self.spawn_cat_in_hut(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `spawnInVillage`: more than four occupied beds within 48 blocks and
|
||||
/// fewer than five cats around.
|
||||
fn spawn_cat_in_village(&mut self, pos: [i32; 3]) {
|
||||
// PoiManager.getCountInRange(HOME, IS_OCCUPIED): beds villagers have claimed.
|
||||
let mut beds: Vec<[i32; 3]> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.species() == Species::Villager && m.alive())
|
||||
.filter_map(|m| m.extra.bed)
|
||||
.filter(|b| {
|
||||
let d = |i: usize| i64::from(b[i] - pos[i]);
|
||||
d(0).abs() <= 48
|
||||
&& d(2).abs() <= 48
|
||||
&& d(0).pow(2) + d(1).pow(2) + d(2).pow(2) <= 48 * 48
|
||||
})
|
||||
.collect();
|
||||
beds.sort_unstable();
|
||||
beds.dedup();
|
||||
if beds.len() > 4 && self.cats_near(pos, 48.0) < 5 {
|
||||
self.spawn_cat(pos, false);
|
||||
}
|
||||
}
|
||||
/// `spawnInHut`: no cat within 16 blocks; the cat stays.
|
||||
fn spawn_cat_in_hut(&mut self, pos: [i32; 3]) {
|
||||
if self.cats_near(pos, 16.0) == 0 {
|
||||
self.spawn_cat(pos, true);
|
||||
}
|
||||
}
|
||||
/// Cats in `AABB(pos).inflate(radius, 8, radius)`.
|
||||
fn cats_near(&self, pos: [i32; 3], radius: f64) -> usize {
|
||||
let [x, y, z] = pos.map(f64::from);
|
||||
let min = Vec3::new(x - radius, y - 8.0, z - radius);
|
||||
let max = Vec3::new(x + 1.0 + radius, y + 9.0, z + 1.0 + radius);
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.species() == Species::Cat && m.alive() && m.bounds().intersects_corners(min, max)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
/// `spawnCat`: finalized for a natural spawn, black inside
|
||||
/// `#cats_spawn_as_black` structures (`Cat.finalizeSpawn`).
|
||||
fn spawn_cat(&mut self, pos: [i32; 3], persistent: bool) {
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
let Some(id) = self.spawn_mob("minecraft:cat", at, persistent) else {
|
||||
return;
|
||||
};
|
||||
let black = self.structure_tag("minecraft:cats_spawn_as_black");
|
||||
let in_black = self.structures_at(pos).iter().any(|s| black.contains(s));
|
||||
if in_black && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.extra.variant = 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,18 @@ pub struct UiState {
|
||||
pub death_time: u32,
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
/// The state a window starts with: the chat remembers the commands kept
|
||||
/// in the game folder (`ChatComponent`'s constructor).
|
||||
pub fn new() -> Self {
|
||||
let mut ui = Self::default();
|
||||
if let Ok(dir) = std::env::current_dir() {
|
||||
ui.chat.load_history(&dir);
|
||||
}
|
||||
ui
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `PlayerList.isOp` for the single-player owner: the world's Allow Commands.
|
||||
pub(super) fn allow_commands(&self) -> bool {
|
||||
@@ -695,12 +707,78 @@ impl Game<'_> {
|
||||
Some(ClickEvent::OpenFile(path)) => {
|
||||
super::packs::open_folder(std::path::Path::new(&path));
|
||||
}
|
||||
Some(ClickEvent::OpenUrl(url)) => self.click_url(url),
|
||||
Some(ClickEvent::SuggestCommand(command)) => {
|
||||
if let Some(screen) = self.ui.chat_screen.as_mut() {
|
||||
screen.suggest_command(&m, &c, &command);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
// Screen.defaultHandleClickEvent: a page link means nothing in chat.
|
||||
Some(ClickEvent::ChangePage(_)) | None => {}
|
||||
}
|
||||
}
|
||||
/// `Screen.clickUrlAction`: nothing with Web Links off, else the link
|
||||
/// opens, after the confirmation unless Prompt on Links is off.
|
||||
pub(super) fn click_url(&mut self, url: String) {
|
||||
if !self.settings.chat_links {
|
||||
return;
|
||||
}
|
||||
if self.settings.file.bool("chatLinksPrompt") {
|
||||
self.link_prompt = Some(url);
|
||||
} else {
|
||||
super::packs::open_uri(&url);
|
||||
}
|
||||
}
|
||||
/// `ConfirmLinkScreen` (untrusted): the link and its warning over the
|
||||
/// chat, answered yes to open it.
|
||||
fn link_prompt_layout(
|
||||
&self,
|
||||
url: &str,
|
||||
size: (f32, f32),
|
||||
) -> horizon_client::confirm_screen::Confirm {
|
||||
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(
|
||||
&self.gui.atlas.font,
|
||||
size,
|
||||
&Text::translate("chat.link.confirm", &[]),
|
||||
&message,
|
||||
)
|
||||
}
|
||||
pub(super) fn draw_link_prompt(&self, batch: &mut HudBatch) {
|
||||
let Some(url) = &self.link_prompt else {
|
||||
return;
|
||||
};
|
||||
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) = (
|
||||
Text::translate("gui.yes", &[]),
|
||||
Text::translate("gui.no", &[]),
|
||||
);
|
||||
self.link_prompt_layout(url, (w, h))
|
||||
.draw(batch, atlas, self.cursor, [&yes, &no]);
|
||||
}
|
||||
/// 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) {
|
||||
if let Some(url) = self.link_prompt.take()
|
||||
&& yes
|
||||
{
|
||||
super::packs::open_uri(&url);
|
||||
}
|
||||
}
|
||||
pub(super) fn link_prompt_click(&mut self) {
|
||||
let Some(url) = &self.link_prompt else {
|
||||
return;
|
||||
};
|
||||
let answer = self
|
||||
.link_prompt_layout(url, self.gui_size())
|
||||
.answer_at(self.cursor);
|
||||
if let Some(yes) = answer {
|
||||
self.audio.play_button_click();
|
||||
self.answer_link_prompt(yes);
|
||||
}
|
||||
}
|
||||
/// Chat lines over the HUD (focused while the chat screen is open).
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Chiseled bookshelves: `ChiseledBookShelfBlock.useItemOn` / `useWithoutItem`
|
||||
//! (a book into the slot under the crosshair, or out of it) and
|
||||
//! `ChiseledBookShelfBlockEntity.updateState`, which also follows the books a
|
||||
//! hopper or dropper moved (`machines::container_changed`). The slots' books
|
||||
//! show through the block state's `slot_N_occupied`, which reaches a server's
|
||||
//! clients with the block update.
|
||||
use super::{Game, Hit, machines::dir_from_name};
|
||||
use horizon_client::{containers::BlockEntity, items::ItemStack, mobs::Who};
|
||||
use horizon_core::{
|
||||
geometry::{Direction, Vec3},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::level::{flags, hit_slot};
|
||||
|
||||
const BLOCK: &str = "minecraft:chiseled_bookshelf";
|
||||
|
||||
impl Game<'_> {
|
||||
/// `useItemOn` with `held` in the acting hand, then `useWithoutItem` on
|
||||
/// the main hand's turn: None when the block passes the click, else
|
||||
/// whether the use succeeded and the arm swings (a click on an empty slot
|
||||
/// without a book only consumes it).
|
||||
pub(super) fn use_chiseled_bookshelf(
|
||||
&mut self,
|
||||
hit: &Hit,
|
||||
held: Option<&ItemStack>,
|
||||
) -> Option<bool> {
|
||||
let pos = hit.pos;
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let info = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.filter(|s| s.name == BLOCK)?;
|
||||
let facing = info
|
||||
.properties
|
||||
.get("facing")
|
||||
.and_then(|f| dir_from_name(f))?;
|
||||
let face = Direction::ALL
|
||||
.into_iter()
|
||||
.find(|d| d.normal() == hit.face)?;
|
||||
let at = Vec3::new(hit.point[0], hit.point[1], hit.point[2]);
|
||||
let slot = hit_slot(
|
||||
facing,
|
||||
face,
|
||||
BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
at,
|
||||
2,
|
||||
3,
|
||||
)?;
|
||||
let occupied = info
|
||||
.properties
|
||||
.get(&format!("slot_{slot}_occupied"))
|
||||
.map(String::as_str)
|
||||
== Some("true");
|
||||
let book = held.filter(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:bookshelf_books")
|
||||
.is_some_and(|m| m.contains(&*s.item))
|
||||
});
|
||||
let centre = pos.map(|v| f64::from(v) + 0.5);
|
||||
if let Some(book) = book
|
||||
&& !occupied
|
||||
{
|
||||
// addBook: one book goes in (a creative player keeps theirs).
|
||||
let book = book.clone();
|
||||
self.stats.add("used", &book.item, 1);
|
||||
let sound = if &*book.item == "minecraft:enchanted_book" {
|
||||
"minecraft:block.chiseled_bookshelf.insert.enchanted"
|
||||
} else {
|
||||
"minecraft:block.chiseled_bookshelf.insert"
|
||||
};
|
||||
self.bookshelf_items(pos)?[slot] = Some(ItemStack {
|
||||
count: 1,
|
||||
..book.clone()
|
||||
});
|
||||
self.consume_held();
|
||||
self.chiseled_bookshelf_update_state(pos, slot);
|
||||
self.audio.play(sound, Some(centre), 1.0, 1.0);
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, &book);
|
||||
return Some(true);
|
||||
}
|
||||
// TRY_WITH_EMPTY_HAND: only the main hand goes on to useWithoutItem.
|
||||
if self.hand != horizon_client::first_person::Hand::Main {
|
||||
return None;
|
||||
}
|
||||
if !occupied {
|
||||
return Some(false);
|
||||
}
|
||||
// removeBook: the book goes to the inventory, or is dropped.
|
||||
let retrieved = self.bookshelf_items(pos)?[slot].take()?;
|
||||
self.chiseled_bookshelf_update_state(pos, slot);
|
||||
let sound = if &*retrieved.item == "minecraft:enchanted_book" {
|
||||
"minecraft:block.chiseled_bookshelf.pickup.enchanted"
|
||||
} else {
|
||||
"minecraft:block.chiseled_bookshelf.pickup"
|
||||
};
|
||||
self.audio.play(sound, Some(centre), 1.0, 1.0);
|
||||
self.give_or_drop(retrieved);
|
||||
self.post_block_game_event("minecraft:block_change", pos, Some(Who::Player), None);
|
||||
Some(true)
|
||||
}
|
||||
|
||||
/// The bookshelf's six slots (made if the block has no block entity yet).
|
||||
fn bookshelf_items(&mut self, pos: [i32; 3]) -> Option<&mut [Option<ItemStack>; 6]> {
|
||||
let entity = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_or_insert_with((pos[0], pos[1], pos[2]), || {
|
||||
BlockEntity::ChiseledBookshelf(Box::default())
|
||||
});
|
||||
match entity {
|
||||
BlockEntity::ChiseledBookshelf(b) => Some(&mut b.items),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChiseledBookShelfBlockEntity.updateState`: `slot` becomes the last
|
||||
/// one touched, the block state takes the slots' occupancy
|
||||
/// (`setBlockAndUpdate`) and `BLOCK_CHANGE` goes out.
|
||||
fn chiseled_bookshelf_update_state(&mut self, pos: [i32; 3], slot: usize) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let Some(BlockEntity::ChiseledBookshelf(b)) = self.world.block_entities.get_mut(&key)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
b.last_slot = slot as i32;
|
||||
let occupied: Vec<bool> = b.items.iter().map(Option::is_some).collect();
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some(mut state) = registry
|
||||
.state(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for (i, occupied) in occupied.into_iter().enumerate() {
|
||||
state
|
||||
.properties
|
||||
.insert(format!("slot_{i}_occupied"), occupied.to_string());
|
||||
}
|
||||
let Some(new) = registry.id(&state) else {
|
||||
return;
|
||||
};
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, new, flags::UPDATE_ALL));
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
|
||||
}
|
||||
|
||||
/// The books a hopper or dropper put in or took out: the slot whose
|
||||
/// occupancy no longer matches the block state is the one `setItem` or
|
||||
/// `removeItem` updated.
|
||||
pub(super) fn chiseled_bookshelf_synced(&mut self, pos: (i32, i32, i32)) {
|
||||
let Some(BlockEntity::ChiseledBookshelf(b)) = self.world.block_entities.get(&pos) else {
|
||||
return;
|
||||
};
|
||||
let Some(info) = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(self.world.block(pos.0, pos.1, pos.2))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let moved = (0..6).rev().find(|i| {
|
||||
let shown = info
|
||||
.properties
|
||||
.get(&format!("slot_{i}_occupied"))
|
||||
.map(String::as_str);
|
||||
b.items[*i].is_some() != (shown == Some("true"))
|
||||
});
|
||||
if let Some(slot) = moved {
|
||||
self.chiseled_bookshelf_update_state([pos.0, pos.1, pos.2], slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,10 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
let random = self.particles.level_random();
|
||||
for m in self.mobs.iter_mut().filter(|m| !m.removed) {
|
||||
ambient::client_ai_step(m, &mut events);
|
||||
ambient::client_particles(m, random, &mut events);
|
||||
}
|
||||
for event in events {
|
||||
match event {
|
||||
@@ -37,6 +39,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.eye_particles();
|
||||
self.panda_effects();
|
||||
self.sprint_dust();
|
||||
self.effect_swirls();
|
||||
}
|
||||
|
||||
@@ -45,6 +45,13 @@ pub(super) struct CombatTracker {
|
||||
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
|
||||
/// fire decides whom the blast may hurt.
|
||||
pub(super) explosion_source: Option<u32>,
|
||||
/// The blast now going off comes from a primed TNT, creeper, wither
|
||||
/// skull, wither or TNT minecart, the sources that empty a hive they
|
||||
/// destroy (`BeehiveBlock.getDrops`).
|
||||
pub(super) hive_emptying_blast: bool,
|
||||
/// The mob that owns the projectile exploding now (`getIndirectSourceEntity`):
|
||||
/// the mobs the blast hurts record it as their attacker.
|
||||
pub(super) explosion_owner: Option<u32>,
|
||||
/// Charged creepers whose blast just went off and that have not dropped
|
||||
/// a head yet (`Creeper.droppedSkulls`): they are gone from the mob list,
|
||||
/// but the mobs they killed still ask for their loot.
|
||||
@@ -131,6 +138,19 @@ impl Game<'_> {
|
||||
self.recheck_combat();
|
||||
for e in events {
|
||||
let (name, amount) = match e {
|
||||
// LivingEntity.getDamageAfterMagicAbsorb (Resistance) and
|
||||
// Player.actuallyHurt (absorption hearts) and
|
||||
// LivingEntity.hurtServer (the shield, only for an accepted
|
||||
// hit), in tenths.
|
||||
MotionEvent::Health(HealthEvent::Statistic { name, amount }) => {
|
||||
match *name {
|
||||
"resisted" => self.stats.custom("damage_resisted", *amount),
|
||||
"absorbed" => self.stats.custom("damage_absorbed", *amount),
|
||||
"blocked" => self.stats.custom("damage_blocked_by_shield", *amount),
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
MotionEvent::Health(HealthEvent::RecordedDamage { source, amount }) => {
|
||||
let Some(d) = self.motion.health.damage.definition(*source) else {
|
||||
continue;
|
||||
@@ -177,6 +197,10 @@ impl Game<'_> {
|
||||
direct,
|
||||
});
|
||||
self.combat.last_damage = now;
|
||||
// InfestedMobEffect.onMobHurt.
|
||||
if self.potions.effects.get("infested").is_some() {
|
||||
self.player_infested();
|
||||
}
|
||||
}
|
||||
self.combat.pending.clear();
|
||||
self.combat.pending_direct.clear();
|
||||
|
||||
@@ -2049,8 +2049,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
self.world.block_entities.insert(key, new);
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let _ = self.world.set(pos[0], pos[1], pos[2], state);
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
true
|
||||
}
|
||||
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
|
||||
|
||||
@@ -46,6 +46,9 @@ pub(super) struct Creatures {
|
||||
pub projectiles: Vec<Projectile>,
|
||||
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
|
||||
pub wind_bursts: Vec<super::wind::Burst>,
|
||||
/// Mace smash knockback on the other players from a guest's turn, waiting
|
||||
/// for it to end (game/mace.rs).
|
||||
pub smash_pushes: Vec<super::mace::SmashPush>,
|
||||
/// Explosion packets from a guest's turn, waiting for it to end
|
||||
/// (centre, small, radius, blocks hit; game/lan_particles.rs).
|
||||
pub lan_explosions: Vec<([f64; 3], bool, f32, usize)>,
|
||||
@@ -53,6 +56,8 @@ pub(super) struct Creatures {
|
||||
pub elder_apparition: i32,
|
||||
/// The wandering trader spawner's clock.
|
||||
pub trader: super::wandering_trader::Spawner,
|
||||
/// The cat spawner's countdown (`CatSpawner.nextTick`).
|
||||
pub cat_tick: i32,
|
||||
/// Particles from a guest's turn, waiting for it to end
|
||||
/// (`lan_send_particles`: options, centre, spread, speed, count).
|
||||
pub lan_particles: Vec<(
|
||||
@@ -158,6 +163,8 @@ fn flame_geometry(
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
h -= 0.45;
|
||||
@@ -199,12 +206,15 @@ impl Game<'_> {
|
||||
off_hand: None,
|
||||
head: self.armor[0].as_ref().map(|s| s.item.clone()),
|
||||
armor_cover: self.armor.iter().filter(|a| a.is_some()).count() as f32 / 4.0,
|
||||
invisible: self.potions.effects.get("invisibility").is_some(),
|
||||
spectator: self.spectator(),
|
||||
gold_armor: self.wearing_gold(),
|
||||
hurt_by: self.player_hurt_by(),
|
||||
last_hurt: self.combat.last_hurt_mob.map(|(id, at)| (Who::Mob(id), at)),
|
||||
velocity: p.velocity,
|
||||
yaw: self.yaw,
|
||||
sprinting: p.sprinting,
|
||||
riding: self.riding.is_some(),
|
||||
hero: self.potions.effects.get("hero_of_the_village").is_some(),
|
||||
effects: self.potions.effects.list.iter().map(|e| e.id).collect(),
|
||||
uuid: self.level.player_uuid.unwrap_or_default(),
|
||||
@@ -415,6 +425,7 @@ impl Game<'_> {
|
||||
world: &self.world,
|
||||
registry: self.pack.assets.registry,
|
||||
facts: self.res.facts(),
|
||||
fluid_tables: &self.motion.fluids,
|
||||
movement: &self.motion.blocks,
|
||||
collision: self.collision,
|
||||
data: self.res.mob_data(),
|
||||
@@ -432,11 +443,13 @@ impl Game<'_> {
|
||||
block_tags: &self.res.items().block_tags,
|
||||
biomes: self.res.biomes(),
|
||||
mob_griefing: self.ui.rules.bool("mob_griefing"),
|
||||
mob_drops: self.ui.rules.bool("mob_drops"),
|
||||
end: self.dim == Dim::End,
|
||||
world_seed: self.level.seed,
|
||||
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
|
||||
teams: std::sync::Arc::new(self.mob_teams()),
|
||||
items: Some(self.res.items()),
|
||||
lid_openers: &self.lid_openers,
|
||||
difficulty: self.local_difficulty(
|
||||
self.player
|
||||
.as_ref()
|
||||
@@ -493,6 +506,7 @@ impl Game<'_> {
|
||||
if self.ui.rules.bool("spawn_mobs") {
|
||||
self.tick_wandering_trader();
|
||||
self.tick_patrol_spawner();
|
||||
self.tick_cat_spawner();
|
||||
}
|
||||
self.tick_raids();
|
||||
self.tick_creature_entities()
|
||||
@@ -525,6 +539,7 @@ impl Game<'_> {
|
||||
let mut events = Vec::new();
|
||||
let mut mobs = std::mem::take(&mut self.mobs);
|
||||
let mut dead = Vec::new();
|
||||
let mut corpses = Vec::new();
|
||||
let mut died = std::mem::take(&mut self.died);
|
||||
let ticking = self.entity_ticking_area();
|
||||
// With guests on, each mob sees its nearest player (game/lan_mobs.rs).
|
||||
@@ -574,6 +589,9 @@ impl Game<'_> {
|
||||
..base.clone()
|
||||
};
|
||||
let m = &mut mobs[i];
|
||||
// getCurrentDifficultyAt(blockPosition()): the mob's own chunk,
|
||||
// not the local player's (a server thread has no player).
|
||||
w.difficulty = self.local_difficulty([m.position.x, m.position.y, m.position.z]);
|
||||
w.nearest_player = super::natural_spawning::nearest_player(
|
||||
&players,
|
||||
m.position.x,
|
||||
@@ -619,6 +637,11 @@ impl Game<'_> {
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
let from = events.len();
|
||||
let kept = m.tick(&w, &mut scratch, &mut events);
|
||||
// A killed mob's body is removed: its effects that act on
|
||||
// death run (`LivingEntity.remove` with KILLED).
|
||||
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
|
||||
corpses.push(m.clone());
|
||||
}
|
||||
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
|
||||
// LivingEntity.tickDeath: after twenty ticks the body is removed.
|
||||
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
|
||||
@@ -699,11 +722,22 @@ impl Game<'_> {
|
||||
for m in dead {
|
||||
self.mob_died(&m);
|
||||
}
|
||||
for mut m in corpses {
|
||||
let names: Vec<&'static str> = m
|
||||
.extra
|
||||
.effects
|
||||
.iter()
|
||||
.map(|e| e.0.trim_start_matches("minecraft:"))
|
||||
.collect();
|
||||
let bounds = m.bounds();
|
||||
self.effects_on_death(&names, bounds, false, Some(m.id), Some(&mut m.random));
|
||||
}
|
||||
self.handle_mob_events(events)?;
|
||||
self.lan_guest_mob_events(guest_events);
|
||||
self.tick_projectiles()?;
|
||||
self.tick_fangs();
|
||||
self.tick_allays();
|
||||
self.tick_pandas();
|
||||
self.tick_breeze_effects();
|
||||
self.tick_sniffer_effects();
|
||||
self.piglin_pickups();
|
||||
@@ -771,6 +805,7 @@ impl Game<'_> {
|
||||
self.villager_murdered(m, by_player);
|
||||
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
|
||||
self.drop_death_loot(&mut dying, by_player, consumed, pos);
|
||||
self.create_wither_rose(m);
|
||||
if m.species() == Species::CopperGolem {
|
||||
// CopperGolem.dropEquipment: the preserved equipment too.
|
||||
self.golem_drop_equipment(&mut dying);
|
||||
@@ -901,6 +936,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `InfestedMobEffect.onMobHurt` for a mob: silverfish from half its height up.
|
||||
pub(super) fn mob_infested(&mut self, mob: u32) {
|
||||
if let Some(m) = self.mobs.iter().find(|m| m.id == mob) {
|
||||
let at = [
|
||||
m.position.x,
|
||||
m.position.y + f64::from(m.height()) / 2.0,
|
||||
m.position.z,
|
||||
];
|
||||
let look = m.view_vector();
|
||||
self.infested_hurt(at, look, Some(mob));
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.dropFromGiftLootTable`: the gift table from its random
|
||||
/// sequence with the mob as `this` (a chicken's egg depends on its
|
||||
/// variant); the loot data of the game's resources decides, and without it
|
||||
@@ -965,6 +1012,9 @@ impl Game<'_> {
|
||||
.map_or([0.0; 3], |m| [m.velocity.x, m.velocity.y, m.velocity.z]);
|
||||
self.drown_particles(at, v);
|
||||
}
|
||||
MobEvent::EntityEvent { mob, event, at } => {
|
||||
self.lan_entity_event(mob as i32, event, at)
|
||||
}
|
||||
MobEvent::Sound(name, at, volume, pitch)
|
||||
| MobEvent::LocalSound(name, at, volume, pitch) => {
|
||||
self.audio
|
||||
@@ -1012,6 +1062,19 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::Knockback {
|
||||
target,
|
||||
power,
|
||||
x,
|
||||
z,
|
||||
} => match target {
|
||||
Who::Player => self.knock_back_player(power, x, z),
|
||||
Who::Mob(id) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.knockback(power, x, z);
|
||||
}
|
||||
}
|
||||
},
|
||||
MobEvent::Shift { target, delta } => {
|
||||
let d = Vec3::new(delta[0], delta[1], delta[2]);
|
||||
match target {
|
||||
@@ -1046,7 +1109,7 @@ impl Game<'_> {
|
||||
self.combat.charged_exploders.push(mob);
|
||||
}
|
||||
}
|
||||
MobEvent::Explode(at, radius) => {
|
||||
MobEvent::Explode(at, radius, species) => {
|
||||
// The exploding mob causes the damage (Creeper.explodeCreeper).
|
||||
let source_mob = self.combat.explosion_source.or_else(|| {
|
||||
self.mobs
|
||||
@@ -1062,7 +1125,14 @@ impl Game<'_> {
|
||||
self.note_attacker("minecraft:explosion", id);
|
||||
}
|
||||
self.combat.explosion_source = source_mob;
|
||||
let result = self.explode(at, radius);
|
||||
// Only a creeper or a wither empties the hives it blows up.
|
||||
// The creeper is already gone from the list, so the event
|
||||
// carries its kind.
|
||||
let result = if matches!(species, Species::Creeper | Species::Wither) {
|
||||
self.with_hive_emptying_blast(|g| g.explode(at, radius))
|
||||
} else {
|
||||
self.explode(at, radius)
|
||||
};
|
||||
self.combat.explosion_source = None;
|
||||
result?
|
||||
}
|
||||
@@ -1073,6 +1143,7 @@ impl Game<'_> {
|
||||
} => {
|
||||
let mut killed = false;
|
||||
let mut hurt = false;
|
||||
let mut infested = false;
|
||||
if let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
|
||||
let hit = t.damage(amount, source);
|
||||
hurt = hit;
|
||||
@@ -1081,20 +1152,27 @@ impl Game<'_> {
|
||||
// The hurt or death sound the mob raised.
|
||||
let center = t.center();
|
||||
for event in std::mem::take(&mut t.outbox) {
|
||||
if let MobEvent::Sound(s, _, volume, pitch) = event {
|
||||
self.audio.play(
|
||||
&format!("minecraft:{s}"),
|
||||
Some(center),
|
||||
volume,
|
||||
pitch,
|
||||
);
|
||||
} else {
|
||||
// Left for the end of the tick.
|
||||
t.outbox.push(event);
|
||||
match event {
|
||||
MobEvent::Sound(s, _, volume, pitch) => {
|
||||
self.audio.play(
|
||||
&format!("minecraft:{s}"),
|
||||
Some(center),
|
||||
volume,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
MobEvent::Infested { .. } => infested = true,
|
||||
other => {
|
||||
// Left for the end of the tick.
|
||||
t.outbox.push(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if infested {
|
||||
self.mob_infested(target);
|
||||
}
|
||||
if hurt
|
||||
&& let Some(Who::Mob(attacker)) = source.attacker
|
||||
&& let Some(m) = self
|
||||
@@ -1140,6 +1218,7 @@ impl Game<'_> {
|
||||
}
|
||||
MobEvent::BecomeStatue { mob } => self.golem_become_statue(mob),
|
||||
MobEvent::EnterHive { mob, hive } => self.bee_enter_hive(mob, hive),
|
||||
MobEvent::Infested { mob } => self.mob_infested(mob),
|
||||
MobEvent::Gift { target, item } => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == target)
|
||||
&& m.saddle_item().is_none()
|
||||
@@ -1190,9 +1269,9 @@ impl Game<'_> {
|
||||
[0.0, 0.2, 0.0],
|
||||
10,
|
||||
),
|
||||
MobEvent::SetBlock { pos, state } => {
|
||||
self.world.set(pos[0], pos[1], pos[2], state)?
|
||||
}
|
||||
// `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop
|
||||
// above trampled farmland breaks and drops.
|
||||
MobEvent::SetBlock { pos, state } => self.level_set(pos, state)?,
|
||||
MobEvent::SetDoorOpen { pos, open } => {
|
||||
let pos = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open));
|
||||
@@ -1303,12 +1382,17 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
MobEvent::AroundSelf {
|
||||
mob,
|
||||
event,
|
||||
kind,
|
||||
feet,
|
||||
width,
|
||||
height,
|
||||
burst,
|
||||
} => self.particles_around(kind, feet, [width, height], burst),
|
||||
} => {
|
||||
self.lan_entity_event(mob as i32, event, feet);
|
||||
self.particles_around(kind, feet, [width, height], burst)
|
||||
}
|
||||
MobEvent::ParticleAt { kind, at, velocity } => {
|
||||
if let Some(ty) = horizon_client::particles::ParticleType::from_name(kind) {
|
||||
let options = horizon_client::particles::ParticleOptions::simple(ty);
|
||||
@@ -1419,9 +1503,30 @@ impl Game<'_> {
|
||||
m.ai.aggressive |= aggressive;
|
||||
}
|
||||
}
|
||||
MobEvent::PandaSneezed { mob } => self.panda_sneezed(mob),
|
||||
MobEvent::ShedScute { mob, at, pitch } => {
|
||||
// The mob already checked `shouldDropLoot` (a baby, `mob_drops`).
|
||||
let table = "minecraft:gameplay/armadillo_shed";
|
||||
let drops = self.sequence_loot(table, table, None);
|
||||
for d in drops {
|
||||
self.spawn_at_location(Vec3::new(at[0], at[1], at[2]), 0.0, d);
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:entity.armadillo.scute_drop",
|
||||
Some(at),
|
||||
1.0,
|
||||
pitch,
|
||||
);
|
||||
let ctx = self.who_context(Some(Who::Mob(mob)), None);
|
||||
self.post_game_event("minecraft:entity_place", at, ctx);
|
||||
}
|
||||
MobEvent::GiftLoot { mob, table, at } => {
|
||||
// Turtle.ageBoundaryReached: only with mob loot on.
|
||||
if table == "minecraft:gameplay/turtle_grow" && !self.ui.rules.bool("mob_drops")
|
||||
// Turtle.ageBoundaryReached and Panda.afterSneeze: only
|
||||
// with mob loot on.
|
||||
if matches!(
|
||||
table,
|
||||
"minecraft:gameplay/turtle_grow" | "minecraft:gameplay/panda_sneeze"
|
||||
) && !self.ui.rules.bool("mob_drops")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -1547,6 +1652,8 @@ impl Game<'_> {
|
||||
q.extra.in_love = 0;
|
||||
}
|
||||
let q = self.mobs.iter().find(|m| m.id == partner).cloned();
|
||||
// Animal.finalizeSpawnChildFromBreeding: entity event 18 for the parent.
|
||||
self.mob_event_particles(parent, 18);
|
||||
// Sniffer.spawnChildFromBreeding: an egg instead of a baby.
|
||||
if p.species() == Species::Sniffer {
|
||||
self.lay_sniffer_egg(&p);
|
||||
@@ -1658,6 +1765,13 @@ impl Game<'_> {
|
||||
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
return;
|
||||
}
|
||||
// Panda.getBreedOffspring: genes from both parents.
|
||||
if p.species() == Species::Panda {
|
||||
horizon_client::mobs::panda::inherit(child, &p, q.as_ref().unwrap_or(&p));
|
||||
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
return;
|
||||
}
|
||||
// Nautilus.getBreedOffspring: a tame parent's baby is tame too.
|
||||
if p.species() == Species::Nautilus && p.extra.tame {
|
||||
child.extra.tame = true;
|
||||
@@ -1666,6 +1780,10 @@ 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 {
|
||||
child.extra.variant = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant);
|
||||
}
|
||||
if p.species() == Species::Sheep {
|
||||
child.extra.color = p.extra.color;
|
||||
}
|
||||
@@ -1819,6 +1937,10 @@ impl Game<'_> {
|
||||
let (xd, zd) = from.map_or((0.0, 0.0), |from| {
|
||||
(from[0] - player.position.x, from[2] - player.position.z)
|
||||
});
|
||||
// dealDefaultKnockback's `indicateDamage`, unless blocked.
|
||||
if o.blocked <= 0.0 {
|
||||
player.health.indicate_damage(xd, zd, player.yaw);
|
||||
}
|
||||
self.knock_back_player(f64::from(0.4_f32), xd, zd);
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& let Some(r) = p.hurt_log.last_mut()
|
||||
@@ -1831,6 +1953,23 @@ impl Game<'_> {
|
||||
self.thorns(a);
|
||||
}
|
||||
}
|
||||
// LivingEntity.hurtServer: ENTITY_HURT_PLAYER, unless it returned
|
||||
// early (invulnerable, or inside the hurt cooldown).
|
||||
if outcome.hurt || outcome.sound {
|
||||
let source = attacker
|
||||
.or(owner)
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.map(|m| self.mob_entity(m));
|
||||
self.trigger_entity_hurt_player(&horizon_client::advancements::DamageView {
|
||||
kind: damage_type.to_owned(),
|
||||
direct: source.clone().filter(|_| !projectile),
|
||||
is_direct: !projectile,
|
||||
source,
|
||||
original: amount,
|
||||
actual: amount - outcome.blocked,
|
||||
blocked: outcome.blocked > 0.0,
|
||||
});
|
||||
}
|
||||
if raised {
|
||||
self.after_block(outcome, amount, attacker, projectile);
|
||||
}
|
||||
@@ -2063,6 +2202,21 @@ impl Game<'_> {
|
||||
if super::remote_projectiles::has_smoke_trail(&p.kind) {
|
||||
trails.push(p.position);
|
||||
}
|
||||
// ThrownEnderpearl.onAboveBubbleColumn / onInsideBubbleColumn.
|
||||
if keep && hit == Hit::None && matches!(p.kind, ProjectileKind::EnderPearl) {
|
||||
let (at, h) = (p.position, 0.125);
|
||||
horizon_client::motion::bubble_column_push(
|
||||
w.world,
|
||||
w.registry,
|
||||
w.facts,
|
||||
w.collision,
|
||||
Aabb::new(
|
||||
Vec3::new(at.x - h, at.y, at.z - h),
|
||||
Vec3::new(at.x + h, at.y + 2.0 * h, at.z + h),
|
||||
),
|
||||
&mut p.velocity,
|
||||
);
|
||||
}
|
||||
if let Some(a) = p.arrow.as_mut() {
|
||||
arrow_events.extend(a.events.drain(..).map(|e| (p.position, e)));
|
||||
}
|
||||
@@ -2488,14 +2642,46 @@ impl Game<'_> {
|
||||
.is_some_and(|m| m.damage(amount, source));
|
||||
if p.owner == Some(Who::Player) {
|
||||
self.last_hurt_by_player.insert(id, self.lan_acting);
|
||||
if hurt && let Some(m) = self.mobs.iter().find(|m| m.id == id) {
|
||||
// LivingEntity.hurtServer: PLAYER_HURT_ENTITY.
|
||||
let victim = self.mob_entity(m);
|
||||
let spectral = p.arrow.as_ref().is_some_and(|a| {
|
||||
&*a.pickup_item.item == "minecraft:spectral_arrow"
|
||||
});
|
||||
let direct = horizon_client::advancements::EntityView {
|
||||
kind: if spectral {
|
||||
"minecraft:spectral_arrow"
|
||||
} else {
|
||||
"minecraft:arrow"
|
||||
}
|
||||
.into(),
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
..Default::default()
|
||||
};
|
||||
self.player_hit(&victim, "minecraft:arrow", Some(direct), amount);
|
||||
}
|
||||
}
|
||||
hurt
|
||||
}
|
||||
};
|
||||
// doKnockback: Punch pushes along the flight.
|
||||
// doKnockback: Punch pushes along the flight, less the target's
|
||||
// knockback resistance.
|
||||
let flight = (p.velocity.x * p.velocity.x + p.velocity.z * p.velocity.z).sqrt();
|
||||
if hurt && p.bonus.punch > 0 && flight > 1e-6 {
|
||||
let k = f64::from(p.bonus.punch) * 0.6 / flight;
|
||||
let resistance = match who {
|
||||
Who::Player => self
|
||||
.player
|
||||
.as_mut()
|
||||
.and_then(|pl| pl.attribute("minecraft:knockback_resistance").ok())
|
||||
.unwrap_or(0.0),
|
||||
Who::Mob(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map_or(0.0, |m| m.attrs.knockback_resistance),
|
||||
};
|
||||
let push = f64::from(p.bonus.punch) * 0.6 * (1.0 - resistance).max(0.0);
|
||||
if hurt && push > 0.0 && flight > 1e-6 {
|
||||
let k = push / flight;
|
||||
self.handle_mob_events(vec![MobEvent::Push {
|
||||
target: who,
|
||||
velocity: [p.velocity.x * k, 0.1, p.velocity.z * k],
|
||||
@@ -2531,6 +2717,7 @@ impl Game<'_> {
|
||||
};
|
||||
m.damage(amount, source);
|
||||
}
|
||||
self.thrown_break_event(p.id, atp);
|
||||
self.item_burst(atp, "minecraft:snowball");
|
||||
}
|
||||
(ProjectileKind::Egg, hit) => {
|
||||
@@ -2556,6 +2743,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.thrown_break_event(p.id, atp);
|
||||
self.item_burst(atp, "minecraft:egg");
|
||||
}
|
||||
(ProjectileKind::EnderPearl, hit) => {
|
||||
@@ -2659,7 +2847,10 @@ impl Game<'_> {
|
||||
if let Some(owner) = self.combat.projectile_owner {
|
||||
self.note_attacker("minecraft:explosion", owner);
|
||||
}
|
||||
self.explode(atp, *power as f32)?;
|
||||
self.combat.explosion_owner = self.combat.projectile_owner;
|
||||
let result = self.explode(atp, *power as f32);
|
||||
self.combat.explosion_owner = None;
|
||||
result?;
|
||||
}
|
||||
(
|
||||
ProjectileKind::Potion {
|
||||
@@ -2805,10 +2996,17 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `Snowball`/`ThrownEgg.onHit`: entity event 3 for the guests, before the
|
||||
/// projectile is discarded (the clients lose it when the tracker next runs).
|
||||
fn thrown_break_event(&mut self, id: u32, at: [f64; 3]) {
|
||||
if id != 0 {
|
||||
self.lan_entity_event(id as i32, 3, at);
|
||||
}
|
||||
}
|
||||
/// `Snowball`/`ThrownEgg.handleEntityEvent(3)`: eight crumbs of the
|
||||
/// thrown item where it broke, at rest for a snowball, scattered by the
|
||||
/// client entity's random (the particle engine's here) for an egg.
|
||||
fn item_burst(&mut self, at: [f64; 3], item: &str) {
|
||||
pub(super) fn item_burst(&mut self, at: [f64; 3], item: &str) {
|
||||
let options = self.item_particle(&horizon_client::items::ItemStack::new(item, 1));
|
||||
let egg = item != "minecraft:snowball";
|
||||
for _ in 0..8 {
|
||||
@@ -2844,6 +3042,10 @@ impl Game<'_> {
|
||||
if self.name_mob(index) {
|
||||
return Ok(true);
|
||||
}
|
||||
if self.spawn_egg_offspring(index) {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
{
|
||||
let item = self.held().map(|h| h.item.to_string()).unwrap_or_default();
|
||||
if self.interact_leash(index, &item) {
|
||||
@@ -2886,6 +3088,14 @@ impl Game<'_> {
|
||||
let creative = self.creative;
|
||||
// The hand's slot (Player.interactOn passes the hand).
|
||||
let selected = self.held_slot();
|
||||
// Panda.mobInteract: bamboo, or a click on a panda lying on its back.
|
||||
if self.mobs[index].species() == Species::Panda && self.mobs[index].alive() {
|
||||
let taken = self.interact_panda(index, &item);
|
||||
if taken {
|
||||
self.swing_arm();
|
||||
}
|
||||
return Ok(taken);
|
||||
}
|
||||
if self.interact_zombie_villager(index, &item) {
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -2946,6 +3156,7 @@ impl Game<'_> {
|
||||
.items()
|
||||
.get(&item)
|
||||
.is_some_and(|i| i.max_damage > 0);
|
||||
let acting = self.acting_player_uuid();
|
||||
let m = &mut self.mobs[index];
|
||||
if !m.alive() {
|
||||
return Ok(false);
|
||||
@@ -3004,6 +3215,7 @@ impl Game<'_> {
|
||||
at: pos,
|
||||
setup,
|
||||
}])?;
|
||||
self.damage_held_tool();
|
||||
}
|
||||
Species::Sheep if item == "minecraft:shears" && !m.extra.sheared && !m.is_baby() => {
|
||||
m.extra.sheared = true;
|
||||
@@ -3050,6 +3262,7 @@ impl Game<'_> {
|
||||
}
|
||||
Species::Wolf
|
||||
if m.extra.tame
|
||||
&& m.extra.owner == Some(acting)
|
||||
&& item.ends_with("_dye")
|
||||
&& horizon_client::mobs::render::DYE_NAMES
|
||||
.iter()
|
||||
@@ -3072,7 +3285,9 @@ impl Game<'_> {
|
||||
consume = true;
|
||||
}
|
||||
Species::Wolf
|
||||
if m.extra.tame && !(is_food && m.extra.age == 0 && m.extra.in_love <= 0) =>
|
||||
if m.extra.tame
|
||||
&& m.extra.owner == Some(acting)
|
||||
&& !(is_food && m.extra.age == 0 && m.extra.in_love <= 0) =>
|
||||
{
|
||||
m.extra.sitting = !m.extra.sitting;
|
||||
m.ai.jumping = false;
|
||||
@@ -3092,20 +3307,14 @@ impl Game<'_> {
|
||||
let tamed = m.random.next_int_bound(3).unwrap_or(1) == 0;
|
||||
if tamed {
|
||||
horizon_client::mobs::tame::set_tame(m, true);
|
||||
m.extra.owner = self.level.player_uuid.or(Some([0, 0, 0, 1]));
|
||||
m.extra.owner = Some(acting);
|
||||
m.persistent = true;
|
||||
m.nav_stop();
|
||||
m.ai.target = None;
|
||||
m.extra.sitting = true;
|
||||
}
|
||||
// TamableAnimal.spawnTamingParticles.
|
||||
let kind = if tamed { "heart" } else { "smoke" };
|
||||
// TamableAnimal.spawnTamingParticles (entity event 7 or 6).
|
||||
self.particles_around_mob(
|
||||
self.mobs[index].id,
|
||||
kind,
|
||||
horizon_client::particles::Burst::TAMING,
|
||||
);
|
||||
self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
|
||||
if tamed {
|
||||
let view = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_entity("minecraft:tame_animal", "entity", &view);
|
||||
@@ -3119,6 +3328,7 @@ impl Game<'_> {
|
||||
// Cat.mobInteract: dye, heal or sit when tame; fish may tame a wild one.
|
||||
Species::Cat
|
||||
if m.extra.tame
|
||||
&& m.extra.owner == Some(acting)
|
||||
&& item.ends_with("_dye")
|
||||
&& horizon_client::mobs::render::DYE_NAMES
|
||||
.iter()
|
||||
@@ -3141,7 +3351,12 @@ impl Game<'_> {
|
||||
m.persistent = true;
|
||||
consume = true;
|
||||
}
|
||||
Species::Cat if m.extra.tame && is_food && m.health < m.attrs.max_health => {
|
||||
Species::Cat
|
||||
if m.extra.tame
|
||||
&& m.extra.owner == Some(acting)
|
||||
&& is_food
|
||||
&& m.health < m.attrs.max_health =>
|
||||
{
|
||||
let heal = self
|
||||
.res
|
||||
.items()
|
||||
@@ -3154,7 +3369,9 @@ impl Game<'_> {
|
||||
eat = eating_sound(m, &mut self.world.random);
|
||||
}
|
||||
Species::Cat
|
||||
if m.extra.tame && !(is_food && m.extra.age == 0 && m.extra.in_love <= 0) =>
|
||||
if m.extra.tame
|
||||
&& m.extra.owner == Some(acting)
|
||||
&& !(is_food && m.extra.age == 0 && m.extra.in_love <= 0) =>
|
||||
{
|
||||
m.extra.sitting = !m.extra.sitting;
|
||||
self.world.modified.insert((
|
||||
@@ -3168,17 +3385,12 @@ impl Game<'_> {
|
||||
let tamed = m.random.next_int_bound(3).unwrap_or(1) == 0;
|
||||
if tamed {
|
||||
m.extra.tame = true;
|
||||
m.extra.owner = self.level.player_uuid.or(Some([0, 0, 0, 1]));
|
||||
m.extra.owner = Some(acting);
|
||||
m.extra.sitting = true;
|
||||
}
|
||||
eat = eating_sound(m, &mut self.world.random);
|
||||
let kind = if tamed { "heart" } else { "smoke" };
|
||||
// TamableAnimal.spawnTamingParticles (entity event 7 or 6).
|
||||
self.particles_around_mob(
|
||||
self.mobs[index].id,
|
||||
kind,
|
||||
horizon_client::particles::Burst::TAMING,
|
||||
);
|
||||
self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
|
||||
if tamed {
|
||||
let view = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_entity("minecraft:tame_animal", "entity", &view);
|
||||
@@ -3226,13 +3438,8 @@ impl Game<'_> {
|
||||
m.extra.tame = true;
|
||||
m.extra.owner = self.level.player_uuid.or(Some([0, 0, 0, 1]));
|
||||
}
|
||||
let kind = if tamed { "heart" } else { "smoke" };
|
||||
// TamableAnimal.spawnTamingParticles (entity event 7 or 6).
|
||||
self.particles_around_mob(
|
||||
self.mobs[index].id,
|
||||
kind,
|
||||
horizon_client::particles::Burst::TAMING,
|
||||
);
|
||||
self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
|
||||
if tamed {
|
||||
let view = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_entity("minecraft:tame_animal", "entity", &view);
|
||||
@@ -3263,6 +3470,11 @@ impl Game<'_> {
|
||||
Some(sound) => {
|
||||
consume = true;
|
||||
eat = sound;
|
||||
// Animal.setInLove: entity event 18 (a baby that
|
||||
// only grew is not in love).
|
||||
if self.mobs[index].extra.in_love > 0 {
|
||||
self.mob_event_particles(self.mobs[index].id, 18);
|
||||
}
|
||||
}
|
||||
// Animal.mobInteract still consumes the client click when
|
||||
// an adult cannot breed, without swinging or taking food.
|
||||
@@ -3382,23 +3594,9 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `ItemStack.hurtAndBreak(1, ...)` on the held tool: Unbreaking, break sound and stat.
|
||||
pub(super) fn damage_held_tool(&mut self) {
|
||||
if self.creative {
|
||||
return;
|
||||
}
|
||||
let slot = self.held_slot();
|
||||
let max_damage = self
|
||||
.held()
|
||||
.and_then(|s| self.res.items().get(&s.item))
|
||||
.map_or(0, |i| i.max_damage);
|
||||
if max_damage > 0
|
||||
&& let Some(stack) = self.stack_in_mut(slot).as_mut()
|
||||
{
|
||||
stack.damage += 1;
|
||||
if stack.damage >= max_damage {
|
||||
*self.stack_in_mut(slot) = None;
|
||||
}
|
||||
}
|
||||
self.damage_held(1);
|
||||
}
|
||||
/// Throwables and spawn eggs (SnowballItem/EggItem/EnderpearlItem/SpawnEggItem.use).
|
||||
pub(super) fn throw_item(&mut self, stack: &ItemStack) -> Result<bool> {
|
||||
@@ -3459,6 +3657,45 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob
|
||||
/// `index`'s own kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`): a baby
|
||||
/// at the mob's feet, taking the parent's variant as `getBreedOffspring`
|
||||
/// does, and one egg used. False (PASS) when the mob has no baby form.
|
||||
fn spawn_egg_offspring(&mut self, index: usize) -> bool {
|
||||
let Some(stack) = self.held().cloned() else {
|
||||
return false;
|
||||
};
|
||||
let parent = &self.mobs[index];
|
||||
if !parent.alive()
|
||||
|| stack.item.strip_suffix("_spawn_egg") != Some(parent.kind.id.as_str())
|
||||
|| !horizon_client::mobs::drops::can_be_baby(parent.species())
|
||||
// WanderingTrader.getBreedOffspring returns null: no offspring, the
|
||||
// click carries on to trading.
|
||||
|| parent.species() == Species::WanderingTrader
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (kind, at) = (
|
||||
parent.kind.id.clone(),
|
||||
[parent.position.x, parent.position.y, parent.position.z],
|
||||
);
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
|
||||
self.consume_held();
|
||||
true
|
||||
}
|
||||
fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
|
||||
let Some(hit) = self.pick() else {
|
||||
return Ok(false);
|
||||
@@ -3590,8 +3827,10 @@ impl Game<'_> {
|
||||
let scale = self.view_scale();
|
||||
for mob in &self.mobs {
|
||||
let entity_start = out.len();
|
||||
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws.
|
||||
if mob.invisible {
|
||||
let outline = self.mob_outline(mob);
|
||||
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
|
||||
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
|
||||
if mob.invisible && outline.is_none() {
|
||||
continue;
|
||||
}
|
||||
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
|
||||
@@ -3660,9 +3899,18 @@ impl Game<'_> {
|
||||
out,
|
||||
);
|
||||
self.mob_block_geometry(mob, camera, alpha, packed, out);
|
||||
if let Some(rgb) = outline {
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
if mob.invisible {
|
||||
out.truncate(entity_start);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Glowing outlines and a guardian's beam remain visible independently
|
||||
// of whether the body is hidden behind terrain.
|
||||
if !mob.glowing && horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none() {
|
||||
if outline.is_none()
|
||||
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
|
||||
{
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
// GuardianRenderer: the laser to the locked target.
|
||||
@@ -3710,7 +3958,15 @@ impl Game<'_> {
|
||||
super::pickups::Taken::Arrow(a) => Some((&**a, super::pickups::position(p, alpha))),
|
||||
_ => None,
|
||||
});
|
||||
// A glowing projectile's outline: what it drew up to the next one.
|
||||
let mut glowing = None;
|
||||
for (a, at) in live.chain(taken) {
|
||||
if let Some((start, rgb)) = glowing.take() {
|
||||
self.mark_outline(&out[start..], rgb);
|
||||
}
|
||||
if a.id != 0 && self.entity_glowing(a.id) {
|
||||
glowing = Some((out.len(), super::glowing::team_color(self.mob_team(a.id))));
|
||||
}
|
||||
let p = glam::DVec3::from_array(at) - camera;
|
||||
let c = p.as_vec3();
|
||||
let light = self.world.light(
|
||||
@@ -3839,9 +4095,14 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some((start, rgb)) = glowing {
|
||||
self.mark_outline(&out[start..], rgb);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Bucketable.getBucketItemStack` and `getPickupSound` of a mob that a water
|
||||
|
||||
@@ -27,6 +27,8 @@ pub(super) const SPECIAL: u32 = u32::MAX - 4096;
|
||||
const CRYSTAL_IDS: u32 = SPECIAL + 16;
|
||||
const FIGHT_FILE: &str = "data/minecraft/ender_dragon_fight.dat";
|
||||
const MAX_HEALTH: f32 = 200.0;
|
||||
/// `Mob.getAmbientSoundInterval`.
|
||||
const AMBIENT_INTERVAL: i32 = 80;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Fight {
|
||||
@@ -48,6 +50,9 @@ pub(super) struct Fight {
|
||||
respawn: Option<(Respawn, i32)>,
|
||||
/// The four crystals on the exit portal driving the respawn.
|
||||
respawn_crystals: Vec<Vec3>,
|
||||
/// The player behind the crystal blast under way, whose `explosion`
|
||||
/// damage source names them (`EndCrystal.hurtServer`).
|
||||
blast_by: Option<FightWho>,
|
||||
dirty: bool,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -262,6 +267,9 @@ pub(super) struct Dragon {
|
||||
/// only damage above the last hit lands, and only the excess.
|
||||
invulnerable: i32,
|
||||
last_hurt: f32,
|
||||
/// `lastHurtByPlayer` with its 100 tick memory: the player credited with
|
||||
/// the kill, and the dragon tick of their last hit.
|
||||
hurt_by_player: Option<(FightWho, i32)>,
|
||||
pub death_time: i32,
|
||||
pub flap: f32,
|
||||
pub old_flap: f32,
|
||||
@@ -270,6 +278,8 @@ pub(super) struct Dragon {
|
||||
flame_count: i32,
|
||||
sitting_damage: f32,
|
||||
growl: i32,
|
||||
/// `Mob.ambientSoundTime`.
|
||||
ambient_time: i32,
|
||||
ticks: i32,
|
||||
/// DragonFlightHistory: (y, yRot) samples, newest at `head`.
|
||||
history: [(f64, f32); 64],
|
||||
@@ -385,6 +395,7 @@ impl Dragon {
|
||||
hurt_time: 0,
|
||||
invulnerable: 0,
|
||||
last_hurt: 0.0,
|
||||
hurt_by_player: None,
|
||||
death_time: 0,
|
||||
flap: 0.0,
|
||||
old_flap: 0.0,
|
||||
@@ -397,6 +408,7 @@ impl Dragon {
|
||||
flame_count: 0,
|
||||
sitting_damage: 0.0,
|
||||
growl: 100,
|
||||
ambient_time: 0,
|
||||
ticks: 0,
|
||||
history: [(position.y, yaw); 64],
|
||||
head: 0,
|
||||
@@ -426,6 +438,10 @@ impl Dragon {
|
||||
pub fn alive(&self) -> bool {
|
||||
self.health > 0.0
|
||||
}
|
||||
/// `isDeadOrDying` for the killing blow: the dying flight, or no health.
|
||||
fn down(&self) -> bool {
|
||||
matches!(self.phase, Phase::Dying { .. }) || !self.alive()
|
||||
}
|
||||
fn part_box(&self, i: usize) -> Aabb {
|
||||
let (w, h) = PART_SIZES[i];
|
||||
let p = self.parts[i].0;
|
||||
@@ -893,8 +909,12 @@ impl Game<'_> {
|
||||
.any(|p| p.distance_squared(c.position) < 0.01);
|
||||
if damage == CrystalDamage::Direct {
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
// The blast's damage source carries the player, so the dragon
|
||||
// remembers them before the blast can kill it.
|
||||
self.fight.blast_by = by;
|
||||
let _ =
|
||||
self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false);
|
||||
self.fight.blast_by = None;
|
||||
}
|
||||
if respawn_crystal {
|
||||
self.abort_respawn();
|
||||
@@ -905,7 +925,7 @@ impl Game<'_> {
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
if d.nearest_crystal == Some(index) {
|
||||
d.nearest_crystal = None;
|
||||
self.hurt_dragon(0, 10.0, true, false);
|
||||
self.hurt_dragon(0, 10.0, true, by, false);
|
||||
} else if let Some(n) = d.nearest_crystal.as_mut()
|
||||
&& *n > index
|
||||
{
|
||||
@@ -977,18 +997,46 @@ impl Game<'_> {
|
||||
self.destroy_crystal(index, by, CrystalDamage::Direct);
|
||||
Hurt::Landed { full: true }
|
||||
} else if id >= SPECIAL {
|
||||
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, arrow)
|
||||
let by = by_player.then(|| self.acting_player());
|
||||
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, by, arrow)
|
||||
} else {
|
||||
Hurt::Refused
|
||||
}
|
||||
}
|
||||
/// `EnderDragon.hurt(part, source, damage)` on the dragon in the level.
|
||||
fn hurt_dragon(&mut self, part: usize, damage: f32, counts: bool, arrow: bool) -> Hurt {
|
||||
/// `by` is the player behind the hit, remembered for the kill credit.
|
||||
fn hurt_dragon(
|
||||
&mut self,
|
||||
part: usize,
|
||||
damage: f32,
|
||||
counts: bool,
|
||||
by: Option<FightWho>,
|
||||
arrow: bool,
|
||||
) -> Hurt {
|
||||
let Some(d) = self.dragon.as_mut() else {
|
||||
return Hurt::Refused;
|
||||
};
|
||||
let was_down = d.down();
|
||||
let (cooldown, last_hurt) = (d.invulnerable, d.last_hurt);
|
||||
let hurt = d.hurt(part, damage, counts, arrow);
|
||||
// LivingEntity.hurtServer: damage that is applied sets lastHurtByPlayer
|
||||
// (100 ticks); a hit the damage cooldown absorbs returns before that.
|
||||
if counts
|
||||
&& matches!(hurt, Hurt::Landed { .. })
|
||||
&& (d.invulnerable != cooldown || d.last_hurt != last_hurt)
|
||||
&& let Some(who) = by
|
||||
{
|
||||
d.hurt_by_player = Some((who, d.ticks));
|
||||
}
|
||||
if !was_down && d.down() {
|
||||
self.credit_dragon_kill();
|
||||
}
|
||||
let Some(d) = self.dragon.as_mut() else {
|
||||
return hurt;
|
||||
};
|
||||
if hurt == (Hurt::Landed { full: true }) {
|
||||
// Mob.playHurtSound: the ambient sound clock starts again.
|
||||
d.ambient_time = -AMBIENT_INTERVAL;
|
||||
let at = [d.position.x, d.position.y, d.position.z];
|
||||
let pitch = 0.8 + self.world.random.next_float() * 0.3;
|
||||
self.audio
|
||||
@@ -996,6 +1044,47 @@ impl Game<'_> {
|
||||
}
|
||||
hurt
|
||||
}
|
||||
/// `LivingEntity.die`: `getKillCredit().awardKillScore`, for the player
|
||||
/// who hurt the dragon within the last 100 ticks. A guest's kill is
|
||||
/// theirs: their statistics, scores and advancements.
|
||||
fn credit_dragon_kill(&mut self) {
|
||||
let Some(d) = self.dragon.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some((who, _)) = d.hurt_by_player.filter(|(_, hit)| d.ticks - hit <= 100) else {
|
||||
return;
|
||||
};
|
||||
let view = horizon_client::advancements::EntityView {
|
||||
kind: "minecraft:ender_dragon".to_owned(),
|
||||
position: [d.position.x, d.position.y, d.position.z],
|
||||
..Default::default()
|
||||
};
|
||||
let id = d.id;
|
||||
let guest = match who {
|
||||
FightWho::Guest(conn) if self.lan_acting.is_none() => Some(conn),
|
||||
_ => None,
|
||||
};
|
||||
let credited = guest.is_some_and(|conn| {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return false;
|
||||
};
|
||||
let done = self
|
||||
.as_guest(&mut host, conn, |g| g.award_dragon_kill(id, &view))
|
||||
.is_some();
|
||||
self.lan = Some(host);
|
||||
done
|
||||
});
|
||||
if !credited {
|
||||
self.award_dragon_kill(id, &view);
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.awardKillScore` for the dragon, on the killer's state.
|
||||
fn award_dragon_kill(&mut self, id: u32, view: &horizon_client::advancements::EntityView) {
|
||||
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);
|
||||
}
|
||||
/// 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.
|
||||
pub(super) fn dragon_hit(&self) -> Option<(u32, f64)> {
|
||||
@@ -1661,6 +1750,24 @@ impl Game<'_> {
|
||||
self.tick_dragon_death();
|
||||
return;
|
||||
}
|
||||
// Mob.baseTick: the ambient sound, rarer right after one.
|
||||
let roll = d.random.next_int_bound(1000).unwrap_or(999);
|
||||
let waited = d.ambient_time;
|
||||
d.ambient_time += 1;
|
||||
if roll < waited {
|
||||
d.ambient_time = -AMBIENT_INTERVAL;
|
||||
let at = [d.position.x, d.position.y, d.position.z];
|
||||
// LivingEntity.getVoicePitch, from the dragon's own random.
|
||||
let r = &mut d.random;
|
||||
let pitch = (r.next_float() - r.next_float()) * 0.2 + 1.0;
|
||||
self.audio.play(
|
||||
"minecraft:entity.ender_dragon.ambient",
|
||||
Some(at),
|
||||
5.0,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
let d = self.dragon.as_mut().expect("dragon");
|
||||
// Flapping, growls and the flap sound.
|
||||
let flap_was = (d.old_flap * std::f32::consts::TAU).cos();
|
||||
let v = d.velocity;
|
||||
@@ -2140,10 +2247,12 @@ impl Game<'_> {
|
||||
c.at[1],
|
||||
c.at[2] + f64::from(a.sin() * d),
|
||||
];
|
||||
self.particles.add(
|
||||
self.particles.add_with(
|
||||
&ParticleOptions::simple(ParticleType::DragonBreath),
|
||||
at,
|
||||
[0.0, 0.0, 0.0],
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
// AreaEffectCloud: after its 20-tick wait, every 20 ticks per entity.
|
||||
@@ -2512,6 +2621,8 @@ impl Game<'_> {
|
||||
layer: 2,
|
||||
color: c,
|
||||
light: 0,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2577,6 +2688,8 @@ impl Game<'_> {
|
||||
layer: 6,
|
||||
color: c,
|
||||
light: wrap_light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
(last_sin, last_cos, last_u) = (sin, cos, uu);
|
||||
@@ -2622,6 +2735,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: 240 | 240 << 8,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2747,8 +2862,9 @@ impl Game<'_> {
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
|
||||
});
|
||||
let by = self.fight.blast_by;
|
||||
for (part, amount) in hits {
|
||||
self.hurt_dragon(part, amount, true, false);
|
||||
self.hurt_dragon(part, amount, true, by, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,8 +157,10 @@ impl Game<'_> {
|
||||
items: Some(self.res.items()),
|
||||
..Default::default()
|
||||
};
|
||||
// minecraft:post_attack ignite effects of the attacker's weapon (fire aspect).
|
||||
// minecraft:post_attack effects of the attacker's weapon on the victim:
|
||||
// ignite (fire aspect) and apply_mob_effect (bane of arthropods).
|
||||
let mut ignite = 0.0f32;
|
||||
let mut mob_effects = Vec::new();
|
||||
for (id, level) in held.enchantments() {
|
||||
let Some(def) = self.res.stations().enchantments.get(id) else {
|
||||
continue;
|
||||
@@ -168,24 +170,33 @@ impl Game<'_> {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if effect["enchanted"] == "attacker"
|
||||
&& effect["affected"] == "victim"
|
||||
&& effect["effect"]["type"] == "minecraft:ignite"
|
||||
let e = &effect["effect"];
|
||||
let kind_of = e["type"].as_str();
|
||||
if effect["enchanted"] != "attacker"
|
||||
|| effect["affected"] != "victim"
|
||||
|| !matches!(
|
||||
kind_of,
|
||||
Some("minecraft:ignite" | "minecraft:apply_mob_effect")
|
||||
)
|
||||
{
|
||||
let mut random = self.world.random.clone();
|
||||
let ok = effect.get("requirements").is_none_or(|r| {
|
||||
self.res
|
||||
.stations()
|
||||
.enchantments
|
||||
.condition(r, *level, &ctx, &mut random)
|
||||
});
|
||||
self.world.random = random;
|
||||
if ok {
|
||||
ignite = ignite.max(horizon_client::enchant::level_value(
|
||||
&effect["effect"]["duration"],
|
||||
*level,
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut random = self.world.random.clone();
|
||||
let ok = effect.get("requirements").is_none_or(|r| {
|
||||
self.res
|
||||
.stations()
|
||||
.enchantments
|
||||
.condition(r, *level, &ctx, &mut random)
|
||||
});
|
||||
self.world.random = random;
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
if kind_of == Some("minecraft:ignite") {
|
||||
ignite =
|
||||
ignite.max(horizon_client::enchant::level_value(&e["duration"], *level));
|
||||
} else {
|
||||
mob_effects.push((e.clone(), *level));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,8 +206,41 @@ impl Game<'_> {
|
||||
{
|
||||
m.ignite_for_ticks((ignite * 20.0) as i32);
|
||||
}
|
||||
for (effect, level) in mob_effects {
|
||||
self.apply_mob_effect(mob, &effect, level);
|
||||
}
|
||||
self.post_attack_bursts(&held, &kind);
|
||||
}
|
||||
/// `ApplyMobEffect.apply`: one of `to_apply` (a single id or a list) for a
|
||||
/// random duration and amplifier between the level-based bounds.
|
||||
fn apply_mob_effect(&mut self, mob: usize, effect: &serde_json::Value, level: u32) {
|
||||
use horizon_client::enchant::level_value;
|
||||
let Some(m) = self.mobs.get_mut(mob) else {
|
||||
return;
|
||||
};
|
||||
let ids: Vec<&str> = match &effect["to_apply"] {
|
||||
serde_json::Value::String(id) => vec![id.as_str()],
|
||||
serde_json::Value::Array(list) => list.iter().filter_map(|v| v.as_str()).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
// HolderSet.getRandomElement, then Mth.randomBetween for both values.
|
||||
let pick = m.random.next_int_bound(ids.len() as i32).unwrap_or(0) as usize;
|
||||
let mut between = |min: &str, max: &str| {
|
||||
let (lo, hi) = (
|
||||
level_value(&effect[min], level),
|
||||
level_value(&effect[max], level),
|
||||
);
|
||||
m.random.next_float() * (hi - lo) + lo
|
||||
};
|
||||
let ticks = (between("min_duration", "max_duration") * 20.0).round() as i32;
|
||||
let amplifier = (between("min_amplifier", "max_amplifier").round() as i32).max(0);
|
||||
if let Some(id) = super::potions::mob_effect_id(ids[pick]) {
|
||||
m.add_effect(id, ticks, amplifier);
|
||||
}
|
||||
}
|
||||
/// `minecraft:post_attack` explode effects on the attacker (wind burst):
|
||||
/// a burst at the player's feet that only knocks back and triggers
|
||||
/// blocks (`ExplodeEffect` with `block_interaction: trigger`).
|
||||
@@ -307,8 +351,10 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
hits.push(1.0 + self.world.random.next_float() * 4.0);
|
||||
// ChangeItemDamage -> ItemStack.hurtAndBreak: unbreaking applies.
|
||||
let loss = self.durability_loss(&stack, 2);
|
||||
if let Some(s) = self.armor[slot].as_mut() {
|
||||
s.damage += 2;
|
||||
s.damage += loss;
|
||||
if self
|
||||
.res
|
||||
.items()
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
containers::{BlockEntity, EndGateway},
|
||||
environment::Environment,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
@@ -27,14 +28,64 @@ const GATEWAY_Y: i32 = 75;
|
||||
const GATEWAY_FILE: &str = "horizon-end-gateways.json";
|
||||
|
||||
/// A gateway teleport waiting for its destination chunks.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum GatewayJob {
|
||||
/// TheEndGatewayBlockEntity.findOrCreateValidTeleportPos from this gateway.
|
||||
Find { gateway: [i32; 3] },
|
||||
/// `exact` is the gateway's own `ExactTeleport`, read while its chunk is loaded.
|
||||
Find { gateway: [i32; 3], exact: bool },
|
||||
/// A known exit (exact, or findExitPosition around it).
|
||||
Known { exit: [i32; 3], exact: bool },
|
||||
}
|
||||
|
||||
impl GatewayJob {
|
||||
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
|
||||
fn for_gateway(
|
||||
gateway: [i32; 3],
|
||||
entity: Option<&BlockEntity>,
|
||||
legacy: Option<([i32; 3], bool)>,
|
||||
) -> Self {
|
||||
let configured = match entity {
|
||||
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
|
||||
_ => None,
|
||||
};
|
||||
match configured.or(legacy) {
|
||||
Some((exit, exact)) => Self::Known { exit, exact },
|
||||
// Worldgen return gateways (end_gateway_return) lead exactly to the End spawn.
|
||||
None if f64::from(gateway[0]).hypot(f64::from(gateway[2])) > 512.0 => Self::Known {
|
||||
exit: END_SPAWN,
|
||||
exact: true,
|
||||
},
|
||||
None => Self::Find {
|
||||
gateway,
|
||||
exact: exact_teleport(entity),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `TheEndGatewayBlockEntity.exactTeleport` of the block entity at a gateway
|
||||
/// (false without one).
|
||||
fn exact_teleport(entity: Option<&BlockEntity>) -> bool {
|
||||
matches!(entity, Some(BlockEntity::EndGateway(g)) if g.exact_teleport)
|
||||
}
|
||||
|
||||
/// Moves an exit that only the side file knew into the gateway's block entity, unless the block
|
||||
/// entity has its own; true when it took the saved one.
|
||||
fn adopt_saved_exit(entity: &mut EndGateway, (exit, exact): ([i32; 3], bool)) -> bool {
|
||||
if entity.exit_portal.is_some() {
|
||||
return false;
|
||||
}
|
||||
entity.exit_portal = Some(exit);
|
||||
entity.exact_teleport = exact;
|
||||
true
|
||||
}
|
||||
|
||||
/// The end of `getPortalPosition`: the exit itself when the teleport is exact,
|
||||
/// else the spot [`Game::resolve_gateway`] finds around it (`findExitPosition`).
|
||||
fn exit_target(exit: [i32; 3], exact: bool, find: impl FnOnce([i32; 3]) -> [i32; 3]) -> [i32; 3] {
|
||||
if exact { exit } else { find(exit) }
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct EndState {
|
||||
/// Gateway position -> (exit, exact teleport); loaded lazily from the save.
|
||||
@@ -247,6 +298,8 @@ impl Game<'_> {
|
||||
layer: 2,
|
||||
color,
|
||||
light: 0,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -606,7 +659,9 @@ impl Game<'_> {
|
||||
|
||||
/// EnderDragonFight without a dragon: once the centre of the main island
|
||||
/// is loaded, place the active exit portal (EndPodiumFeature) and the
|
||||
/// first gateway (spawnNewGateway) if the portal is not there yet.
|
||||
/// first gateway (spawnNewGateway) if the portal is not there yet. Like
|
||||
/// scanState and spawnExitPortal, only while the fight has no exit portal
|
||||
/// location: a podium that is later broken stays broken.
|
||||
fn ensure_exit_portal(&mut self) -> Result<()> {
|
||||
if self.travelling.is_some()
|
||||
|| !(-1..=0).all(|cz| (-1..=0).all(|cx| self.streaming.is_lit(cx, cz)))
|
||||
@@ -616,8 +671,14 @@ impl Game<'_> {
|
||||
}
|
||||
let won = self.fight.previously_killed;
|
||||
let gateway = self.first_gateway();
|
||||
if (0..256).any(|y| &*self.block_name([0, y, 0]) == "minecraft:bedrock") {
|
||||
// The podium exists; the gateway may have been out of range when it was placed.
|
||||
if self.exit_portal().is_none() {
|
||||
// findExitPortal remembers the location of a podium that is already there.
|
||||
if let Some(level) = self.podium_level() {
|
||||
self.set_exit_portal([0, level, 0]);
|
||||
}
|
||||
}
|
||||
if self.exit_portal().is_some() {
|
||||
// The gateway may have been out of range when it was placed.
|
||||
if won
|
||||
&& self.world.loaded(gateway[0], gateway[2])
|
||||
&& &*self.block_name(gateway) != "minecraft:end_gateway"
|
||||
@@ -648,17 +709,40 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The portal level of the podium around the centre column, read from the
|
||||
/// bedrock that EndPodiumFeature places (rim at the portal level, disc
|
||||
/// below it, the torch column) so a broken column does not hide it.
|
||||
fn podium_level(&self) -> Option<i32> {
|
||||
let mut offsets: Vec<[i32; 3]> = (0..4).map(|dy| [0, dy, 0]).collect();
|
||||
for z in -4..=4i32 {
|
||||
for x in -4..=4i32 {
|
||||
let flat = f64::from(x * x + z * z);
|
||||
if (6.25..12.25).contains(&flat) {
|
||||
offsets.push([x, 0, z]);
|
||||
} else if flat + 1.0 < 6.25 {
|
||||
offsets.push([x, -1, z]);
|
||||
}
|
||||
}
|
||||
}
|
||||
(1..253)
|
||||
.map(|y| {
|
||||
let score = offsets
|
||||
.iter()
|
||||
.filter(|o| &*self.block_name([o[0], y + o[1], o[2]]) == "minecraft:bedrock")
|
||||
.count();
|
||||
(score, y)
|
||||
})
|
||||
.filter(|&(score, _)| score >= 3)
|
||||
.max()
|
||||
.map(|(_, y)| y)
|
||||
}
|
||||
/// `spawnExitPortal(true)` after the dragon dies.
|
||||
pub(super) fn activate_exit_portal(&mut self) -> Result<()> {
|
||||
let origin = match self.exit_portal() {
|
||||
Some(o) => o,
|
||||
None => {
|
||||
// The rim's level: the top of the bedrock column minus three.
|
||||
let top = (0..256)
|
||||
.rev()
|
||||
.find(|&y| &*self.block_name([0, y, 0]) == "minecraft:bedrock")
|
||||
.unwrap_or(64);
|
||||
[0, top - 3, 0]
|
||||
// The rim's level, found without assuming the centre column is intact.
|
||||
[0, self.podium_level().unwrap_or(61), 0]
|
||||
}
|
||||
};
|
||||
self.place_podium(origin, true)
|
||||
@@ -761,6 +845,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.world
|
||||
.block_entities
|
||||
.get_or_insert_with((origin[0], origin[1], origin[2]), || {
|
||||
BlockEntity::EndGateway(Default::default())
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -782,8 +871,7 @@ impl Game<'_> {
|
||||
self.end.exits.as_mut().expect("loaded exits")
|
||||
}
|
||||
|
||||
fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> {
|
||||
self.gateway_exits().insert(gateway, (exit, exact));
|
||||
fn save_gateway_exits(&mut self) -> Result<()> {
|
||||
let list: Vec<_> = self
|
||||
.gateway_exits()
|
||||
.iter()
|
||||
@@ -795,6 +883,35 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drops a gateway's entry from the side file: its block entity is the source of truth.
|
||||
fn forget_gateway_exit(&mut self, gateway: [i32; 3]) -> Result<()> {
|
||||
if self.gateway_exits().remove(&gateway).is_some() {
|
||||
self.save_gateway_exits()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> {
|
||||
// TheEndGatewayBlockEntity.setExitPosition (also EndGatewayFeature.knownExit).
|
||||
let key = (gateway[0], gateway[1], gateway[2]);
|
||||
let chunk = (gateway[0] >> 4, gateway[2] >> 4);
|
||||
// The gateway's chunk can be unloaded by now (travel waits for the destination): the
|
||||
// exit then waits in the side file until the gateway is used again (`gateway_job`).
|
||||
if self.world.chunks.contains_key(&chunk)
|
||||
&& let BlockEntity::EndGateway(g) = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_or_insert_with(key, || BlockEntity::EndGateway(Default::default()))
|
||||
{
|
||||
g.exit_portal = Some(exit);
|
||||
g.exact_teleport = exact;
|
||||
self.world.modified.insert(chunk);
|
||||
return self.forget_gateway_exit(gateway);
|
||||
}
|
||||
self.gateway_exits().insert(gateway, (exit, exact));
|
||||
self.save_gateway_exits()
|
||||
}
|
||||
|
||||
/// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition:
|
||||
/// travel within the End, waiting for the destination chunks to load.
|
||||
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
|
||||
@@ -814,25 +931,39 @@ impl Game<'_> {
|
||||
/// its exit, or the search for one, and the spot to load before either
|
||||
/// is resolved ([`Game::resolve_gateway`]).
|
||||
pub(super) fn gateway_job(&mut self, gateway: [i32; 3]) -> (GatewayJob, Vec3) {
|
||||
let known = self.gateway_exits().get(&gateway).copied();
|
||||
let job = match known {
|
||||
Some((exit, exact)) => GatewayJob::Known { exit, exact },
|
||||
// Worldgen return gateways (end_gateway_return) lead exactly to the End spawn.
|
||||
None if f64::from(gateway[0]).hypot(f64::from(gateway[2])) > 512.0 => {
|
||||
GatewayJob::Known {
|
||||
exit: END_SPAWN,
|
||||
exact: true,
|
||||
}
|
||||
let mut known = self.gateway_exits().get(&gateway).copied();
|
||||
// Once the gateway's chunk is loaded its block entity is the source of truth: an exit the
|
||||
// side file kept for it moves there, and the entry is dropped, so removing or replacing
|
||||
// the exit (`/data remove block`) is not undone by an old entry.
|
||||
let key = (gateway[0], gateway[1], gateway[2]);
|
||||
let chunk = (gateway[0] >> 4, gateway[2] >> 4);
|
||||
if let Some(saved) = known
|
||||
&& self.world.chunks.contains_key(&chunk)
|
||||
&& let BlockEntity::EndGateway(g) = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_or_insert_with(key, || BlockEntity::EndGateway(Default::default()))
|
||||
{
|
||||
if adopt_saved_exit(g, saved) {
|
||||
self.world.modified.insert(chunk);
|
||||
}
|
||||
None => GatewayJob::Find { gateway },
|
||||
};
|
||||
known = None;
|
||||
let _ = self.forget_gateway_exit(gateway);
|
||||
}
|
||||
let job = GatewayJob::for_gateway(
|
||||
gateway,
|
||||
self.world
|
||||
.block_entities
|
||||
.get(&(gateway[0], gateway[1], gateway[2])),
|
||||
known,
|
||||
);
|
||||
let destination = match job {
|
||||
GatewayJob::Known { exit, .. } => Vec3::new(
|
||||
f64::from(exit[0]) + 0.5,
|
||||
f64::from(exit[1]),
|
||||
f64::from(exit[2]) + 0.5,
|
||||
),
|
||||
GatewayJob::Find { gateway } => {
|
||||
GatewayJob::Find { gateway, .. } => {
|
||||
let d = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
|
||||
.normalize_or_zero()
|
||||
* 1024.0;
|
||||
@@ -889,12 +1020,10 @@ impl Game<'_> {
|
||||
[p[0], p[1] + 1, p[2]]
|
||||
};
|
||||
match job {
|
||||
GatewayJob::Known { exit, exact } => Ok(bottom_center(if exact {
|
||||
exit
|
||||
} else {
|
||||
find_exit(self, exit)
|
||||
})),
|
||||
GatewayJob::Find { gateway } => {
|
||||
GatewayJob::Known { exit, exact } => Ok(bottom_center(exit_target(exit, exact, |e| {
|
||||
find_exit(self, e)
|
||||
}))),
|
||||
GatewayJob::Find { gateway, exact } => {
|
||||
// findExitPortalXZPosTentative over the loaded chunks.
|
||||
let dir = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
|
||||
.normalize_or_zero();
|
||||
@@ -954,8 +1083,11 @@ impl Game<'_> {
|
||||
let exit = [tallest[0], tallest[1] + 10, tallest[2]];
|
||||
self.place_gateway(exit)?;
|
||||
self.set_gateway_exit(exit, gateway, false)?;
|
||||
self.set_gateway_exit(gateway, exit, false)?;
|
||||
Ok(bottom_center(find_exit(self, exit)))
|
||||
// setExitPosition keeps the gateway's own exactTeleport.
|
||||
self.set_gateway_exit(gateway, exit, exact)?;
|
||||
Ok(bottom_center(exit_target(exit, exact, |e| {
|
||||
find_exit(self, e)
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1247,8 +1379,103 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: full,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::containers::EndGateway;
|
||||
|
||||
#[test]
|
||||
fn a_saved_exit_moves_into_a_gateway_without_one() {
|
||||
let saved = ([8, 100, 0], true);
|
||||
let mut bare = EndGateway::default();
|
||||
assert!(adopt_saved_exit(&mut bare, saved));
|
||||
assert_eq!(bare.exit_portal, Some([8, 100, 0]));
|
||||
assert!(bare.exact_teleport);
|
||||
// The block entity's own exit wins over a stale entry.
|
||||
let mut own = EndGateway {
|
||||
exit_portal: Some([1, 2, 3]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!adopt_saved_exit(&mut own, saved));
|
||||
assert_eq!(own.exit_portal, Some([1, 2, 3]));
|
||||
assert!(!own.exact_teleport);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_exit_keeps_the_gateways_exact_teleport() {
|
||||
let exact = BlockEntity::EndGateway(EndGateway {
|
||||
exact_teleport: true,
|
||||
..Default::default()
|
||||
});
|
||||
let plain = BlockEntity::EndGateway(EndGateway::default());
|
||||
assert!(exact_teleport(Some(&exact)));
|
||||
assert!(!exact_teleport(Some(&plain)));
|
||||
assert!(!exact_teleport(None));
|
||||
assert!(!exact_teleport(Some(&BlockEntity::DaylightDetector)));
|
||||
let exit = [8, 100, 0];
|
||||
// Exact: the exit itself, nothing is searched for.
|
||||
assert_eq!(exit_target(exit, true, |_| panic!("searched")), exit);
|
||||
assert_eq!(
|
||||
exit_target(exit, false, |e| [e[0], e[1] + 11, e[2]]),
|
||||
[8, 111, 0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_gateway_job_prefers_block_entity_then_legacy_then_heuristic() {
|
||||
let gateway = [702, 100, 0];
|
||||
let exit = [8, 100, 0];
|
||||
for exact in [true, false] {
|
||||
let entity = BlockEntity::EndGateway(EndGateway {
|
||||
exit_portal: Some(exit),
|
||||
exact_teleport: exact,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(gateway, Some(&entity), Some((END_SPAWN, !exact))),
|
||||
GatewayJob::Known { exit, exact },
|
||||
);
|
||||
}
|
||||
let empty = BlockEntity::EndGateway(EndGateway::default());
|
||||
for entity in [None, Some(&empty)] {
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(gateway, entity, Some((exit, false))),
|
||||
GatewayJob::Known { exit, exact: false }
|
||||
);
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(gateway, entity, None),
|
||||
GatewayJob::Known {
|
||||
exit: END_SPAWN,
|
||||
exact: true
|
||||
}
|
||||
);
|
||||
let nearby = [96, 75, 0];
|
||||
let exact = BlockEntity::EndGateway(EndGateway {
|
||||
exact_teleport: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(nearby, Some(&exact), None),
|
||||
GatewayJob::Find {
|
||||
gateway: nearby,
|
||||
exact: true
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(nearby, entity, None),
|
||||
GatewayJob::Find {
|
||||
gateway: nearby,
|
||||
exact: false
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,6 +307,12 @@ impl Game<'_> {
|
||||
)
|
||||
{
|
||||
outcome = o;
|
||||
// dealDefaultKnockback's `indicateDamage` points at the blast
|
||||
// (the source position), not the no-direction fallback.
|
||||
if o.knockback && o.blocked <= 0.0 {
|
||||
p.health
|
||||
.indicate_damage(center[0] - feet[0], center[2] - feet[2], p.yaw);
|
||||
}
|
||||
}
|
||||
if !spectator && !(creative && flying) {
|
||||
p.velocity = Vec3::new(
|
||||
@@ -394,7 +400,14 @@ impl Game<'_> {
|
||||
// it kills drop its charged-creeper loot (`Creeper.killedEntity`).
|
||||
let source = horizon_client::mobs::Damage {
|
||||
kind: horizon_client::mobs::DamageKind::Explosion,
|
||||
attacker: exploding.map(horizon_client::mobs::Who::Mob),
|
||||
// A projectile's owner (`getIndirectSourceEntity`), else
|
||||
// the exploding mob itself.
|
||||
attacker: self
|
||||
.combat
|
||||
.explosion_owner
|
||||
.or(exploding)
|
||||
.filter(|&o| o != m.id)
|
||||
.map(horizon_client::mobs::Who::Mob),
|
||||
from: Some(center),
|
||||
};
|
||||
if hurtable && !m.damage(damage(dist, exposure), source) {
|
||||
@@ -490,6 +503,15 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Runs an explosion whose source empties the hives it destroys
|
||||
/// (`BeehiveBlock.getDrops`: primed TNT, creeper, wither skull, wither,
|
||||
/// TNT minecart).
|
||||
pub(super) fn with_hive_emptying_blast<R>(&mut self, blast: impl FnOnce(&mut Self) -> R) -> R {
|
||||
self.combat.hive_emptying_blast = true;
|
||||
let result = blast(self);
|
||||
self.combat.hive_emptying_blast = false;
|
||||
result
|
||||
}
|
||||
/// `Level.explode` on the server: positions, entities, blocks, fire; then
|
||||
/// the client's explosion sound.
|
||||
pub(super) fn server_explode(
|
||||
@@ -606,6 +628,22 @@ impl Game<'_> {
|
||||
// Block.onExplosionHit: the drops copy from the block entity, which
|
||||
// then spills its contents as the block turns to air.
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let is_hive = matches!(&*name, "minecraft:beehive" | "minecraft:bee_nest");
|
||||
// BeehiveBlock.getDrops: the bees of a hive blown up by TNT, a
|
||||
// creeper, a wither skull, a wither or a TNT minecart come out.
|
||||
if is_hive && self.combat.hive_emptying_blast {
|
||||
let facing = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("facing").cloned());
|
||||
self.empty_hive(
|
||||
pos,
|
||||
horizon_client::beehive::facing_step(facing.as_deref()),
|
||||
horizon_client::beehive::Release::Emergency,
|
||||
);
|
||||
}
|
||||
if let Some(entity) = self.world.block_entities.get(&key).cloned() {
|
||||
let custom = self.world.block_names.get(&key).cloned();
|
||||
self.pending.record(pos, &entity, &name, custom);
|
||||
@@ -638,6 +676,10 @@ impl Game<'_> {
|
||||
}
|
||||
self.pending.clear(pos);
|
||||
self.level_set(pos, self.world.air)?;
|
||||
// BeehiveBlock.onExplosionHit: the bees about turn on the player.
|
||||
if is_hive {
|
||||
self.anger_nearby_bees(pos);
|
||||
}
|
||||
// TntBlock.wasExploded: new PrimedTnt (its push drawn first), then
|
||||
// getRandomShortFuse(80); no game event.
|
||||
if is_tnt && self.ui.rules.bool("tnt_explodes") {
|
||||
|
||||
@@ -62,11 +62,56 @@ impl Rocket {
|
||||
item,
|
||||
}
|
||||
}
|
||||
/// One tick of a server's client: the previous position, then `step`.
|
||||
pub(super) fn remote_step(&mut self, step: impl FnOnce(Vec3) -> Vec3) {
|
||||
/// One tick of a server's client: the rocket flies on by itself between
|
||||
/// the server's updates (a rocket that follows an entity is placed by the
|
||||
/// caller).
|
||||
pub(super) fn remote_step(&mut self, scene: &mut SceneCollisions) {
|
||||
self.previous = self.position;
|
||||
if self.attached_to.is_none() {
|
||||
self.free_flight(scene);
|
||||
}
|
||||
self.life += 1;
|
||||
self.position = step(self.position);
|
||||
}
|
||||
/// `FireworkRocketEntity.tick` for a rocket attached to no entity: speed
|
||||
/// up (an angled one keeps its motion) and rise, then move. True when a
|
||||
/// block stopped it.
|
||||
fn free_flight(&mut self, scene: &mut SceneCollisions) -> bool {
|
||||
let h = 0.125;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
|
||||
Vec3::new(
|
||||
self.position.x + h,
|
||||
self.position.y + 2.0 * h,
|
||||
self.position.z + h,
|
||||
),
|
||||
);
|
||||
let wanted = if self.angled {
|
||||
self.velocity
|
||||
} else {
|
||||
Vec3::new(
|
||||
self.velocity.x * 1.15,
|
||||
self.velocity.y + 0.04,
|
||||
self.velocity.z * 1.15,
|
||||
)
|
||||
};
|
||||
let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) else {
|
||||
return false;
|
||||
};
|
||||
let blocked = (moved.x - wanted.x).abs() > 1e-7
|
||||
|| (moved.y - wanted.y).abs() > 1e-7
|
||||
|| (moved.z - wanted.z).abs() > 1e-7;
|
||||
let horizontal = (moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7;
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
self.velocity = if horizontal {
|
||||
Vec3::new(self.velocity.x, wanted.y, self.velocity.z)
|
||||
} else {
|
||||
wanted
|
||||
};
|
||||
blocked
|
||||
}
|
||||
/// Position and motion (the dispenser parity fixture).
|
||||
pub(super) fn motion(&self) -> (Vec3, Vec3) {
|
||||
@@ -191,46 +236,14 @@ impl Game<'_> {
|
||||
r.velocity = p.velocity;
|
||||
}
|
||||
} else {
|
||||
// Not shot at an angle: accelerate (unless blocked sideways) and rise.
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let h = 0.125;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(r.position.x - h, r.position.y, r.position.z - h),
|
||||
Vec3::new(r.position.x + h, r.position.y + 2.0 * h, r.position.z + h),
|
||||
);
|
||||
let wanted = if r.angled {
|
||||
r.velocity
|
||||
} else {
|
||||
Vec3::new(
|
||||
r.velocity.x * 1.15,
|
||||
r.velocity.y + 0.04,
|
||||
r.velocity.z * 1.15,
|
||||
)
|
||||
};
|
||||
if let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) {
|
||||
let blocked = (moved.x - wanted.x).abs() > 1e-7
|
||||
|| (moved.y - wanted.y).abs() > 1e-7
|
||||
|| (moved.z - wanted.z).abs() > 1e-7;
|
||||
if r.angled && blocked {
|
||||
// hitTargetOrDeflectSelf: explode on the block.
|
||||
r.life = r.lifetime;
|
||||
}
|
||||
let horizontal =
|
||||
(moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7;
|
||||
r.position = Vec3::new(
|
||||
r.position.x + moved.x,
|
||||
r.position.y + moved.y,
|
||||
r.position.z + moved.z,
|
||||
);
|
||||
r.velocity = if horizontal {
|
||||
Vec3::new(r.velocity.x, wanted.y, r.velocity.z)
|
||||
} else {
|
||||
wanted
|
||||
};
|
||||
if r.free_flight(&mut scene) && r.angled {
|
||||
// hitTargetOrDeflectSelf: explode on the block.
|
||||
r.life = r.lifetime;
|
||||
}
|
||||
}
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
@@ -383,7 +396,12 @@ impl Game<'_> {
|
||||
/// lit at the rocket's light probe.
|
||||
pub(super) fn rocket_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
let orientation = glam::Mat4::from_quat(self.camera_orientation());
|
||||
for r in self.rockets.iter().filter(|r| !r.attached) {
|
||||
// shouldRender: not while attached to an entity (`isAttachedToEntity`).
|
||||
for r in self
|
||||
.rockets
|
||||
.iter()
|
||||
.filter(|r| !r.attached && r.attached_to.is_none())
|
||||
{
|
||||
// FireworkRocketEntity.shouldRenderAtSqrDistance: within 64 blocks;
|
||||
// EntityRenderer.shouldRender: its 0.25 box in the view.
|
||||
if !self.standing_entity_in_view(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! bobbing, lure and bite timers, open water, hooking mobs, retrieving the
|
||||
//! `gameplay/fishing` loot) and `FishingHookRenderer` (bobber and line).
|
||||
use super::{Game, view_vector};
|
||||
use horizon_client::advancements::EntityView;
|
||||
use horizon_client::lan::{entity::States, hub::Hub};
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
@@ -77,7 +78,7 @@ impl Game<'_> {
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let pitch = 0.4 / (self.world.random.next_float() * 0.4 + 0.8);
|
||||
if self.hook.is_some() {
|
||||
let damage = self.retrieve_hook();
|
||||
let damage = self.retrieve_hook(stack);
|
||||
self.damage_held(damage);
|
||||
self.audio.play(
|
||||
"minecraft:entity.fishing_bobber.retrieve",
|
||||
@@ -143,7 +144,7 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
/// `FishingHook.retrieve`: the rod damage it costs.
|
||||
fn retrieve_hook(&mut self) -> u32 {
|
||||
fn retrieve_hook(&mut self, rod: &ItemStack) -> u32 {
|
||||
let Some(hook) = self.hook.take() else {
|
||||
return 0;
|
||||
};
|
||||
@@ -167,6 +168,15 @@ impl Game<'_> {
|
||||
item.velocity = pulled(item.velocity);
|
||||
hooked_item = true;
|
||||
}
|
||||
// CriteriaTriggers.FISHING_ROD_HOOKED with no items.
|
||||
let hooked = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| self.mob_entity(m));
|
||||
if let Some(hooked) = hooked {
|
||||
self.trigger_fishing_rod_hooked(rod, &hooked, &[]);
|
||||
}
|
||||
// `hookedIn instanceof ItemEntity ? 3 : 5`.
|
||||
damage = if hooked_item { 3 } else { 5 };
|
||||
} else if hook.nibble > 0 {
|
||||
@@ -191,6 +201,7 @@ impl Game<'_> {
|
||||
Some(hook.open_water),
|
||||
);
|
||||
self.world.random = random;
|
||||
let mut caught = Vec::new();
|
||||
for stack in items {
|
||||
// FishingHook.retrieve: fish_caught for each catch in #minecraft:fishes.
|
||||
if self
|
||||
@@ -222,6 +233,7 @@ impl Game<'_> {
|
||||
if let Some(potion) = &stack.potion {
|
||||
item = item.with_potion(potion);
|
||||
}
|
||||
caught.push(item.clone());
|
||||
let (dx, dy, dz) = (
|
||||
owner.x - hook.position.x,
|
||||
owner.y - hook.position.y,
|
||||
@@ -241,6 +253,15 @@ impl Game<'_> {
|
||||
let xp = self.world.random.next_int_bound(6).unwrap_or(0) as u32 + 1;
|
||||
self.spawn_orbs([owner.x, owner.y + 0.5, owner.z + 0.5], xp);
|
||||
}
|
||||
// CriteriaTriggers.FISHING_ROD_HOOKED: the hook itself is the entity.
|
||||
let bobber = EntityView {
|
||||
kind: "minecraft:fishing_bobber".into(),
|
||||
position: [hook.position.x, hook.position.y, hook.position.z],
|
||||
on_ground: hook.on_ground,
|
||||
fishing_open_water: Some(hook.open_water),
|
||||
..Default::default()
|
||||
};
|
||||
self.trigger_fishing_rod_hooked(rod, &bobber, &caught);
|
||||
damage = 1;
|
||||
}
|
||||
if hook.on_ground {
|
||||
@@ -645,6 +666,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ impl Game<'_> {
|
||||
living: Some(HurtBob {
|
||||
hurt_time: p.health.hurt_animation_time as f32 - partial,
|
||||
hurt_duration: hurt.hurt_duration,
|
||||
hurt_dir: 0.0,
|
||||
hurt_dir: p.health.hurt_dir,
|
||||
death_time: self.death_time().map(|t| t as f32 + partial),
|
||||
}),
|
||||
player: Some((
|
||||
|
||||
@@ -378,6 +378,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Glowing entities (`EntityRenderer.extractRenderState`'s outlineColor): which entities
|
||||
//! appear glowing, in which team colour, and their silhouettes for the renderer's entity
|
||||
//! outline (`OutlineBufferSource`).
|
||||
use super::{Game, Vertex};
|
||||
use horizon_client::{mobs::Mob, scoreboard as sb};
|
||||
|
||||
/// `Entity.getTeamColor`: the team's colour, else white.
|
||||
pub(super) fn team_color(team: Option<&sb::Team>) -> u32 {
|
||||
team.and_then(|t| t.color.as_deref())
|
||||
.and_then(sb::team_color_rgb)
|
||||
.unwrap_or(0xFF_FFFF)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Entity.isCurrentlyGlowing` of an entity that is not living (an item, a projectile):
|
||||
/// its `Glowing` tag, or shared flag 6 as a server sent it.
|
||||
pub(super) fn entity_glowing(&self, id: u32) -> bool {
|
||||
self.remote_glowing(id).unwrap_or_else(|| {
|
||||
self.persist_raw(id)
|
||||
.and_then(|t| horizon_client::save::number(t.get("Glowing")))
|
||||
.is_some_and(|b| b != 0)
|
||||
})
|
||||
}
|
||||
|
||||
/// `Minecraft.shouldEntityAppearGlowing` of a mob or another player, with its outline
|
||||
/// colour (`getTeamColor`; another player's team goes by name).
|
||||
pub(super) fn mob_outline(&self, m: &Mob) -> Option<u32> {
|
||||
if !m.is_currently_glowing() {
|
||||
return None;
|
||||
}
|
||||
let team = if &*m.kind.id == "minecraft:player" {
|
||||
let names = self.remote_player_names();
|
||||
let name = names.into_iter().find(|(id, _)| *id == m.id);
|
||||
name.and_then(|(_, name)| self.score.board.players_team(&name))
|
||||
} else {
|
||||
self.mob_team(m.id)
|
||||
};
|
||||
Some(team_color(team))
|
||||
}
|
||||
|
||||
/// Adds one glowing entity's geometry to the outline pass in `rgb`, the textures'
|
||||
/// alpha kept (`OutlineBufferSource`'s team-coloured copy of each draw).
|
||||
pub(super) fn mark_outline(&self, vertices: &[Vertex], rgb: u32) {
|
||||
let color = horizon_client::mobs::render::abgr(rgb);
|
||||
self.glowing_vertices
|
||||
.borrow_mut()
|
||||
.extend(vertices.iter().map(|v| Vertex {
|
||||
color,
|
||||
normal: [0; 4],
|
||||
..*v
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `continueAttack`, `MultiPlayerGameMode` block breaking), the arm swing
|
||||
//! (`LivingEntity.SwingState`) and the first-person equip heights
|
||||
//! (`FirstPersonHandsAndItems`) with the item swap ticker they read.
|
||||
use super::{Game, Mining, Screen};
|
||||
use super::{Game, Mining, Screen, minecarts::CartKind};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
attack_strength::HeldStack,
|
||||
@@ -289,6 +289,64 @@ impl Game<'_> {
|
||||
// Only alive mobs are hit, so a picked living entity is alive.
|
||||
Some(true)
|
||||
}
|
||||
/// `Hud.canRenderCrosshairForSpectator` for the nearest hit in reach: an
|
||||
/// entity that is a `MenuProvider` (a chest or hopper minecart, a chest
|
||||
/// boat), or a block whose `showAsInteractableInSpectatorMode` holds.
|
||||
pub(super) fn spectator_crosshair(&self) -> bool {
|
||||
let cart = self.cart_hit().map(|(i, d)| {
|
||||
let kind = &self.carts[i].kind;
|
||||
(d, matches!(kind, CartKind::Chest | CartKind::Hopper))
|
||||
});
|
||||
let boat = self.boat_hit().map(|(i, d)| (d, self.boats[i].chest));
|
||||
let others = [
|
||||
self.mob_hit().map(|h| h.1),
|
||||
self.armor_stand_hit().map(|h| h.1),
|
||||
self.cushion_hit().map(|h| h.1),
|
||||
self.painting_hit().map(|h| h.1),
|
||||
self.frame_hit().map(|h| h.1),
|
||||
self.dragon_hit().map(|h| h.1),
|
||||
];
|
||||
let nearest = cart
|
||||
.into_iter()
|
||||
.chain(boat)
|
||||
.chain(others.into_iter().flatten().map(|d| (d, false)))
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0));
|
||||
if let Some((distance, menu)) = nearest
|
||||
&& self.before_block(distance)
|
||||
{
|
||||
return menu;
|
||||
}
|
||||
let Some(hit) = self.pick() else {
|
||||
return false;
|
||||
};
|
||||
let name = &*self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
|
||||
.name;
|
||||
let name = name.trim_start_matches("minecraft:");
|
||||
match name {
|
||||
// BlockBehaviour.getMenuProvider and `instanceof Portal`.
|
||||
"crafting_table" | "enchanting_table" | "anvil" | "chipped_anvil" | "damaged_anvil"
|
||||
| "stonecutter" | "loom" | "cartography_table" | "grindstone" | "smithing_table"
|
||||
| "barrel" | "furnace" | "blast_furnace" | "smoker" | "brewing_stand" | "hopper"
|
||||
| "dispenser" | "dropper" | "crafter" | "lectern" | "beacon" | "nether_portal"
|
||||
| "end_portal" | "end_gateway" => true,
|
||||
// ChestBlock.getMenuProvider: none while a redstone conductor
|
||||
// sits on top of either half (a sitting cat aside).
|
||||
"chest" | "trapped_chest" => {
|
||||
let key = (hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let blocked = |p: (i32, i32, i32)| {
|
||||
let above = self.world.block(p.0, p.1 + 1, p.2);
|
||||
self.res.behaviours().facts(above).flags
|
||||
& horizon_world::level::data::state_flags::CONDUCTOR
|
||||
!= 0
|
||||
};
|
||||
!blocked(key) && self.chest_other_half(key).is_none_or(|o| !blocked(o))
|
||||
}
|
||||
_ => name.ends_with("shulker_box"),
|
||||
}
|
||||
}
|
||||
/// `Attributes.ATTACK_SPEED` with the main-hand item's modifier.
|
||||
pub(super) fn main_hand_attack_speed(&self) -> f64 {
|
||||
horizon_client::attack_strength::BASE_ATTACK_SPEED
|
||||
|
||||
@@ -420,6 +420,8 @@ impl Game<'_> {
|
||||
s.flags.published.store(0, Ordering::Relaxed);
|
||||
}
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
// PlayerList.removeAll: the owner and every guest leave the game.
|
||||
host.award_leave_game_to_all();
|
||||
for g in &host.hub.guests {
|
||||
self.save_guest(&host, g);
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ pub(super) struct Host<'a> {
|
||||
banners: &'a horizon_client::banner::Banners,
|
||||
sprites: &'a HashMap<String, Rect>,
|
||||
skins: &'a RefCell<horizon_client::skins::Downloader>,
|
||||
profiles: &'a RefCell<horizon_client::skins::Resolver>,
|
||||
}
|
||||
impl SpecialHost for Host<'_> {
|
||||
fn banner_texture(&self, pattern: &str) -> Option<String> {
|
||||
@@ -144,14 +145,14 @@ impl SpecialHost for Host<'_> {
|
||||
fn player_skin(&self, profile: &Value) -> Option<String> {
|
||||
// SkullBlockRenderer.getPlayerSkin: SkinManager.getInsecureSkinInformation
|
||||
// accepts a texture property without a signature, unlike the tab list.
|
||||
let url = horizon_client::skins::profile_skin_url(profile)?;
|
||||
let location =
|
||||
horizon_client::skins::skin_location(&horizon_client::skins::texture_hash(&url)?);
|
||||
let _ = self
|
||||
.skins
|
||||
.borrow_mut()
|
||||
.state(horizon_client::skins::Kind::Skin, &url);
|
||||
self.sprites.contains_key(&location).then_some(location)
|
||||
// A `Dynamic` profile (a name or id only) resolves through the
|
||||
// services (`PlayerSkinRenderCache.getOrDefault`).
|
||||
Some(horizon_client::skins::head_skin(
|
||||
profile,
|
||||
&mut self.profiles.borrow_mut(),
|
||||
&mut self.skins.borrow_mut(),
|
||||
|location| self.sprites.contains_key(location),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +195,7 @@ impl Game<'_> {
|
||||
banners: &self.res.stations().banners,
|
||||
sprites: &self.gui.atlas.sprites,
|
||||
skins: &self.skin_downloads,
|
||||
profiles: &self.skin_profiles,
|
||||
}
|
||||
}
|
||||
/// The client level as item models read it: worked out once a frame (a
|
||||
@@ -296,8 +298,32 @@ impl Game<'_> {
|
||||
x,
|
||||
y,
|
||||
self.started.elapsed().as_secs_f32(),
|
||||
self.settings.file.double("glintSpeed") as f32,
|
||||
self.settings.file.double("glintStrength") as f32,
|
||||
);
|
||||
}
|
||||
/// The `GLINT_TEXTURING` inputs the atlas and options provide.
|
||||
pub(super) fn glint(&self) -> Option<item_render::Glint> {
|
||||
self.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.get("misc/enchanted_glint_item")
|
||||
.copied()
|
||||
.map(|_| item_render::Glint {
|
||||
speed: self.settings.file.double("glintSpeed") as f32,
|
||||
alpha: self.settings.file.double("glintStrength") as f32,
|
||||
millis: self.started.elapsed().as_secs_f32() * 1000.0,
|
||||
scale: 0.5,
|
||||
})
|
||||
}
|
||||
/// The glint scroll phase shared by world items and armour
|
||||
/// (`item_render::Glint::scroll_ticks`).
|
||||
pub(super) fn glint_ticks(&self) -> f32 {
|
||||
item_render::Glint::scroll_ticks(
|
||||
self.started.elapsed().as_secs_f32() * 1000.0,
|
||||
self.settings.file.double("glintSpeed") as f32,
|
||||
)
|
||||
}
|
||||
/// Submit a resolved item under `pose` (camera-relative) at `light`
|
||||
/// (packed lightmap coordinates).
|
||||
pub(super) fn submit_item_world(
|
||||
@@ -311,6 +337,7 @@ impl Game<'_> {
|
||||
out,
|
||||
light,
|
||||
lights: Some(diffuse_lights(self.dim == Dim::Nether)),
|
||||
glint: self.glint(),
|
||||
};
|
||||
state.submit(
|
||||
&self.pack.item_renderer,
|
||||
@@ -332,6 +359,7 @@ impl Game<'_> {
|
||||
out,
|
||||
light,
|
||||
lights: None,
|
||||
glint: self.glint(),
|
||||
};
|
||||
state.submit(
|
||||
&self.pack.item_renderer,
|
||||
@@ -405,7 +433,12 @@ impl Game<'_> {
|
||||
) {
|
||||
self.submit_item_world(&state, copy, vertex_light(packed), out);
|
||||
}
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
if self.entity_glowing(item.id) {
|
||||
let rgb = super::glowing::team_color(self.mob_team(item.id));
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
} else {
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,8 @@ impl Game<'_> {
|
||||
self.set_has_record(pos, true);
|
||||
self.start_song(pos, &song);
|
||||
self.jukebox_changed(pos);
|
||||
// ServerPlayerGameMode.useItemOn: the action was consumed.
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, stack);
|
||||
true
|
||||
}
|
||||
/// Level event 1010: the song from the jukebox, and the "Now Playing" overlay.
|
||||
|
||||
@@ -92,6 +92,33 @@ pub(super) struct LanHost {
|
||||
states: lan::entity::States,
|
||||
}
|
||||
|
||||
impl LanHost {
|
||||
/// `PlayerList.remove`: `player.awardStat(Stats.LEAVE_GAME)` before the
|
||||
/// player is saved. Once per guest, however often they are saved.
|
||||
pub(super) fn award_leave_game(&mut self, conn: u32) {
|
||||
if let Some(play) = self.play.get_mut(&conn)
|
||||
&& !play.left_game
|
||||
{
|
||||
play.left_game = true;
|
||||
play.stats.custom("leave_game", 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `PlayerList.removeAll`: every player who joined leaves with the world.
|
||||
pub(super) fn award_leave_game_to_all(&mut self) {
|
||||
let conns: Vec<u32> = self
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
self.award_leave_game(conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The player who owns an integrated server's world: a guest whose state is
|
||||
/// the world's own player (`players/data/<singleplayer_uuid>.dat`), as in
|
||||
/// single player.
|
||||
@@ -154,6 +181,136 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The variant and look entries of `mob_meta`'s mobs (registry holders, the
|
||||
/// fish's, the bee's, the copper golem's weathering...), in a match of their
|
||||
/// own: `mob_meta`'s arms hold these mobs' other entries.
|
||||
fn mob_variant_meta(
|
||||
m: &Mob,
|
||||
hub: &Hub,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
let (kind, e) = (m.kind.id.as_str(), &m.extra);
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
match kind {
|
||||
// Axolotl.DATA_VARIANT (after AgeableMob's two).
|
||||
"minecraft:axolotl" => {
|
||||
if e.variant != 0 {
|
||||
meta.push((18, serializer::INT, Value::Int(i32::from(e.variant))));
|
||||
}
|
||||
}
|
||||
// Frog.DATA_VARIANT_ID, Pig's, Cow's, Chicken's and ZombieNautilus's
|
||||
// (after the entries of their parents): registry holders.
|
||||
"minecraft:frog"
|
||||
| "minecraft:pig"
|
||||
| "minecraft:cow"
|
||||
| "minecraft:chicken"
|
||||
| "minecraft:zombie_nautilus" => {
|
||||
let (index, serializer, registry) = match kind {
|
||||
"minecraft:frog" => (18, serializer::FROG_VARIANT, "minecraft:frog_variant"),
|
||||
"minecraft:pig" => (19, serializer::PIG_VARIANT, "minecraft:pig_variant"),
|
||||
"minecraft:cow" => (18, serializer::COW_VARIANT, "minecraft:cow_variant"),
|
||||
"minecraft:chicken" => {
|
||||
(18, serializer::CHICKEN_VARIANT, "minecraft:chicken_variant")
|
||||
}
|
||||
_ => (
|
||||
21,
|
||||
serializer::ZOMBIE_NAUTILUS_VARIANT,
|
||||
"minecraft:zombie_nautilus_variant",
|
||||
),
|
||||
};
|
||||
// Each registry's default (`define(DATA_VARIANT_ID, ... TEMPERATE)`)
|
||||
// stays out of the pairing data, as other default values do.
|
||||
let name = horizon_client::mobs::finalize::variant_name(m.species(), e.variant);
|
||||
let name = name.trim_start_matches("minecraft:");
|
||||
if let Some(v) = id(registry, name)
|
||||
&& name != "temperate"
|
||||
{
|
||||
meta.push((index, serializer, Value::Id(v)));
|
||||
}
|
||||
if let Some(v) = id(registry, "temperate") {
|
||||
defaults.push((index, serializer, Value::Id(v)));
|
||||
}
|
||||
}
|
||||
// Rabbit.DATA_TYPE_ID (after AgeableMob's two) and
|
||||
// Parrot.DATA_VARIANT_ID (after TamableAnimal's two and its flags and
|
||||
// owner). The fox's type goes with its flags (#2538).
|
||||
"minecraft:rabbit" | "minecraft:parrot" => {
|
||||
let index = if kind == "minecraft:parrot" { 20 } else { 18 };
|
||||
if e.variant != 0 {
|
||||
meta.push((index, serializer::INT, Value::Int(i32::from(e.variant))));
|
||||
}
|
||||
}
|
||||
// Bee.DATA_FLAGS_ID (rolling 2, stung 4, nectar 8) and
|
||||
// DATA_ANGER_END_TIME: the texture shows the nectar and the anger.
|
||||
"minecraft:bee" => {
|
||||
let bee = horizon_client::mobs::bee::get(m);
|
||||
let flags = bee.map_or(0, |b| b.flags);
|
||||
if flags != 0 {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(flags as i8)));
|
||||
}
|
||||
defaults.push((18, serializer::BYTE, Value::Byte(0)));
|
||||
let anger_end = bee.map_or(-1, |b| b.anger_end);
|
||||
if anger_end != -1 {
|
||||
meta.push((19, serializer::LONG, Value::Long(anger_end)));
|
||||
}
|
||||
defaults.push((19, serializer::LONG, Value::Long(-1)));
|
||||
}
|
||||
// AbstractFish.FROM_BUCKET takes 16: Salmon.DATA_TYPE (small 0,
|
||||
// medium 1, large 2; medium is the data's default, so always sent),
|
||||
// Pufferfish.PUFF_STATE and TropicalFish.DATA_ID_TYPE_VARIANT: the
|
||||
// pattern's packed id (size, then index << 8), base colour << 16 and
|
||||
// pattern colour << 24.
|
||||
"minecraft:salmon" => {
|
||||
meta.push((17, serializer::INT, Value::Int(i32::from(e.variant.min(2)))));
|
||||
}
|
||||
"minecraft:pufferfish" => {
|
||||
if e.size > 0 {
|
||||
meta.push((17, serializer::INT, Value::Int(e.size)));
|
||||
}
|
||||
defaults.push((17, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
"minecraft:tropical_fish" => {
|
||||
let pattern = e.variant.min(11);
|
||||
let packed = i32::from(pattern >= 6)
|
||||
| i32::from(pattern % 6) << 8
|
||||
| i32::from(e.variant2 & 15) << 16
|
||||
| i32::from(e.variant3 & 15) << 24;
|
||||
if packed != 0 {
|
||||
meta.push((17, serializer::INT, Value::Int(packed)));
|
||||
}
|
||||
}
|
||||
// CopperGolem.DATA_WEATHER_STATE: unaffected 0 to oxidized 3, and it
|
||||
// goes back down when the golem is scraped.
|
||||
"minecraft:copper_golem" => {
|
||||
if e.variant != 0 {
|
||||
meta.push((
|
||||
16,
|
||||
serializer::WEATHERING_COPPER_STATE,
|
||||
Value::Id(i32::from(e.variant.min(3))),
|
||||
));
|
||||
}
|
||||
defaults.push((16, serializer::WEATHERING_COPPER_STATE, Value::Id(0)));
|
||||
}
|
||||
// Ghast.DATA_IS_CHARGING: the shooting texture.
|
||||
"minecraft:ghast" => {
|
||||
if e.flag {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// WitherBoss.DATA_ID_INV (after the three head targets): the
|
||||
// invulnerable ticks, the blinking texture and the growing scale.
|
||||
"minecraft:wither" => {
|
||||
if e.counter > 0 {
|
||||
meta.push((19, serializer::INT, Value::Int(e.counter)));
|
||||
}
|
||||
defaults.push((19, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A mob's synched data as ServerEntity sends it (only the values that differ
|
||||
/// from the defaults the client constructs), with vanilla's entry indices:
|
||||
/// Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses.
|
||||
@@ -162,12 +319,22 @@ fn mob_meta(
|
||||
m: &Mob,
|
||||
hub: &Hub,
|
||||
player_entity: Option<i32>,
|
||||
game_time: i64,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
let kind = m.kind.id.as_str();
|
||||
if m.fire_ticks > 0 {
|
||||
meta.push((0, serializer::BYTE, Value::Byte(1)));
|
||||
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, glowing 0x40
|
||||
// (LivingEntity.updateGlowingStatus).
|
||||
let shared = i8::from(m.fire_ticks > 0) | i8::from(m.is_currently_glowing()) << 6;
|
||||
if shared != 0 {
|
||||
meta.push((0, serializer::BYTE, Value::Byte(shared)));
|
||||
}
|
||||
// Entity.DATA_POSE: the pose the species keeps in its own fields (a
|
||||
// pose back at standing goes out by the entity's own default).
|
||||
let pose = horizon_client::mobs::sync::pose_id(m);
|
||||
if pose != 0 {
|
||||
meta.push((6, serializer::POSE, Value::Id(i32::from(pose))));
|
||||
}
|
||||
if let Some(name) = &m.custom_name {
|
||||
meta.push((
|
||||
@@ -221,6 +388,7 @@ fn mob_meta(
|
||||
}
|
||||
let e = &m.extra;
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
mob_variant_meta(m, hub, meta, defaults);
|
||||
match kind {
|
||||
"minecraft:guardian" | "minecraft:elder_guardian" => {
|
||||
// Guardian.DATA_ID_MOVING and DATA_ID_ATTACK_TARGET.
|
||||
@@ -277,6 +445,18 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((18, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// Fox.DATA_TYPE_ID and DATA_FLAGS_ID (after AgeableMob's two; sleeping 32, ...).
|
||||
"minecraft:fox" => {
|
||||
if e.variant != 0 {
|
||||
meta.push((18, serializer::INT, Value::Int(i32::from(e.variant))));
|
||||
}
|
||||
defaults.push((18, serializer::INT, Value::Int(0)));
|
||||
let flags = (e.ints[0] & 0xff) as u8 as i8;
|
||||
if flags != 0 {
|
||||
meta.push((19, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
defaults.push((19, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
"minecraft:cat" | "minecraft:wolf" => {
|
||||
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
|
||||
let flags = i8::from(e.sitting) | if e.tame { 4 } else { 0 };
|
||||
@@ -305,6 +485,14 @@ fn mob_meta(
|
||||
meta.push((collar, serializer::INT, Value::Int(i32::from(e.color & 15))));
|
||||
}
|
||||
defaults.push((collar, serializer::INT, Value::Int(red)));
|
||||
// Wolf.DATA_ANGER_END_TIME: the game time the anger ends, -1 when calm.
|
||||
if kind == "minecraft:wolf" {
|
||||
if e.anger > 0 {
|
||||
let end = game_time + i64::from(e.anger);
|
||||
meta.push((22, serializer::LONG, Value::Long(end)));
|
||||
}
|
||||
defaults.push((22, serializer::LONG, Value::Long(-1)));
|
||||
}
|
||||
}
|
||||
"minecraft:horse"
|
||||
| "minecraft:donkey"
|
||||
@@ -383,6 +571,29 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// Panda: UNHAPPY_COUNTER, SNEEZE_COUNTER and EAT_COUNTER (after
|
||||
// AgeableMob's two), MAIN_GENE_ID, HIDDEN_GENE_ID and DATA_ID_FLAGS;
|
||||
// the client animates the states from them.
|
||||
"minecraft:panda" => {
|
||||
let (flags, counters) = horizon_client::mobs::panda::get(m)
|
||||
.map_or((0, [0; 3]), |s| (s.flags, [s.unhappy, s.sneeze, s.eat]));
|
||||
for (index, value) in (18..).zip(counters) {
|
||||
if value != 0 {
|
||||
meta.push((index, serializer::INT, Value::Int(value)));
|
||||
}
|
||||
defaults.push((index, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
for (index, gene) in [(21, e.variant), (22, e.variant2)] {
|
||||
if gene != 0 {
|
||||
meta.push((index, serializer::BYTE, Value::Byte(gene as i8)));
|
||||
}
|
||||
defaults.push((index, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
if flags != 0 {
|
||||
meta.push((23, serializer::BYTE, Value::Byte(flags as i8)));
|
||||
}
|
||||
defaults.push((23, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// PolarBear.DATA_STANDING_ID (after AgeableMob's two): the bear
|
||||
// rears up; the client animates it.
|
||||
"minecraft:polar_bear" => {
|
||||
@@ -398,6 +609,121 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((17, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// Warden.CLIENT_ANGER_LEVEL: the heartbeat's pace.
|
||||
"minecraft:warden" => {
|
||||
let anger = e.warden.as_ref().map_or(0, |w| w.client_anger);
|
||||
if anger != 0 {
|
||||
meta.push((16, serializer::INT, Value::Int(anger)));
|
||||
}
|
||||
defaults.push((16, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
// Sniffer.DATA_STATE (after AgeableMob's two).
|
||||
"minecraft:sniffer" => {
|
||||
if e.variant != 0 {
|
||||
meta.push((
|
||||
18,
|
||||
serializer::SNIFFER_STATE,
|
||||
Value::Id(i32::from(e.variant)),
|
||||
));
|
||||
}
|
||||
defaults.push((18, serializer::SNIFFER_STATE, Value::Id(0)));
|
||||
}
|
||||
// Shulker.DATA_ATTACH_FACE_ID, DATA_PEEK_ID and DATA_COLOR_ID (16
|
||||
// for none).
|
||||
"minecraft:shulker" => {
|
||||
if e.variant3 != 0 {
|
||||
meta.push((16, serializer::DIRECTION, Value::Id(i32::from(e.variant3))));
|
||||
}
|
||||
defaults.push((16, serializer::DIRECTION, Value::Id(0)));
|
||||
if e.counter != 0 {
|
||||
meta.push((17, serializer::BYTE, Value::Byte(e.counter as i8)));
|
||||
}
|
||||
defaults.push((17, serializer::BYTE, Value::Byte(0)));
|
||||
if e.color != 16 {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(e.color as i8)));
|
||||
}
|
||||
defaults.push((18, serializer::BYTE, Value::Byte(16)));
|
||||
}
|
||||
// Vex.DATA_FLAGS_ID: charging in bit 0 (the arms and the glowing
|
||||
// texture).
|
||||
"minecraft:vex" => {
|
||||
if e.flag {
|
||||
meta.push((16, serializer::BYTE, Value::Byte(1)));
|
||||
}
|
||||
defaults.push((16, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// Creaking.CAN_MOVE, IS_ACTIVE and IS_TEARING_DOWN.
|
||||
"minecraft:creaking" => {
|
||||
if e.flag2 {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
if e.flag {
|
||||
meta.push((17, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
|
||||
if horizon_client::mobs::creaking::tearing_down(m) {
|
||||
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// Allay.DATA_DANCING and DATA_CAN_DUPLICATE (the held item is its
|
||||
// main hand equipment).
|
||||
"minecraft:allay" => {
|
||||
if e.flag {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
if e.egg_time > 0 {
|
||||
meta.push((17, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
defaults.push((17, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
// Axolotl.DATA_PLAYING_DEAD (after DATA_VARIANT).
|
||||
"minecraft:axolotl" => {
|
||||
if horizon_client::mobs::axolotl::is_playing_dead(m) {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// CopperGolem.COPPER_GOLEM_STATE (after DATA_WEATHER_STATE).
|
||||
"minecraft:copper_golem" => {
|
||||
if e.variant3 != 0 {
|
||||
meta.push((
|
||||
17,
|
||||
serializer::COPPER_GOLEM_STATE,
|
||||
Value::Id(i32::from(e.variant3)),
|
||||
));
|
||||
}
|
||||
defaults.push((17, serializer::COPPER_GOLEM_STATE, Value::Id(0)));
|
||||
}
|
||||
// Armadillo.ARMADILLO_STATE (after AgeableMob's two).
|
||||
"minecraft:armadillo" => {
|
||||
let state = horizon_client::mobs::armadillo::state(m);
|
||||
if state != 0 {
|
||||
meta.push((18, serializer::ARMADILLO_STATE, Value::Id(i32::from(state))));
|
||||
}
|
||||
defaults.push((18, serializer::ARMADILLO_STATE, Value::Id(0)));
|
||||
}
|
||||
// Frog.DATA_TONGUE_TARGET_ID (after the variant): the entity id plus
|
||||
// one, 0 for none.
|
||||
"minecraft:frog" => {
|
||||
if let Some(target) = e.frog.tongue_target {
|
||||
let id = i32::try_from(target).map_or(0, |t| t + 1);
|
||||
meta.push((19, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(id)));
|
||||
}
|
||||
defaults.push((19, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(0)));
|
||||
}
|
||||
// Goat.DATA_HAS_LEFT_HORN and DATA_HAS_RIGHT_HORN (after
|
||||
// DATA_IS_SCREAMING_GOAT): both true by default.
|
||||
"minecraft:goat" => {
|
||||
for (index, lost) in [(19, e.lost_horns[0]), (20, e.lost_horns[1])] {
|
||||
if lost {
|
||||
meta.push((index, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
defaults.push((index, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// Enderman.DATA_CARRY_STATE.
|
||||
"minecraft:enderman" => {
|
||||
if let Some(state) = e.carried {
|
||||
@@ -410,6 +736,18 @@ fn mob_meta(
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Camel.DASH and LAST_POSE_CHANGE_TICK (after AbstractHorse's flags).
|
||||
if horizon_client::mobs::camel::is_camel(m.species()) {
|
||||
if e.flag2 {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
|
||||
let tick = horizon_client::mobs::camel::synced_pose_tick(m, game_time);
|
||||
if tick != 0 {
|
||||
meta.push((20, serializer::LONG, Value::Long(tick)));
|
||||
}
|
||||
defaults.push((20, serializer::LONG, Value::Long(0)));
|
||||
}
|
||||
}
|
||||
|
||||
/// What a client shakes a mob by: Entity.DATA_TICKS_FROZEN (7), whose full
|
||||
@@ -728,6 +1066,7 @@ impl Game<'_> {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
host.award_leave_game_to_all();
|
||||
for g in &host.hub.guests {
|
||||
self.save_guest(&host, g);
|
||||
}
|
||||
@@ -935,7 +1274,7 @@ impl Game<'_> {
|
||||
out.clear();
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
// The lead's holder: the player the mob sees (a guest, else the
|
||||
// host) or another mob. A fence knot isn't an entity here.
|
||||
// host), another mob or a fence knot (game/lan_entities.rs).
|
||||
let player_entity = mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
@@ -944,7 +1283,7 @@ impl Game<'_> {
|
||||
let leash = m.extra.leash.and_then(|h| match h {
|
||||
Holder::Player => player_entity,
|
||||
Holder::Mob(id) => Some(id as i32),
|
||||
Holder::Knot(_) => None,
|
||||
Holder::Knot(p) => self.knot_ids.get(&p).map(|&id| id as i32),
|
||||
});
|
||||
let e = out.add(m.id as i32, entity_uuid(m.id), m.kind.id.as_str());
|
||||
e.position = [m.position.x, m.position.y, m.position.z];
|
||||
@@ -952,7 +1291,14 @@ impl Game<'_> {
|
||||
e.on_ground = m.on_ground;
|
||||
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
|
||||
e.needs_sync = m.needs_sync;
|
||||
mob_meta(m, hub, player_entity, &mut e.meta, &mut e.defaults);
|
||||
mob_meta(
|
||||
m,
|
||||
hub,
|
||||
player_entity,
|
||||
self.world.game_time,
|
||||
&mut e.meta,
|
||||
&mut e.defaults,
|
||||
);
|
||||
mob_freeze_meta(m, &mut e.meta, &mut e.defaults);
|
||||
mob_equipment(m, hub, &mut e.equipment);
|
||||
self.mob_attributes(m, &mut e.attributes);
|
||||
@@ -964,6 +1310,10 @@ impl Game<'_> {
|
||||
e.position = [d.position.x, d.position.y, d.position.z];
|
||||
e.on_ground = d.on_ground;
|
||||
e.velocity = [d.velocity.x, d.velocity.y, d.velocity.z];
|
||||
// Entity.DATA_SHARED_FLAGS_ID: glowing 0x40 (the Glowing tag).
|
||||
if self.entity_glowing(d.id) {
|
||||
e.meta.push((0, serializer::BYTE, Value::Byte(0x40)));
|
||||
}
|
||||
e.meta.push((8, serializer::ITEM_STACK, Value::Item(stack)));
|
||||
}
|
||||
for o in &self.orbs {
|
||||
@@ -1073,6 +1423,8 @@ impl Game<'_> {
|
||||
self.ui.rules.bool("player_movement_check"),
|
||||
self.ui.rules.bool("elytra_movement_check"),
|
||||
);
|
||||
host.hub
|
||||
.set_immediate_respawn(self.ui.rules.bool("immediate_respawn"));
|
||||
// A server with several levels runs a pass for each
|
||||
// (`tick_server`): the network is read once, before the first.
|
||||
let pass = self.level_pass;
|
||||
@@ -1143,6 +1495,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.lan_crafting_left(&mut host, &mut guest);
|
||||
if guest.placed {
|
||||
host.award_leave_game(guest.conn);
|
||||
}
|
||||
self.save_guest(&host, &guest);
|
||||
host.models.remove(&guest.conn);
|
||||
host.play.remove(&guest.conn);
|
||||
@@ -1620,7 +1975,12 @@ impl Game<'_> {
|
||||
out,
|
||||
);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
if !m.glowing {
|
||||
// LivingEntity.isCurrentlyGlowing: the guest's Glowing effect.
|
||||
let guest = host.hub.guest(*conn);
|
||||
if m.glowing || guest.is_some_and(|g| g.effects.get("glowing").is_some()) {
|
||||
let team = guest.and_then(|g| self.score.board.players_team(&g.name));
|
||||
self.mark_outline(&out[entity_start..], super::glowing::team_color(team));
|
||||
} else {
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,12 @@ pub(super) struct GuestPlay {
|
||||
potions: super::potions::BodyPotions,
|
||||
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
|
||||
pub(super) stats: super::stats::Stats,
|
||||
/// `Stats.LEAVE_GAME` is counted: a guest is saved more than once on the
|
||||
/// way out, the statistic is awarded once (`LanHost::award_leave_game`).
|
||||
pub(super) left_game: bool,
|
||||
last_inventory: u64,
|
||||
/// `ServerPlayer.levitationStartPos` and `levitationStartTime`.
|
||||
levitation: Option<([f64; 3], u64)>,
|
||||
/// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs).
|
||||
pub(super) portal: horizon_client::nether_portal::PortalState,
|
||||
/// `ServerPlayer.wonGame`: the End poem is showing, the player waits for
|
||||
@@ -158,6 +163,7 @@ pub(super) struct GuestSwap {
|
||||
host_ender: Option<(i32, i32, i32)>,
|
||||
adv_loaded: bool,
|
||||
last_inventory: u64,
|
||||
levitation: Option<([f64; 3], u64)>,
|
||||
death_cause: Option<String>,
|
||||
/// The guest's own damage record, in place while they act.
|
||||
combat: super::combat::CombatTracker,
|
||||
@@ -386,6 +392,7 @@ impl Game<'_> {
|
||||
std::mem::swap(&mut self.hook, &mut play.hook);
|
||||
let adv_loaded = std::mem::replace(&mut self.adv.loaded, true);
|
||||
let last_inventory = std::mem::replace(&mut self.adv.last_inventory, play.last_inventory);
|
||||
let levitation = std::mem::replace(&mut self.adv.levitation, play.levitation);
|
||||
let death_cause = std::mem::replace(&mut self.ui.death_cause, play.death_cause.take());
|
||||
let acting = (
|
||||
self.lan_acting.replace(conn),
|
||||
@@ -457,6 +464,7 @@ impl Game<'_> {
|
||||
host_ender,
|
||||
adv_loaded,
|
||||
last_inventory,
|
||||
levitation,
|
||||
death_cause,
|
||||
acting,
|
||||
packets,
|
||||
@@ -516,6 +524,7 @@ impl Game<'_> {
|
||||
host_ender,
|
||||
adv_loaded,
|
||||
last_inventory,
|
||||
levitation,
|
||||
death_cause,
|
||||
combat,
|
||||
acting,
|
||||
@@ -638,6 +647,7 @@ impl Game<'_> {
|
||||
std::mem::swap(&mut self.hook, &mut play.hook);
|
||||
self.adv.loaded = adv_loaded;
|
||||
play.last_inventory = std::mem::replace(&mut self.adv.last_inventory, last_inventory);
|
||||
play.levitation = std::mem::replace(&mut self.adv.levitation, levitation);
|
||||
let stat_events = std::mem::take(&mut play.stats.events);
|
||||
let name = g.name.clone();
|
||||
self.potions.swap_player(&mut g.effects, &mut play.potions);
|
||||
@@ -681,6 +691,13 @@ impl Game<'_> {
|
||||
g.sprinting = false;
|
||||
}
|
||||
m.sprinting = false;
|
||||
// ServerPlayer.indicateDamage: the hit's direction to the player.
|
||||
if let Some(yaw) = m.health.indicated_dir.take() {
|
||||
g.send(&C::HurtAnimation(play::HurtAnimation {
|
||||
id: g.entity_id,
|
||||
yaw,
|
||||
}));
|
||||
}
|
||||
let teleported = std::mem::take(&mut m.teleported);
|
||||
let moved: f64 = (0..3).map(|k| (at[k] - g.position[k]).powi(2)).sum();
|
||||
g.motion = Some(Box::new(m));
|
||||
@@ -882,13 +899,17 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// The guest's open container is closing (`AbstractContainerMenu.removed`):
|
||||
/// it no longer counts among the openers of the lid.
|
||||
/// it no longer counts among the openers of the lid, and the lid's close
|
||||
/// sound is played for everyone around (`ChestBlockEntity.onClose`, a
|
||||
/// `playSound` with no player), the guest included, once the last opener
|
||||
/// has left.
|
||||
pub(super) fn guest_container_closed(&mut self, host: &mut LanHost, conn: u32) {
|
||||
if let Some(g) = host.hub.guest(conn)
|
||||
&& let Some(pos) = g.menu.as_ref().and_then(|m| m.pos)
|
||||
&& !g.spectator()
|
||||
&& self.container_openers_changed(pos, false)
|
||||
{
|
||||
self.container_openers_changed(pos, false);
|
||||
self.container_edge_sound(pos, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -982,6 +1003,20 @@ impl Game<'_> {
|
||||
// The pause between two blocks is the client's (MultiPlayerGameMode's
|
||||
// destroyDelay); the server digs from the START it receives.
|
||||
play.destroy_delay = 0;
|
||||
// ServerPlayerGameMode.handleBlockBreakAction: a survival START
|
||||
// attacks the block at once, so a tap released in the same tick
|
||||
// still plays a note. The dig is marked begun for tick_mining.
|
||||
let pos = a.pos;
|
||||
self.as_guest(host, conn, |g| {
|
||||
if g.may_build() && !g.creative {
|
||||
let _ = g.attack_block(pos);
|
||||
g.mining = Some(Mining {
|
||||
pos,
|
||||
progress: 0.0,
|
||||
ticks: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
1 => {}
|
||||
3 => {
|
||||
|
||||
@@ -127,9 +127,18 @@ impl Game<'_> {
|
||||
}
|
||||
let dropped = std::mem::take(&mut menu.dropped);
|
||||
let equipped = std::mem::take(&mut menu.equipped);
|
||||
let brewed = std::mem::take(&mut menu.brewed);
|
||||
menu.drops.clear();
|
||||
menu.crafted.clear();
|
||||
store_player(menu, &mut host.hub.guests[gi]);
|
||||
if !brewed.is_empty() {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
self.as_guest(host, conn, |g| {
|
||||
for stack in &brewed {
|
||||
g.trigger_brewed_potion(stack);
|
||||
}
|
||||
});
|
||||
}
|
||||
if !equipped.is_empty() {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
self.as_guest(host, conn, |g| {
|
||||
@@ -576,16 +585,18 @@ impl Game<'_> {
|
||||
/// A guest left: what their crafting grids and workstation held goes back into their
|
||||
/// inventory before it is saved (dropped where they stood if full).
|
||||
pub(super) fn lan_crafting_left(&mut self, host: &mut LanHost, g: &mut Guest) {
|
||||
let play = host.play.remove(&g.conn);
|
||||
// The guest's play state stays: their statistics are awarded and saved
|
||||
// after this (`HostEvent::Left`), which removes it.
|
||||
let play = host.play.get_mut(&g.conn);
|
||||
let mut grid: Vec<ItemStack> = g.craft_grid.iter_mut().filter_map(Option::take).collect();
|
||||
g.craft_result = None;
|
||||
if let Some(play) = play {
|
||||
if let Some((_, _, menu, _)) = play.crafting.table {
|
||||
if let Some((_, _, menu, _)) = play.crafting.table.take() {
|
||||
grid.extend((1..10).filter_map(|s| menu.slot(s).cloned()));
|
||||
}
|
||||
// A workstation's inputs too.
|
||||
if let Some(station) = &play.station {
|
||||
grid.extend(super::lan_stations::station_inputs(station));
|
||||
if let Some(station) = play.station.take() {
|
||||
grid.extend(super::lan_stations::station_inputs(&station));
|
||||
}
|
||||
}
|
||||
if grid.is_empty() {
|
||||
|
||||
@@ -11,18 +11,6 @@ use horizon_client::lan::{
|
||||
use horizon_protocol::entity_data::{Value, serializer};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `ArmorStand`'s default poses (head, body, left arm, right arm, left leg,
|
||||
/// right leg), DATA_HEAD_POSE to DATA_RIGHT_LEG_POSE (entries 16 to 21),
|
||||
/// under their `Pose` keys.
|
||||
const STAND_POSES: [(&str, [f32; 3]); 6] = [
|
||||
("Head", [0.0, 0.0, 0.0]),
|
||||
("Body", [0.0, 0.0, 0.0]),
|
||||
("LeftArm", [-10.0, 0.0, -10.0]),
|
||||
("RightArm", [-15.0, 0.0, 10.0]),
|
||||
("LeftLeg", [-1.0, 0.0, -1.0]),
|
||||
("RightLeg", [1.0, 0.0, 1.0]),
|
||||
];
|
||||
|
||||
fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) -> EntityState {
|
||||
EntityState {
|
||||
id: id as i32,
|
||||
@@ -61,28 +49,21 @@ fn vehicle_meta(hurt_time: i32, hurt_dir: i32, damage: f32) -> Vec<(u8, i32, Val
|
||||
meta
|
||||
}
|
||||
|
||||
/// An armor stand's pose from its compound (`ArmorStandPose`), each part
|
||||
/// that differs from the default.
|
||||
fn stand_poses(raw: Option<&Tag>) -> Vec<(u8, i32, Value)> {
|
||||
let Some(pose) = raw.and_then(|t| t.get("Pose")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
/// An armor stand's pose (`ArmorStandPose`), each part that differs from the
|
||||
/// default, as DATA_HEAD_POSE to DATA_RIGHT_LEG_POSE (entries 16 to 21).
|
||||
fn stand_poses(a: &horizon_client::entities::ArmorStand) -> Vec<(u8, i32, Value)> {
|
||||
let mut meta = Vec::new();
|
||||
for (i, (key, default)) in STAND_POSES.iter().enumerate() {
|
||||
let Some(Tag::List(values)) = pose.get(key) else {
|
||||
continue;
|
||||
};
|
||||
let mut r = *default;
|
||||
for (k, v) in values.iter().take(3).enumerate() {
|
||||
if let Tag::Float(bits) = v {
|
||||
r[k] = f32::from_bits(*bits);
|
||||
}
|
||||
}
|
||||
if r != *default {
|
||||
for (i, (rotation, default)) in a
|
||||
.pose
|
||||
.iter()
|
||||
.zip(horizon_client::entities::ArmorStand::DEFAULT_POSE)
|
||||
.enumerate()
|
||||
{
|
||||
if *rotation != default {
|
||||
meta.push((
|
||||
16 + i as u8,
|
||||
serializer::ROTATIONS,
|
||||
Value::Floats(r.to_vec()),
|
||||
Value::Floats(rotation.to_vec()),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -185,7 +166,19 @@ impl Game<'_> {
|
||||
if flags != 0 {
|
||||
e.meta.push((15, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
e.meta.extend(stand_poses(self.persist.raw.get(&a.id)));
|
||||
e.meta.extend(stand_poses(a));
|
||||
// A pose put back to its default (`/data`) is sent as that default.
|
||||
e.defaults.push((15, serializer::BYTE, Value::Byte(0)));
|
||||
for (i, default) in horizon_client::entities::ArmorStand::DEFAULT_POSE
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
e.defaults.push((
|
||||
16 + i as u8,
|
||||
serializer::ROTATIONS,
|
||||
Value::Floats(default.to_vec()),
|
||||
));
|
||||
}
|
||||
// Stored main hand, off hand, feet, legs, chest, head: the wire's slots 0 to 5.
|
||||
for (slot, item) in a.equipment.iter().enumerate() {
|
||||
if item.is_some() {
|
||||
@@ -225,6 +218,16 @@ impl Game<'_> {
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
for (p, &id) in &self.knot_ids {
|
||||
// LeashFenceKnotEntity.getAddEntityPacket: the fence's block.
|
||||
out.push(state(
|
||||
id,
|
||||
"minecraft:leash_knot",
|
||||
p.map(f64::from),
|
||||
0.0,
|
||||
false,
|
||||
));
|
||||
}
|
||||
for p in &self.paintings {
|
||||
let mut e = state(p.id, "minecraft:painting", p.pos.map(f64::from), 0.0, false);
|
||||
// Direction.get2DDataValue (south 0, west 1, north 2, east 3) to its
|
||||
@@ -270,6 +273,24 @@ impl Game<'_> {
|
||||
/// Gives the entities Horizon keeps without an id one (`Entity.id`, from
|
||||
/// the same counter as the rest) before they are first tracked.
|
||||
pub(super) fn lan_assign_ids(&mut self) {
|
||||
// LeashFenceKnotEntity: one per fence post with a lead on it
|
||||
// (`notifyLeasheeRemoved` discards it once the last lead is gone).
|
||||
let knots: Vec<[i32; 3]> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| !m.removed)
|
||||
.filter_map(|m| match m.extra.leash {
|
||||
Some(horizon_client::mobs::leash::Holder::Knot(p)) => Some(p),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
self.knot_ids.retain(|p, _| knots.contains(p));
|
||||
for p in knots {
|
||||
self.knot_ids.entry(p).or_insert_with(|| {
|
||||
self.next_entity += 1;
|
||||
self.next_entity
|
||||
});
|
||||
}
|
||||
let next = &mut self.next_entity;
|
||||
let mut give = |id: &mut u32| {
|
||||
if *id == 0 {
|
||||
@@ -532,11 +553,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn armor_stand_poses_send_only_what_differs() {
|
||||
let tag =
|
||||
horizon_world::nbt::parse_snbt("{Pose:{Head:[10f,20f,0f],LeftArm:[-10f,0f,-10f]}}")
|
||||
.unwrap()
|
||||
.0;
|
||||
let meta = stand_poses(Some(&tag));
|
||||
let mut stand = horizon_client::entities::ArmorStand::new(1, Default::default(), 0.0);
|
||||
stand.pose[0] = [10.0, 20.0, 0.0];
|
||||
let meta = stand_poses(&stand);
|
||||
assert_eq!(
|
||||
meta,
|
||||
vec![(
|
||||
@@ -545,6 +564,7 @@ mod tests {
|
||||
Value::Floats(vec![10.0, 20.0, 0.0])
|
||||
)]
|
||||
);
|
||||
assert!(stand_poses(None).is_empty());
|
||||
let plain = horizon_client::entities::ArmorStand::new(1, Default::default(), 0.0);
|
||||
assert!(stand_poses(&plain).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
//! (hits, pushes, fire, its projectiles) runs with that guest swapped in.
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
mobs::{Mob, MobEvent, PlayerView, Who},
|
||||
mobs::{Mob, MobEvent, PlayerView, Species, Who},
|
||||
projectiles::{Hit, Projectile},
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
@@ -28,6 +28,22 @@ pub(super) fn player_for(
|
||||
) -> Option<u32> {
|
||||
let targeting = m.ai.target == Some(Who::Player)
|
||||
|| m.ai.last_hurt_by == Some(Who::Player) && m.age as i32 - m.ai.last_hurt_by_time <= 100;
|
||||
// A tame wolf or cat sees its owner (`TamableAnimal.getOwner`), while they
|
||||
// are in play, whoever is nearer. Not while it targets or was just hurt by
|
||||
// a player: then `Who::Player` is that attacker, whoever it is, and the
|
||||
// owner goals ignore it until the wolf lets go.
|
||||
if !targeting
|
||||
&& matches!(m.species(), Species::Wolf | Species::Cat)
|
||||
&& m.extra.tame
|
||||
&& let Some(owner) = m.extra.owner
|
||||
{
|
||||
if host.is_some_and(|v| v.alive && v.uuid == owner) {
|
||||
return None;
|
||||
}
|
||||
if let Some((conn, _)) = guests.iter().find(|(_, v)| v.alive && v.uuid == owner) {
|
||||
return Some(*conn);
|
||||
}
|
||||
}
|
||||
choose(m.position, targeting, host, guests, bound)
|
||||
}
|
||||
|
||||
@@ -70,6 +86,10 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
|
||||
target: Who::Player,
|
||||
..
|
||||
}
|
||||
| MobEvent::Knockback {
|
||||
target: Who::Player,
|
||||
..
|
||||
}
|
||||
| MobEvent::Ignite {
|
||||
target: Who::Player,
|
||||
..
|
||||
@@ -358,12 +378,15 @@ mod tests {
|
||||
off_hand: None,
|
||||
head: None,
|
||||
armor_cover: 0.0,
|
||||
invisible: false,
|
||||
spectator: false,
|
||||
gold_armor: false,
|
||||
hurt_by: None,
|
||||
last_hurt: None,
|
||||
velocity: Vec3::ZERO,
|
||||
yaw: 0.0,
|
||||
sprinting: false,
|
||||
riding: false,
|
||||
hero: false,
|
||||
effects: Vec::new(),
|
||||
uuid: [0; 4],
|
||||
|
||||
@@ -247,14 +247,10 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let (d, s) = (p.dist.map(f64::from), p.max_speed.map(f64::from));
|
||||
// ClientLevel.addParticle: the camera's 32 blocks unless the packet
|
||||
// (`force`) or the type overrides the limiter.
|
||||
// ClientLevel.addParticle: the camera's 32 blocks and the Particles
|
||||
// option, unless the packet or the type overrides the limiter.
|
||||
let add = |particles: &mut horizon_client::particles::ParticleEngine, at, v| {
|
||||
if p.override_limiter {
|
||||
particles.create(&options, at, v);
|
||||
} else {
|
||||
particles.add(&options, at, v);
|
||||
}
|
||||
particles.add_with(&options, at, v, p.override_limiter, p.always_show);
|
||||
};
|
||||
if p.count == 0 {
|
||||
add(
|
||||
|
||||
@@ -267,6 +267,30 @@ impl Game<'_> {
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
// ServerPlayer.awardKillScore: the blow that kills a player.
|
||||
if hurt {
|
||||
let died = match victim {
|
||||
// A totem that fired is applied on the next tick_totem, so the
|
||||
// victim is still at zero health but is not dying.
|
||||
Victim::Host => self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used),
|
||||
Victim::Guest(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.and_then(|g| g.motion.as_deref())
|
||||
.is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used),
|
||||
};
|
||||
if died {
|
||||
match s.attacker {
|
||||
Some(conn) => {
|
||||
self.as_guest(host, conn, |g| g.stats.custom("player_kills", 1));
|
||||
}
|
||||
None => self.stats.custom("player_kills", 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
if hurt && (s.crit || s.magic) {
|
||||
let target = match victim {
|
||||
Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)),
|
||||
|
||||
@@ -386,6 +386,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Level events whose client handler (`LevelEventHandler`) spawns particles
|
||||
//! on a block: bone meal's growth (1505), dragon egg teleportation (2015), a
|
||||
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
|
||||
//! off (3004) and scraping (3005), plus the smoke, drips, egg and dragon
|
||||
//! fireball particles of 1501, 1502, 1504, 2006, 2009, 2012 and 3009.
|
||||
//! consumable's teleport (2017), an enderman's (2018), electric sparks (3002),
|
||||
//! waxing (3003), wax off (3004) and scraping (3005), plus the smoke, drips,
|
||||
//! egg and dragon fireball particles of 1501, 1502, 1504, 2006, 2009, 2012
|
||||
//! and 3009.
|
||||
use super::Game;
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_client::particles::{self, ParticleOptions, ParticleType};
|
||||
@@ -12,19 +13,22 @@ use horizon_world::registry::BlockStateId;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn dragon_egg_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
self.particles
|
||||
.block_teleportation(pos, data, [16, 8, 16], self.settings.particles);
|
||||
self.particles.block_teleportation(pos, data, [16, 8, 16]);
|
||||
}
|
||||
/// Event 2016: `Shulker.teleportSomewhere`'s particles between the
|
||||
/// shell's old and new cells.
|
||||
pub(super) fn shulker_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
self.particles
|
||||
.block_teleportation(pos, data, [8, 8, 8], self.settings.particles);
|
||||
self.particles.block_teleportation(pos, data, [8, 8, 8]);
|
||||
}
|
||||
/// Event 2017: `TeleportRandomlyConsumeEffect`'s directional particles.
|
||||
pub(super) fn consume_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
self.particles
|
||||
.block_teleportation(pos, data, [127, 127, 127], self.settings.particles);
|
||||
.block_teleportation(pos, data, [127, 127, 127]);
|
||||
}
|
||||
/// Event 2018: an enderman's portal trail.
|
||||
pub(super) fn enderman_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
self.particles
|
||||
.enderman_teleportation(pos, data, self.settings.particles);
|
||||
}
|
||||
/// `LevelEventHandler.levelEvent` 3002 to 3005, drawing from the client
|
||||
/// level's random.
|
||||
|
||||
@@ -66,6 +66,8 @@ pub(super) struct Parked {
|
||||
tnts: Vec<tnt::PrimedTnt>,
|
||||
hoppers: HashMap<(i32, i32, i32), machines::HopperState>,
|
||||
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>,
|
||||
dropped: Vec<ItemEntity>,
|
||||
orbs: Vec<ExperienceOrb>,
|
||||
@@ -170,6 +172,7 @@ impl Game<'_> {
|
||||
tnts: Vec::new(),
|
||||
hoppers: HashMap::new(),
|
||||
mobs: Vec::new(),
|
||||
knot_ids: HashMap::new(),
|
||||
projectiles: Vec::new(),
|
||||
dropped: Vec::new(),
|
||||
orbs: Vec::new(),
|
||||
@@ -223,6 +226,7 @@ impl Game<'_> {
|
||||
swap(&mut self.tnts, &mut p.tnts);
|
||||
swap(&mut self.hoppers, &mut p.hoppers);
|
||||
swap(&mut self.mobs, &mut p.mobs);
|
||||
swap(&mut self.knot_ids, &mut p.knot_ids);
|
||||
swap(&mut self.creatures.projectiles, &mut p.projectiles);
|
||||
swap(&mut self.dropped, &mut p.dropped);
|
||||
swap(&mut self.orbs, &mut p.orbs);
|
||||
|
||||
@@ -16,7 +16,7 @@ pub(super) struct Bolt {
|
||||
flashes: i32,
|
||||
seed: i64,
|
||||
visual_only: bool,
|
||||
/// Mooshrooms switch colour once per bolt.
|
||||
/// Mooshrooms and copper golems change once per bolt.
|
||||
converted: Vec<u32>,
|
||||
}
|
||||
impl Game<'_> {
|
||||
@@ -278,6 +278,13 @@ impl Game<'_> {
|
||||
}
|
||||
self.strike_rod(b.at);
|
||||
}
|
||||
// LightningBolt.tick runs clearCopperOnLightningStrike for
|
||||
// every server bolt, a skeleton trap's visual one included;
|
||||
// a client's copy of a server bolt (spawn_remote_bolt) only
|
||||
// flashes.
|
||||
if self.remote.is_none() {
|
||||
self.bolt_clear_copper(b.at);
|
||||
}
|
||||
}
|
||||
b.life -= 1;
|
||||
if b.life < 0 {
|
||||
@@ -334,6 +341,16 @@ impl Game<'_> {
|
||||
self.level_event_at(3002, p, axis);
|
||||
}
|
||||
}
|
||||
/// `LightningBolt.clearCopperOnLightningStrike` at the block the bolt struck.
|
||||
fn bolt_clear_copper(&mut self, at: [f64; 3]) {
|
||||
let p = BlockPos::new(
|
||||
at[0].floor() as i32,
|
||||
(at[1] - 1.0e-6).floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
);
|
||||
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) {
|
||||
let at = b.at;
|
||||
@@ -393,6 +410,17 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
Species::Creeper => m.extra.charged = true,
|
||||
// CopperGolem.thunderHit (after Entity.thunderHit's fire and
|
||||
// damage): once per bolt, an oxidized golem goes back one step
|
||||
// and its weathering timer (or wax, -2) is reset to -1.
|
||||
Species::CopperGolem if !b.converted.contains(&m.id) => {
|
||||
b.converted.push(m.id);
|
||||
if m.extra.variant > 0 {
|
||||
m.extra.vector[0] = -1.0;
|
||||
m.extra.variant =
|
||||
horizon_client::mobs::copper_golem::weather_previous(m.extra.variant);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Entity.thunderHit: burning (8 seconds) and 5 lightning damage.
|
||||
|
||||
@@ -25,11 +25,13 @@ impl Game<'_> {
|
||||
};
|
||||
let m = &mut self.mobs[index];
|
||||
let mut used = false;
|
||||
let mut loved = false;
|
||||
if item == "minecraft:hay_block" && m.extra.tame && m.extra.age == 0 && m.extra.in_love <= 0
|
||||
{
|
||||
m.extra.in_love = 600;
|
||||
m.extra.love_player = true;
|
||||
used = true;
|
||||
loved = true;
|
||||
}
|
||||
if m.health < m.attrs.max_health {
|
||||
m.health = (m.health + heal).min(m.attrs.max_health);
|
||||
@@ -57,6 +59,10 @@ impl Game<'_> {
|
||||
if !used {
|
||||
return false;
|
||||
}
|
||||
// Animal.setInLove: entity event 18.
|
||||
if loved {
|
||||
self.mob_event_particles(self.mobs[index].id, 18);
|
||||
}
|
||||
self.llama_sound(index, "entity.llama.eat", 1.0);
|
||||
self.consume_held();
|
||||
true
|
||||
|
||||
@@ -37,6 +37,41 @@ pub(super) fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool {
|
||||
|| m.species() == Species::Allay && m.extra.tame
|
||||
}
|
||||
|
||||
/// A smash's knockback on the players, put off while a guest's turn holds
|
||||
/// the other players.
|
||||
pub(super) struct SmashPush {
|
||||
center: Vec3,
|
||||
heavy: f64,
|
||||
attacker: Option<u32>,
|
||||
team: Option<horizon_client::scoreboard::Team>,
|
||||
}
|
||||
/// `MaceItem.getKnockbackPower` and the push of one entity at `at`, `heavy`
|
||||
/// being 2 after a fall of more than five blocks.
|
||||
fn smash_push(center: Vec3, heavy: f64, at: Vec3, velocity: &mut Vec3, resistance: f64) {
|
||||
let d = [at.x - center.x, at.y - center.y, at.z - center.z];
|
||||
let distance_sqr = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
if distance_sqr > KNOCKBACK_RADIUS.powi(2) {
|
||||
return;
|
||||
}
|
||||
let length = distance_sqr.sqrt();
|
||||
let power =
|
||||
(KNOCKBACK_RADIUS - length) * f64::from(KNOCKBACK_POWER) * heavy * (1.0 - resistance);
|
||||
if power <= 0.0 {
|
||||
return;
|
||||
}
|
||||
// Vec3.normalize: a vector shorter than 1e-5 has no direction.
|
||||
let (x, z) = if length < f64::from(1.0e-5f32) {
|
||||
(0.0, 0.0)
|
||||
} else {
|
||||
(d[0] / length * power, d[2] / length * power)
|
||||
};
|
||||
*velocity = Vec3::new(
|
||||
velocity.x + x,
|
||||
velocity.y + f64::from(KNOCKBACK_POWER),
|
||||
velocity.z + z,
|
||||
);
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn holds_mace(&self) -> bool {
|
||||
self.inventory[self.selected]
|
||||
@@ -220,44 +255,75 @@ impl Game<'_> {
|
||||
];
|
||||
self.level_event_at(2013, on_pos, 750);
|
||||
let heavy = if fall > HEAVY_THRESHOLD { 2.0 } else { 1.0 };
|
||||
// getKnockbackPower and the push of one entity at `at`.
|
||||
let push = |at: Vec3, velocity: &mut Vec3, resistance: f64| {
|
||||
let d = [at.x - center.x, at.y - center.y, at.z - center.z];
|
||||
let distance_sqr = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
if distance_sqr > KNOCKBACK_RADIUS.powi(2) {
|
||||
return;
|
||||
}
|
||||
let length = distance_sqr.sqrt();
|
||||
let power = (KNOCKBACK_RADIUS - length)
|
||||
* f64::from(KNOCKBACK_POWER)
|
||||
* heavy
|
||||
* (1.0 - resistance);
|
||||
if power <= 0.0 {
|
||||
return;
|
||||
}
|
||||
// Vec3.normalize: a vector shorter than 1e-5 has no direction.
|
||||
let (x, z) = if length < f64::from(1.0e-5f32) {
|
||||
(0.0, 0.0)
|
||||
} else {
|
||||
(d[0] / length * power, d[2] / length * power)
|
||||
};
|
||||
*velocity = Vec3::new(
|
||||
velocity.x + x,
|
||||
velocity.y + f64::from(KNOCKBACK_POWER),
|
||||
velocity.z + z,
|
||||
);
|
||||
};
|
||||
let player = self.level.player_uuid.or(Some([0, 0, 0, 1]));
|
||||
for m in self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.filter(|m| m.id != id && m.alive() && !m.removed && !ally_of_player(m, player))
|
||||
{
|
||||
push(m.position, &mut m.velocity, m.attrs.knockback_resistance);
|
||||
smash_push(
|
||||
center,
|
||||
heavy,
|
||||
m.position,
|
||||
&mut m.velocity,
|
||||
m.attrs.knockback_resistance,
|
||||
);
|
||||
}
|
||||
for s in self.armor_stands.iter_mut().filter(|s| !s.marker) {
|
||||
push(s.position, &mut s.velocity, 0.0);
|
||||
smash_push(center, heavy, s.position, &mut s.velocity, 0.0);
|
||||
}
|
||||
// The host and the LAN guests are living entities too. A guest's turn
|
||||
// holds the other players (`as_guest`): the push waits for it to end.
|
||||
let push = SmashPush {
|
||||
center,
|
||||
heavy,
|
||||
attacker: self.lan_acting,
|
||||
team: self.player_team().cloned(),
|
||||
};
|
||||
if self.lan_acting.is_some() {
|
||||
self.creatures.smash_pushes.push(push);
|
||||
} else {
|
||||
self.smash_push_players(&push);
|
||||
}
|
||||
}
|
||||
/// `MaceItem.knockback` on the players but the attacker: the host unless
|
||||
/// it attacked, and every guest but the attacker.
|
||||
fn smash_push_players(&mut self, push: &SmashPush) {
|
||||
if push.attacker.is_some() {
|
||||
self.smash_push_player(push);
|
||||
}
|
||||
self.each_guest_hit_except(push.attacker, |g| g.smash_push_player(push));
|
||||
}
|
||||
/// The pushes guests' smashes put off.
|
||||
pub(super) fn flush_smash_pushes(&mut self) {
|
||||
for push in std::mem::take(&mut self.creatures.smash_pushes) {
|
||||
self.smash_push_players(&push);
|
||||
}
|
||||
}
|
||||
/// `MaceItem.knockback` on the player here (the host, or the guest swapped
|
||||
/// in): not a spectator, an ally of the attacker (`isAlliedTo`: the same
|
||||
/// team) or a creative player flying.
|
||||
fn smash_push_player(&mut self, push: &SmashPush) {
|
||||
if self.spectator()
|
||||
|| horizon_client::scoreboard::teams_allied(push.team.as_ref(), self.player_team())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let creative = self.creative;
|
||||
let Some(p) = self.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if creative && p.flying {
|
||||
return;
|
||||
}
|
||||
let resistance = p.attribute("minecraft:knockback_resistance").unwrap_or(0.0);
|
||||
smash_push(
|
||||
push.center,
|
||||
push.heavy,
|
||||
p.position,
|
||||
&mut p.velocity,
|
||||
resistance,
|
||||
);
|
||||
}
|
||||
/// `ServerPlayer.checkFallDamage` after a smash on a grounded target:
|
||||
/// the landing block's particles, 50 for every block fallen (200 at most).
|
||||
|
||||
@@ -41,6 +41,8 @@ enum Kind {
|
||||
Crafter,
|
||||
/// `BrewingStandBlockEntity` (a `WorldlyContainer`).
|
||||
Brewing,
|
||||
/// `ChiseledBookShelfBlockEntity`: books only, one to a slot.
|
||||
Bookshelf,
|
||||
/// `ComposterBlock.getContainer` at this level (a `WorldlyContainerHolder`
|
||||
/// without a block entity): input below 7, empty at 7, bone meal at 8.
|
||||
Composter(i32),
|
||||
@@ -82,7 +84,7 @@ impl View {
|
||||
}
|
||||
}
|
||||
|
||||
fn dir_from_name(name: &str) -> Option<Direction> {
|
||||
pub(super) fn dir_from_name(name: &str) -> Option<Direction> {
|
||||
Some(match name {
|
||||
"down" => Direction::Down,
|
||||
"up" => Direction::Up,
|
||||
@@ -169,6 +171,7 @@ impl Containers<'_> {
|
||||
(BlockEntity::Furnace(_), _) => Kind::Furnace,
|
||||
(BlockEntity::Crafter(_), _) => Kind::Crafter,
|
||||
(BlockEntity::Brewing(_), _) => Kind::Brewing,
|
||||
(BlockEntity::ChiseledBookshelf(_), _) => Kind::Bookshelf,
|
||||
(_, "minecraft:hopper") => Kind::Hopper,
|
||||
_ => Kind::Generic,
|
||||
};
|
||||
@@ -250,6 +253,16 @@ impl Containers<'_> {
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
if view.kind == Kind::Bookshelf {
|
||||
// ListBackedContainer.canPlaceItem: `acceptsItemType` (the books)
|
||||
// and a free slot, as `getMaxStackSize` is 1.
|
||||
return self
|
||||
.items
|
||||
.item_tags
|
||||
.get("minecraft:bookshelf_books")
|
||||
.is_some_and(|m| m.contains(&*stack.item))
|
||||
&& self.get(view, slot).is_none();
|
||||
}
|
||||
if view.kind == Kind::Brewing {
|
||||
// BrewingStandBlockEntity.canPlaceItem
|
||||
return match slot {
|
||||
@@ -399,6 +412,45 @@ pub(super) fn container_signal(
|
||||
.map_or(0, |v| c.signal(&v))
|
||||
}
|
||||
|
||||
/// `ChiseledBookShelfBlock.getAnalogOutputSignal` and
|
||||
/// `ShelfBlock.getAnalogOutputSignal`, from the block entity Horizon keeps as
|
||||
/// it was loaded; `None` for any other block. `direction` points from the
|
||||
/// block to the comparator.
|
||||
pub(super) fn shelf_signal(
|
||||
world: &World,
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
state: BlockStateId,
|
||||
pos: BlockPos,
|
||||
direction: Direction,
|
||||
) -> Option<i32> {
|
||||
let info = registry.state(state)?;
|
||||
let chiseled = info.name == "minecraft:chiseled_bookshelf";
|
||||
if !chiseled && !info.name.ends_with("_shelf") {
|
||||
return None;
|
||||
}
|
||||
if chiseled {
|
||||
// The last slot touched, plus one (`lastInteractedSlot` starts at -1).
|
||||
return Some(match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
|
||||
Some(BlockEntity::ChiseledBookshelf(b)) => b.last_slot + 1,
|
||||
_ => 0,
|
||||
});
|
||||
}
|
||||
let tag = super::spill::saved_entity(world, [pos.x, pos.y, pos.z]);
|
||||
// Only read from the back: a bit for each of the three slots in use.
|
||||
let facing = info.properties.get("facing").and_then(|f| dir_from_name(f));
|
||||
if facing.map(Direction::opposite) != Some(direction) {
|
||||
return Some(0);
|
||||
}
|
||||
let items = horizon_client::save::decode_items(tag.as_ref().and_then(|t| t.get("Items")), 3);
|
||||
Some(
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(slot, item)| i32::from(item.is_some()) << slot)
|
||||
.sum(),
|
||||
)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn containers(&self) -> Containers<'_> {
|
||||
Containers {
|
||||
@@ -433,6 +485,7 @@ impl Game<'_> {
|
||||
if pos.1 == super::minecarts::CART_KEY_Y {
|
||||
return;
|
||||
}
|
||||
self.chiseled_bookshelf_synced(pos);
|
||||
// A double chest's other half changes with it.
|
||||
let halves: Vec<Pos> = std::iter::once(pos)
|
||||
.chain(self.chest_other_half(pos))
|
||||
@@ -626,46 +679,8 @@ impl Game<'_> {
|
||||
/// `HopperBlockEntity.suckInItems`: from the container above, or item entities.
|
||||
fn hopper_suck(&mut self, pos: Pos, hopper: &View) -> bool {
|
||||
let above = (pos.0, pos.1 + 1, pos.2);
|
||||
let c = self.containers();
|
||||
if let Some(source) = c.view(above, false) {
|
||||
if let Kind::Composter(level) = source.kind {
|
||||
if level != 8 {
|
||||
return false;
|
||||
}
|
||||
// tryTakeInItemFromSlot on the OutputContainer: removeItem calls
|
||||
// its setChanged, which empties the composter, and so does the
|
||||
// second setChanged after the move or, when the hopper has no
|
||||
// room, the setItem putting the bone meal back into the detached
|
||||
// container (whose bone meal is lost with it).
|
||||
let bone_meal = c.bone_meal.clone();
|
||||
let at = BlockPos::new(above.0, above.1, above.2);
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
self.world.modified.insert((above.0 >> 4, above.2 >> 4));
|
||||
return moved;
|
||||
}
|
||||
let source_hopper = (source.kind == Kind::Hopper).then_some(above);
|
||||
for slot in source.slots_for_face(Some(Direction::Down)) {
|
||||
let c = self.containers();
|
||||
let Some(stack) = c.get(&source, slot) else {
|
||||
continue;
|
||||
};
|
||||
if !c.can_take(&source, slot, stack, Direction::Down) {
|
||||
continue;
|
||||
}
|
||||
let Some(one) = self.take_one(&source, slot) else {
|
||||
continue;
|
||||
};
|
||||
match self.add_item(source_hopper, hopper, one, None) {
|
||||
None => {
|
||||
self.view_changed(&source);
|
||||
return true;
|
||||
}
|
||||
Some(rest) => self.put_back(&source, slot, rest),
|
||||
}
|
||||
}
|
||||
return false;
|
||||
if let Some(took) = self.suck_from_container(above, hopper) {
|
||||
return took;
|
||||
}
|
||||
let above_state = self.world.block(above.0, above.1, above.2);
|
||||
let blocked = self.res.facts().get(above_state).full_collision
|
||||
@@ -695,6 +710,56 @@ impl Game<'_> {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.suck_item_entities(hopper, min, max)
|
||||
}
|
||||
/// The container half of `HopperBlockEntity.suckInItems`: one item from the
|
||||
/// container at `above`, through its bottom face. `None` when there is no
|
||||
/// container there.
|
||||
fn suck_from_container(&mut self, above: Pos, hopper: &View) -> Option<bool> {
|
||||
let c = self.containers();
|
||||
let source = c.view(above, false)?;
|
||||
if let Kind::Composter(level) = source.kind {
|
||||
if level != 8 {
|
||||
return Some(false);
|
||||
}
|
||||
// tryTakeInItemFromSlot on the OutputContainer: removeItem calls
|
||||
// its setChanged, which empties the composter, and so does the
|
||||
// second setChanged after the move or, when the hopper has no
|
||||
// room, the setItem putting the bone meal back into the detached
|
||||
// container (whose bone meal is lost with it).
|
||||
let bone_meal = c.bone_meal.clone();
|
||||
let at = BlockPos::new(above.0, above.1, above.2);
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
self.world.modified.insert((above.0 >> 4, above.2 >> 4));
|
||||
return Some(moved);
|
||||
}
|
||||
let source_hopper = (source.kind == Kind::Hopper).then_some(above);
|
||||
for slot in source.slots_for_face(Some(Direction::Down)) {
|
||||
let c = self.containers();
|
||||
let Some(stack) = c.get(&source, slot) else {
|
||||
continue;
|
||||
};
|
||||
if !c.can_take(&source, slot, stack, Direction::Down) {
|
||||
continue;
|
||||
}
|
||||
let Some(one) = self.take_one(&source, slot) else {
|
||||
continue;
|
||||
};
|
||||
match self.add_item(source_hopper, hopper, one, None) {
|
||||
None => {
|
||||
self.view_changed(&source);
|
||||
return Some(true);
|
||||
}
|
||||
Some(rest) => self.put_back(&source, slot, rest),
|
||||
}
|
||||
}
|
||||
Some(false)
|
||||
}
|
||||
/// The item half of `HopperBlockEntity.suckInItems` (`getItemsAtAndAbove`):
|
||||
/// adds the item entities that meet the box, until one is taken whole.
|
||||
fn suck_item_entities(&mut self, hopper: &View, min: [f64; 3], max: [f64; 3]) -> bool {
|
||||
let mut i = 0;
|
||||
while i < self.dropped.len() {
|
||||
let b = self.dropped[i].bounds();
|
||||
@@ -719,6 +784,66 @@ impl Game<'_> {
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `MinecartHopper.tick`'s `tryConsumeItems` (`suckInItems`) for an enabled
|
||||
/// hopper minecart: one item from the container above, else the item
|
||||
/// entities over it or touching it, each tick without a cooldown.
|
||||
pub(super) fn hopper_cart_suck(&mut self, cart: &mut super::minecarts::Minecart) {
|
||||
// The cart's slots are edited as a container under the placeholder key
|
||||
// its menu uses; when that menu is open the placeholder is the live copy.
|
||||
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, cart.id as i32);
|
||||
let open = self.world.block_entities.contains_key(&key);
|
||||
if !open {
|
||||
let items = BlockEntity::Chest(std::mem::take(&mut cart.items));
|
||||
self.world.block_entities.insert(key, items);
|
||||
}
|
||||
let size = self
|
||||
.world
|
||||
.block_entities
|
||||
.get(&key)
|
||||
.map_or(0, |e| e.slots().len());
|
||||
let view = View {
|
||||
parts: vec![(key, size)],
|
||||
kind: Kind::Generic,
|
||||
};
|
||||
let p = cart.position;
|
||||
// getLevelY is the cart's y + 0.5, so the container is the block at
|
||||
// y + 1.5, and Hopper.SUCK_AABB (moved by getLevel - 0.5) starts 11/16
|
||||
// above the cart.
|
||||
let above = (
|
||||
p.x.floor() as i32,
|
||||
(p.y + 1.5).floor() as i32,
|
||||
p.z.floor() as i32,
|
||||
);
|
||||
let took = match self.suck_from_container(above, &view) {
|
||||
Some(took) => took,
|
||||
None => self.suck_item_entities(
|
||||
&view,
|
||||
[p.x - 0.5, p.y + 11.0 / 16.0, p.z - 0.5],
|
||||
[p.x + 0.5, p.y + 2.0, p.z + 0.5],
|
||||
),
|
||||
};
|
||||
if !took {
|
||||
// The cart's own box, inflated by 0.25 sideways.
|
||||
let b = cart.bounds();
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
self.suck_item_entities(
|
||||
&view,
|
||||
[lo.x - 0.25, lo.y, lo.z - 0.25],
|
||||
[hi.x + 0.25, hi.y, hi.z + 0.25],
|
||||
);
|
||||
}
|
||||
// The cart's own items stay the one source of truth: a menu copy that
|
||||
// is still around (open, or left by a guest's close) gets the pulled
|
||||
// items back into `cart.items`, so reopening never restores a stale copy.
|
||||
let slots = if open {
|
||||
self.world.block_entities.get(&key).cloned()
|
||||
} else {
|
||||
self.world.block_entities.remove(&key)
|
||||
};
|
||||
if let Some(BlockEntity::Chest(items)) = slots {
|
||||
cart.items = items;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- dispensers and droppers ----
|
||||
|
||||
|
||||
@@ -1143,6 +1143,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1194,6 +1196,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1266,6 +1270,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ pub(super) struct Minecart {
|
||||
pub yaw: f32,
|
||||
pub previous_yaw: f32,
|
||||
pub flipped: bool,
|
||||
on_ground: bool,
|
||||
pub(super) on_ground: bool,
|
||||
horizontal_collision: bool,
|
||||
fall_distance: f64,
|
||||
pub hurt_time: i32,
|
||||
@@ -558,7 +558,6 @@ impl Game<'_> {
|
||||
// applyGravity
|
||||
c.velocity.y -= if self.cart_in_water(c) { 0.005 } else { 0.04 };
|
||||
let pos = self.rail_block(c.position);
|
||||
let fall_speed = c.velocity;
|
||||
if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) {
|
||||
self.move_along_track(c, pos, &rail, intent);
|
||||
if &*rail.name == "minecraft:activator_rail" {
|
||||
@@ -600,22 +599,26 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
CartKind::Tnt => {
|
||||
let speed = fall_speed.x * fall_speed.x + fall_speed.z * fall_speed.z;
|
||||
// MinecartTNT.tick: the fuse and the collision check are separate.
|
||||
if c.fuse > 0 {
|
||||
c.fuse -= 1;
|
||||
let at = [c.position.x, c.position.y + 0.5, c.position.z];
|
||||
self.particles
|
||||
.add(&ParticleOptions::simple(ParticleType::Smoke), at, [0.0; 3]);
|
||||
} else if c.fuse == 0 || (c.horizontal_collision && speed >= 0.01) {
|
||||
} else if c.fuse == 0 {
|
||||
// explode(horizontalDistanceSqr()): a rising cart's
|
||||
// vertical speed adds nothing.
|
||||
let speed = c.velocity.x * c.velocity.x + c.velocity.z * c.velocity.z;
|
||||
let speed = if c.fuse == 0 && c.velocity.y > 0.0 {
|
||||
c.velocity.y * c.velocity.y
|
||||
} else {
|
||||
speed
|
||||
};
|
||||
self.explode_cart(c, speed);
|
||||
return false;
|
||||
}
|
||||
if c.horizontal_collision {
|
||||
let speed = c.velocity.x * c.velocity.x + c.velocity.z * c.velocity.z;
|
||||
if speed >= f64::from(0.01_f32) {
|
||||
self.explode_cart(c, speed);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -659,51 +662,9 @@ impl Game<'_> {
|
||||
// The cart is the explosion's source: it is gone before the blast
|
||||
// looks for entities, so it can't hit itself.
|
||||
self.carts.retain(|x| x.id != c.id);
|
||||
let _ = self.server_explode(at, power, BlockInteraction::Destroy, false);
|
||||
}
|
||||
/// `MinecartHopper.suckInItems`: items above, into the five slots.
|
||||
fn hopper_cart_pickup(&mut self, c: &mut Minecart) {
|
||||
if !c.enabled {
|
||||
return;
|
||||
}
|
||||
let p = c.position;
|
||||
let zone = Aabb::new(
|
||||
Vec3::new(p.x - 0.5, p.y - 0.5 + 0.0, p.z - 0.5),
|
||||
Vec3::new(p.x + 0.5, p.y + 1.5, p.z + 0.5),
|
||||
);
|
||||
let mut i = 0;
|
||||
while i < self.dropped.len() {
|
||||
let e = &self.dropped[i];
|
||||
if e.pickup_delay > 0 || !zone.contains(e.position) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut stack = e.stack.clone();
|
||||
for slot in c.items.iter_mut() {
|
||||
match slot {
|
||||
None => {
|
||||
*slot = Some(stack.clone());
|
||||
stack.count = 0;
|
||||
}
|
||||
Some(s) if s.same_item_same_components(&stack) => {
|
||||
let max = self.res.items().get(&s.item).map_or(64, |i| i.max_stack);
|
||||
let moved = stack.count.min(max.saturating_sub(s.count));
|
||||
s.count += moved;
|
||||
stack.count -= moved;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if stack.count == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if stack.count == 0 {
|
||||
self.dropped.remove(i);
|
||||
} else {
|
||||
self.dropped[i].stack = stack;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let _ = self.with_hive_emptying_blast(|g| {
|
||||
g.server_explode(at, power, BlockInteraction::Destroy, false)
|
||||
});
|
||||
}
|
||||
/// `AbstractMinecart.push` from each player walking into a cart
|
||||
/// (`LivingEntity.pushEntities`): the window's player and every server
|
||||
@@ -837,8 +798,8 @@ impl Game<'_> {
|
||||
}
|
||||
let mut c = self.carts[i].clone();
|
||||
let alive = self.tick_cart(&mut c, [0.0; 2]);
|
||||
if c.kind == CartKind::Hopper {
|
||||
self.hopper_cart_pickup(&mut c);
|
||||
if c.kind == CartKind::Hopper && c.enabled {
|
||||
self.hopper_cart_suck(&mut c);
|
||||
}
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
continue;
|
||||
@@ -1329,6 +1290,7 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color,
|
||||
light,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +204,8 @@ impl Game<'_> {
|
||||
layer: overlay,
|
||||
color,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -384,13 +386,15 @@ impl Game<'_> {
|
||||
owner.use_item_remaining_ticks = 72_000 - mob.extra.using_item;
|
||||
owner.crossbow_charge_duration = 25;
|
||||
}
|
||||
let context = if left {
|
||||
horizon_assets::item_model::DisplayContext::ThirdPersonLeftHand
|
||||
} else {
|
||||
horizon_assets::item_model::DisplayContext::ThirdPersonRightHand
|
||||
use horizon_assets::item_model::DisplayContext as Context;
|
||||
// PandaHoldsItemLayer shows the item as it lies on the ground.
|
||||
let (context, ordinal) = match (mob.species(), left) {
|
||||
(Species::Panda, _) => (Context::Ground, 7),
|
||||
(_, true) => (Context::ThirdPersonLeftHand, 1),
|
||||
(_, false) => (Context::ThirdPersonRightHand, 2),
|
||||
};
|
||||
// `updateForLiving`: seeded by the entity id plus the context ordinal.
|
||||
let seed = mob.id as i32 + if left { 1 } else { 2 };
|
||||
let seed = mob.id as i32 + ordinal;
|
||||
let state = self.resolve_item(&stack, context, Some(&owner), seed);
|
||||
if lit {
|
||||
self.submit_item_world(&state, t, light, out);
|
||||
|
||||
@@ -228,10 +228,12 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
let mut used = false;
|
||||
let mut loved = false;
|
||||
if love && m.extra.tame && m.extra.age == 0 && m.extra.in_love <= 0 {
|
||||
m.extra.in_love = 600;
|
||||
m.extra.love_player = true;
|
||||
used = true;
|
||||
loved = true;
|
||||
}
|
||||
if m.health < m.attrs.max_health && heal > 0.0 {
|
||||
m.health = (m.health + heal).min(m.attrs.max_health);
|
||||
@@ -251,6 +253,7 @@ impl Game<'_> {
|
||||
// eating(): the mouth opens and the horse eats.
|
||||
m.anim.v[2] = 1.0;
|
||||
let at = m.center();
|
||||
let id = m.id;
|
||||
let sound = match m.species() {
|
||||
Species::Camel => "minecraft:entity.camel.eat",
|
||||
Species::CamelHusk => "minecraft:entity.camel_husk.eat",
|
||||
@@ -260,16 +263,17 @@ impl Game<'_> {
|
||||
};
|
||||
let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
|
||||
self.audio.play(sound, Some(at), 1.0, pitch);
|
||||
// Animal.setInLove: entity event 18.
|
||||
if loved {
|
||||
self.mob_event_particles(id, 18);
|
||||
}
|
||||
self.consume_held();
|
||||
true
|
||||
}
|
||||
/// `LocalPlayer.aiStep`'s riding jump: the bar charges while jump is
|
||||
/// held; on release, the jump's strength (0 when there is none).
|
||||
pub(super) fn charge_mount_jump(&mut self, i: usize, keys: Keys) -> f32 {
|
||||
// PlayerRideableJumping: horses, camels and nautili (the dash).
|
||||
let saddled = self.mobs[i].extra.saddled
|
||||
&& (equine::is_equine(self.mobs[i].species())
|
||||
|| horizon_client::mobs::nautilus::is_nautilus(self.mobs[i].species()));
|
||||
let saddled = self.mobs[i].extra.saddled && Self::jumps(self.mobs[i].species());
|
||||
let mut jump = 0.0;
|
||||
if saddled {
|
||||
let st = &mut self.mount;
|
||||
@@ -465,21 +469,28 @@ impl Game<'_> {
|
||||
m.extra.owner = owner;
|
||||
m.persistent = true;
|
||||
// AbstractHorse.tameWithName: entity event 7, spawnTamingParticles(true).
|
||||
self.particles_around_mob(
|
||||
mob,
|
||||
"heart",
|
||||
horizon_client::particles::Burst::TAMING,
|
||||
);
|
||||
self.mob_event_particles(mob, 7);
|
||||
}
|
||||
}
|
||||
MobEvent::Buck { mob } if self.riding == Some(mob) => self.dismount_mount(),
|
||||
MobEvent::Buck { mob } => {
|
||||
if self.riding == Some(mob) {
|
||||
self.dismount_mount();
|
||||
}
|
||||
// RunAroundLikeCrazyGoal.tick: entity event 6, smoke.
|
||||
self.mob_event_particles(mob, 6);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `Player.jumpableVehicle`: a saddled mount that jumps.
|
||||
/// `PlayerRideableJumping`: horses, camels and nautili (the dash).
|
||||
fn jumps(species: Species) -> bool {
|
||||
equine::is_equine(species) || horizon_client::mobs::nautilus::is_nautilus(species)
|
||||
}
|
||||
/// `Player.jumpableVehicle`: a saddled mount that jumps (not a pig or a
|
||||
/// strider, which a saddle only steers).
|
||||
pub(super) fn jumpable_mount(&self) -> bool {
|
||||
self.mount_index()
|
||||
.is_some_and(|i| self.mobs[i].extra.saddled)
|
||||
.is_some_and(|i| self.mobs[i].extra.saddled && Self::jumps(self.mobs[i].species()))
|
||||
}
|
||||
/// `willPrioritizeJumpInfo`: charging a jump or recharging.
|
||||
pub(super) fn mount_jumping(&self) -> bool {
|
||||
@@ -504,8 +515,13 @@ impl Game<'_> {
|
||||
let draws = horizon_client::hud_layout::vehicle_health(w, h, hearts, m.health);
|
||||
horizon_client::hud_layout::paint(batch, atlas, &draws);
|
||||
if jump_bar {
|
||||
// JumpableVehicleBar: the cooldown shows while the jump recharges.
|
||||
let cooldown = i32::from(self.mount.jump_ticks < 0);
|
||||
// JumpableVehicleBar: `getJumpCooldown`, which only the camel's and
|
||||
// the nautilus' dash have; a horse always shows the progress.
|
||||
let cooldown = i32::from(
|
||||
self.mount.jump_ticks < 0
|
||||
&& (matches!(m.species(), Species::Camel | Species::CamelHusk)
|
||||
|| horizon_client::mobs::nautilus::is_nautilus(m.species())),
|
||||
);
|
||||
let draws =
|
||||
horizon_client::hud_overlays::jump_bar(w, h, cooldown, self.mount.jump_scale);
|
||||
horizon_client::hud_layout::paint(batch, atlas, &draws);
|
||||
|
||||
@@ -14,7 +14,9 @@ impl Game<'_> {
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let partial = alpha as f32;
|
||||
for &(p, piston) in &self.world.moving_pistons {
|
||||
let mut states = self.world.moving_pistons.clone();
|
||||
states.extend(self.world.moving_piston_fallback.iter().copied());
|
||||
for (p, piston) in states {
|
||||
let Some((block, base)) = self.moving_piston_blocks(piston, partial) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -379,6 +379,7 @@ impl Game<'_> {
|
||||
layer,
|
||||
color,
|
||||
light,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -504,7 +504,8 @@ impl<'a> Game<'a> {
|
||||
m.position.z.floor() as i32,
|
||||
],
|
||||
is_mob: true,
|
||||
persistent: m.persistent,
|
||||
persistent: m.persistent
|
||||
|| horizon_client::mobs::ai::requires_custom_persistence(m),
|
||||
})
|
||||
.collect();
|
||||
let players = players
|
||||
|
||||
@@ -85,13 +85,8 @@ impl Game<'_> {
|
||||
m.ai.target = None;
|
||||
}
|
||||
let sound = nautilus::eat_sound(m);
|
||||
let kind = if tamed { "heart" } else { "smoke" };
|
||||
// TamableAnimal.spawnTamingParticles (entity event 7 or 6).
|
||||
self.particles_around_mob(
|
||||
self.mobs[index].id,
|
||||
kind,
|
||||
horizon_client::particles::Burst::TAMING,
|
||||
);
|
||||
self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
|
||||
self.nautilus_sound(index, sound);
|
||||
if tamed {
|
||||
let view = self.mob_entity(&self.mobs[index]);
|
||||
@@ -145,6 +140,8 @@ impl Game<'_> {
|
||||
let sound = nautilus::eat_sound(m);
|
||||
self.use_nautilus_food(item);
|
||||
self.nautilus_sound(index, sound);
|
||||
// Animal.setInLove: entity event 18.
|
||||
self.mob_event_particles(self.mobs[index].id, 18);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
|
||||
@@ -307,6 +307,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Pandas on the game side: the food they find on the ground and pick up
|
||||
//! (`Mob.aiStep`, `Panda.pickUpItem`), the bamboo a player gives them
|
||||
//! (`Panda.mobInteract`), the start a sneeze gives the grown pandas about,
|
||||
//! and the crumbs and the sneeze a window draws.
|
||||
use super::{
|
||||
Game,
|
||||
pickups::{Collector, Taken},
|
||||
};
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
mobs::{Species, panda},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Panda.canPickUpAndEat`'s tag.
|
||||
fn panda_eats_from_ground(&self, item: &str) -> bool {
|
||||
self.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:panda_eats_from_ground")
|
||||
.is_some_and(|tag| tag.contains(item))
|
||||
}
|
||||
/// A panda takes what lies at its feet when its mouth is free, and
|
||||
/// looks for food within 6 and 8 blocks for `PandaSitGoal`.
|
||||
pub(super) fn tick_pandas(&mut self) {
|
||||
let griefing = self.ui.rules.bool("mob_griefing");
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if m.species() != Species::Panda || !m.alive() {
|
||||
continue;
|
||||
}
|
||||
let id = m.id;
|
||||
let wants = |g: &Self, e: &horizon_client::entities::ItemEntity| {
|
||||
e.pickup_delay == 0 && g.panda_eats_from_ground(&e.stack.item)
|
||||
};
|
||||
// Mob.aiStep: the items within the box grown by (1, 0, 1).
|
||||
if griefing && m.can_pick_up_loot() && m.extra.main_hand.is_none() {
|
||||
let reach = m.bounds().inflate(Vec3::new(1.0, 0.0, 1.0));
|
||||
let taken = self
|
||||
.dropped
|
||||
.iter()
|
||||
.position(|e| wants(self, e) && reach.intersects(e.bounds()));
|
||||
if let Some(j) = taken {
|
||||
let e = self.dropped.remove(j);
|
||||
self.add_pickup(Taken::Item(Box::new(e.clone())), Collector::Mob(id));
|
||||
panda::hold(&mut self.mobs[i], &e.stack.item, e.stack.count, true);
|
||||
}
|
||||
}
|
||||
let m = &self.mobs[i];
|
||||
let area = |r: f64| m.bounds().inflate(Vec3::new(r, r, r));
|
||||
let (near, wide) = (area(6.0), area(8.0));
|
||||
let food_near = self
|
||||
.dropped
|
||||
.iter()
|
||||
.any(|e| wants(self, e) && near.intersects(e.bounds()));
|
||||
let food_at = self
|
||||
.dropped
|
||||
.iter()
|
||||
.find(|e| wants(self, e) && wide.intersects(e.bounds()))
|
||||
.map(|e| [e.position.x, e.position.y, e.position.z]);
|
||||
panda::set_food(&mut self.mobs[i], food_near, food_at);
|
||||
}
|
||||
}
|
||||
/// `Panda.mobInteract` for a click with `item`; whether the click is taken.
|
||||
pub(super) fn interact_panda(&mut self, index: usize, item: &str) -> bool {
|
||||
let creative = self.creative;
|
||||
let m = &mut self.mobs[index];
|
||||
if panda::scared(m) {
|
||||
return false;
|
||||
}
|
||||
if panda::get_up(m) {
|
||||
return true;
|
||||
}
|
||||
if !m.kind.food.iter().any(|f| f == item) {
|
||||
return false;
|
||||
}
|
||||
if m.ai.target.is_some() {
|
||||
panda::got_bamboo(m);
|
||||
}
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
let mut dropped = None;
|
||||
if m.is_baby() {
|
||||
// ageUp((int) (-getAge() / 20 * 0.1F), true).
|
||||
let seconds = ((-m.extra.age / 20) as f32 * 0.1) as i32;
|
||||
horizon_client::mobs::ai::age_up(m, seconds, true);
|
||||
} else if m.extra.age == 0 && m.extra.in_love <= 0 {
|
||||
m.extra.in_love = 600;
|
||||
m.extra.love_player = true;
|
||||
} else {
|
||||
if panda::is_sitting(m) || m.fluids.in_water {
|
||||
return false;
|
||||
}
|
||||
panda::try_to_sit(m);
|
||||
panda::start_eating(m);
|
||||
// What it held falls, unless the player has infinite materials.
|
||||
if let Some(held) = m.extra.main_hand.clone()
|
||||
&& !creative
|
||||
{
|
||||
dropped = Some(ItemStack::new(&held, panda::held_count(m)));
|
||||
}
|
||||
panda::hold(m, item, 1, false);
|
||||
}
|
||||
self.consume_held();
|
||||
if let Some(stack) = dropped {
|
||||
self.spawn_item(stack, at, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `Panda.afterSneeze`: the grown pandas within 10 blocks of a cub jump.
|
||||
pub(super) fn panda_sneezed(&mut self, id: u32) {
|
||||
let Some(area) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| m.bounds().inflate(Vec3::new(10.0, 10.0, 10.0)))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for m in &mut self.mobs {
|
||||
if m.species() == Species::Panda
|
||||
&& !m.is_baby()
|
||||
&& m.on_ground
|
||||
&& !m.fluids.in_water
|
||||
&& panda::can_perform_action(m, panda::scared(m))
|
||||
&& area.intersects(m.bounds())
|
||||
{
|
||||
// LivingEntity.jumpFromGround.
|
||||
m.needs_sync = true;
|
||||
let power = f64::from(m.jump_power());
|
||||
if power > 1.0e-5 {
|
||||
m.velocity.y = power.max(m.velocity.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What a window draws of its pandas: the crumbs of a bite
|
||||
/// (`addEatingParticles`) and the sneeze's particle.
|
||||
pub(super) fn panda_effects(&mut self) {
|
||||
let mut bites = Vec::new();
|
||||
let mut sneezes = Vec::new();
|
||||
for m in self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.filter(|m| m.species() == Species::Panda)
|
||||
{
|
||||
let (crumbs, sneeze) = panda::effects(m);
|
||||
if let (Some(crumbs), Some(item)) = (crumbs, m.extra.main_hand.clone()) {
|
||||
bites.push((item, crumbs));
|
||||
}
|
||||
sneezes.extend(sneeze);
|
||||
}
|
||||
for (item, crumbs) in bites {
|
||||
let options = self.item_particle(&ItemStack::new(&item, 1));
|
||||
for (at, velocity) in crumbs {
|
||||
self.particles.add(&options, at, velocity);
|
||||
}
|
||||
}
|
||||
let options = ParticleOptions::simple(ParticleType::Sneeze);
|
||||
for (at, velocity) in sneezes {
|
||||
self.particles.add(&options, at, velocity);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -452,6 +452,7 @@ impl Game<'_> {
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let start = out.len();
|
||||
horizon_client::mobs::render::render_with(
|
||||
m,
|
||||
alpha as f32,
|
||||
@@ -465,6 +466,15 @@ impl Game<'_> {
|
||||
self.player_elytra_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
// LivingEntity.isCurrentlyGlowing: the Glowing effect. A server's client is
|
||||
// in its team by its profile name.
|
||||
if self.potions.effects.get("glowing").is_some() {
|
||||
let team = match &self.remote {
|
||||
Some(r) => self.score.board.players_team(&r.profile_name),
|
||||
None => self.player_team(),
|
||||
};
|
||||
self.mark_outline(&out[start..], super::glowing::team_color(team));
|
||||
}
|
||||
}
|
||||
/// `extractEntityInInventoryFollowsMouse`: the player in the box
|
||||
/// `[x0, x1) x [y0, y1)`, `size` pixels per block, turned toward the
|
||||
@@ -719,6 +729,8 @@ mod preview_tests {
|
||||
color: u32::MAX,
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
})
|
||||
};
|
||||
// A shield and arm can intersect: neither average depth orders both ends.
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
//! `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` for
|
||||
//! the entities the game moves itself: the players and the mobs.
|
||||
//!
|
||||
//! The geometry is in [`horizon_world::piston_push`]; this file supplies the
|
||||
//! bodies and applies the movement the way `Entity.move(MoverType.PISTON, …)`
|
||||
//! does: limited to 0.51 blocks per tick per axis (`Entity.limitPistonMovement`),
|
||||
//! then run through the block collision scan, so a pushed entity stops at a
|
||||
//! wall instead of passing through it.
|
||||
//!
|
||||
use super::Game;
|
||||
use horizon_client::collisions::{EntityCollisions, SceneCollisions, with_piston_noclip};
|
||||
use horizon_core::geometry::{Aabb, Direction, Vec3, border::BorderGeometry};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::{
|
||||
block_collision_scan::BlockCollisionWorld, level::pistons::MovingPiston,
|
||||
piston_movement::PistonMovement, piston_push, world_movement::EntityMovementWorld,
|
||||
};
|
||||
|
||||
/// What one piston asks for this tick: where it is, its state, and the progress
|
||||
/// `moveCollidedEntities` runs at (before the tick stores it).
|
||||
pub(super) type Push = (BlockPos, MovingPiston, f32);
|
||||
|
||||
/// Which entity was pushed, so its storage can be reached after the world
|
||||
/// borrow ends.
|
||||
#[derive(Clone, Copy, Hash, PartialEq, Eq)]
|
||||
enum Target {
|
||||
Player,
|
||||
Guest(u32),
|
||||
Mob(usize),
|
||||
Item(usize),
|
||||
Boat(usize),
|
||||
Cart(usize),
|
||||
ArmorStand(usize),
|
||||
}
|
||||
|
||||
/// `Entity.getStepHeight` of each body, read before the push.
|
||||
struct StepHeights {
|
||||
player: f32,
|
||||
guests: std::collections::HashMap<u32, f32>,
|
||||
}
|
||||
|
||||
/// What the push needs from one entity.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Body {
|
||||
bounds: Aabb,
|
||||
position: Vec3,
|
||||
on_ground: bool,
|
||||
step_height: f32,
|
||||
/// Standing on the piston's own block (`Entity.isSupportedBy`).
|
||||
supported: bool,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Push whatever this tick's moving pistons ran into, in the order the
|
||||
/// pistons ticked: vanilla pushes from each block entity's own tick.
|
||||
pub(super) fn push_piston_entities(&mut self, pushes: Vec<Push>) {
|
||||
let mut accumulators = std::collections::HashMap::new();
|
||||
for push in pushes {
|
||||
self.push_for_piston(push, &mut accumulators);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_for_piston(
|
||||
&mut self,
|
||||
(pos, piston, new_progress): Push,
|
||||
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
|
||||
) {
|
||||
let delta_progress = f64::from(new_progress - piston.progress);
|
||||
if delta_progress <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let Some(bindings) = self.collision.pistons() else {
|
||||
return;
|
||||
};
|
||||
let Ok(state) = bindings.collision_related_state(piston.shape_state()) else {
|
||||
return;
|
||||
};
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let Ok(shape) = self
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, [pos.x, pos.y, pos.z])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if shape.is_empty() {
|
||||
return;
|
||||
}
|
||||
let movement = piston.movement_direction();
|
||||
let boxes: Vec<Aabb> = shape.boxes().to_vec();
|
||||
let facts = self.res.facts();
|
||||
let slime = &*facts.get(piston.moved_state).name == "minecraft:slime_block";
|
||||
let honey = &*facts.get(piston.moved_state).name == "minecraft:honey_block";
|
||||
// `moveStuckEntities` asks the moved state's own top for its box.
|
||||
let sticky_top = if honey {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
self.collision
|
||||
.collision_shape(&mut scene, piston.moved_state, [pos.x, pos.y, pos.z])
|
||||
.ok()
|
||||
.and_then(|s| s.bounds())
|
||||
.map_or(0.0, |b| b.max_position().y)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let steps = self.push_step_heights();
|
||||
let search = shape.bounds().map(|bounds| {
|
||||
piston_push::movement_search_area(
|
||||
piston_push::move_by_position_and_progress(pos, bounds, piston.shape_state()),
|
||||
movement,
|
||||
delta_progress,
|
||||
)
|
||||
});
|
||||
let targets = self.push_targets();
|
||||
for &target in &targets {
|
||||
let Some(body) = self.push_body(target, pos, &steps) else {
|
||||
continue;
|
||||
};
|
||||
let amount = piston_push::collided_amount(
|
||||
&boxes,
|
||||
pos,
|
||||
piston.shape_state(),
|
||||
new_progress,
|
||||
movement,
|
||||
body.bounds,
|
||||
);
|
||||
if slime && search.is_some_and(|area| area.intersects(body.bounds)) {
|
||||
self.bounce(target, movement);
|
||||
}
|
||||
if amount > 0.0 {
|
||||
let step = piston_push::step_vector(movement);
|
||||
let before = body.bounds;
|
||||
self.move_pushed(
|
||||
target,
|
||||
before,
|
||||
[body.position.x, body.position.z],
|
||||
body.step_height,
|
||||
body.on_ground,
|
||||
step.scale(amount),
|
||||
movement,
|
||||
accumulators,
|
||||
);
|
||||
// `fixEntityWithinPistonBase`: a retracting source piston drags
|
||||
// an entity that is still inside its own block.
|
||||
if !piston.extending
|
||||
&& piston.source
|
||||
&& let Some(after) = self.push_body(target, pos, &steps)
|
||||
&& let Some(fix) = piston_push::fix_within_piston_base(
|
||||
pos,
|
||||
after.bounds,
|
||||
movement,
|
||||
delta_progress,
|
||||
)
|
||||
{
|
||||
let back = piston_push::step_vector(movement.opposite());
|
||||
self.move_pushed(
|
||||
target,
|
||||
after.bounds,
|
||||
[after.position.x, after.position.z],
|
||||
after.step_height,
|
||||
after.on_ground,
|
||||
back.scale(fix),
|
||||
movement,
|
||||
accumulators,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Vanilla selects honey passengers after all collided entities have moved.
|
||||
if honey && movement.normal()[1] == 0 {
|
||||
let area = piston_push::sticky_area(pos, piston.shape_state(), sticky_top);
|
||||
for target in targets {
|
||||
let Some(body) = self.push_body(target, pos, &steps) else {
|
||||
continue;
|
||||
};
|
||||
if piston_push::sticky_entity_selected(
|
||||
area,
|
||||
body.bounds,
|
||||
horizon_world::level::data::PushReaction::PushPull,
|
||||
body.on_ground,
|
||||
body.supported,
|
||||
body.position,
|
||||
) {
|
||||
self.move_pushed(
|
||||
target,
|
||||
body.bounds,
|
||||
[body.position.x, body.position.z],
|
||||
body.step_height,
|
||||
body.on_ground,
|
||||
piston_push::step_vector(movement).scale(delta_progress),
|
||||
movement,
|
||||
accumulators,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.getStepHeight` for each body, read once: the attribute lookup
|
||||
/// needs the mutable body, and the push only needs the number.
|
||||
fn push_step_heights(&mut self) -> StepHeights {
|
||||
let player = self
|
||||
.player
|
||||
.as_mut()
|
||||
.and_then(|p| p.attribute("minecraft:step_height").ok())
|
||||
.map_or(0.6, |v| v as f32);
|
||||
let mut guests = std::collections::HashMap::new();
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
for g in &mut host.hub.guests {
|
||||
let step = g
|
||||
.motion
|
||||
.as_mut()
|
||||
.and_then(|m| m.attribute("minecraft:step_height").ok())
|
||||
.map_or(0.6, |v| v as f32);
|
||||
guests.insert(g.conn, step);
|
||||
}
|
||||
}
|
||||
StepHeights { player, guests }
|
||||
}
|
||||
|
||||
/// The entities a moving block can run into: the window's player, the LAN
|
||||
/// guests in the level, then the mobs.
|
||||
fn push_targets(&self) -> Vec<Target> {
|
||||
let mut targets = Vec::new();
|
||||
if self.player.is_some() {
|
||||
targets.push(Target::Player);
|
||||
}
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
targets.extend(
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
|
||||
.map(|g| Target::Guest(g.conn)),
|
||||
);
|
||||
}
|
||||
targets.extend((0..self.mobs.len()).map(Target::Mob));
|
||||
targets.extend((0..self.dropped.len()).map(Target::Item));
|
||||
targets.extend((0..self.boats.len()).map(Target::Boat));
|
||||
targets.extend((0..self.carts.len()).map(Target::Cart));
|
||||
targets.extend((0..self.armor_stands.len()).map(Target::ArmorStand));
|
||||
targets
|
||||
}
|
||||
|
||||
/// What the push needs from one entity, or `None` when it is not pushed:
|
||||
/// removed, dead, a spectator, or an armor-stand marker.
|
||||
fn push_body(&self, target: Target, pos: BlockPos, steps: &StepHeights) -> Option<Body> {
|
||||
let at = [pos.x, pos.y, pos.z];
|
||||
match target {
|
||||
Target::Player => {
|
||||
let p = self.player.as_ref()?;
|
||||
if p.spectator {
|
||||
return None;
|
||||
}
|
||||
Some(Body {
|
||||
bounds: p.bounds(),
|
||||
position: p.position,
|
||||
on_ground: p.ground.on_ground,
|
||||
step_height: steps.player,
|
||||
supported: p.ground.supporting_block == Some(pos),
|
||||
})
|
||||
}
|
||||
Target::Guest(conn) => {
|
||||
let host = self.lan.as_ref()?;
|
||||
let g = host.hub.guests.iter().find(|g| g.conn == conn)?;
|
||||
if g.dead || g.game_mode == 3 {
|
||||
return None;
|
||||
}
|
||||
let p = g.position;
|
||||
Some(Body {
|
||||
bounds: Aabb::new(
|
||||
Vec3::new(p[0] - 0.3, p[1], p[2] - 0.3),
|
||||
Vec3::new(p[0] + 0.3, p[1] + 1.8, p[2] + 0.3),
|
||||
),
|
||||
position: Vec3::new(p[0], p[1], p[2]),
|
||||
on_ground: g.on_ground,
|
||||
step_height: steps.guests.get(&conn).copied().unwrap_or(0.6),
|
||||
supported: g
|
||||
.motion
|
||||
.as_ref()
|
||||
.is_some_and(|m| m.ground.supporting_block == Some(pos)),
|
||||
})
|
||||
}
|
||||
Target::Mob(i) => {
|
||||
let m = &self.mobs[i];
|
||||
if m.removed || !m.alive() {
|
||||
return None;
|
||||
}
|
||||
Some(Body {
|
||||
bounds: m.bounds(),
|
||||
position: m.position,
|
||||
on_ground: m.on_ground,
|
||||
step_height: m.kind.step_height as f32,
|
||||
supported: m.supporting == Some(at),
|
||||
})
|
||||
}
|
||||
Target::Item(i) => {
|
||||
let entity = &self.dropped[i];
|
||||
Some(Body {
|
||||
bounds: entity.bounds(),
|
||||
position: entity.position,
|
||||
on_ground: entity.on_ground,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
Target::Boat(i) => {
|
||||
let entity = &self.boats[i];
|
||||
Some(Body {
|
||||
bounds: entity.bounds(),
|
||||
position: entity.position,
|
||||
on_ground: entity.on_ground,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
Target::Cart(i) => {
|
||||
let entity = &self.carts[i];
|
||||
Some(Body {
|
||||
bounds: entity.bounds(),
|
||||
position: entity.position,
|
||||
on_ground: entity.on_ground,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
Target::ArmorStand(i) => {
|
||||
let entity = &self.armor_stands[i];
|
||||
if entity.marker {
|
||||
return None;
|
||||
}
|
||||
Some(Body {
|
||||
bounds: entity.bounds(),
|
||||
position: entity.position,
|
||||
on_ground: entity.on_ground,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.move(MoverType.PISTON, movement)` for one pushed entity: the
|
||||
/// per-tick piston limit, then the block and world border collision scan.
|
||||
fn move_pushed(
|
||||
&mut self,
|
||||
target: Target,
|
||||
before: Aabb,
|
||||
at: [f64; 2],
|
||||
step_height: f32,
|
||||
on_ground: bool,
|
||||
movement: Vec3,
|
||||
piston_direction: Direction,
|
||||
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
|
||||
) {
|
||||
let Some(limited) = self.limit_piston(target, movement, accumulators) else {
|
||||
return;
|
||||
};
|
||||
// `limitPistonMovement` returns zero once the tick's budget is spent.
|
||||
if limited == Vec3::ZERO {
|
||||
return;
|
||||
}
|
||||
let resolved = with_piston_noclip(piston_direction, || {
|
||||
pushed_movement(
|
||||
SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
},
|
||||
self.border.geometry(),
|
||||
at,
|
||||
before,
|
||||
limited,
|
||||
step_height,
|
||||
on_ground,
|
||||
)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
});
|
||||
if resolved.length_squared() <= 1.0e-7
|
||||
&& limited.length_squared() - resolved.length_squared() >= 1.0e-7
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.apply_push(target, resolved);
|
||||
}
|
||||
|
||||
/// `Entity.limitPistonMovement`: each axis may travel at most 0.51 blocks
|
||||
/// per game tick, and a move touches only its first non-zero axis.
|
||||
fn limit_piston(
|
||||
&mut self,
|
||||
target: Target,
|
||||
movement: Vec3,
|
||||
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
|
||||
) -> Option<Vec3> {
|
||||
let time = self.world.game_time;
|
||||
let limit = |state: &mut PistonMovement| state.limit(movement, || Ok::<i64, ()>(time)).ok();
|
||||
match target {
|
||||
Target::Player => {
|
||||
let p = self.player.as_mut()?;
|
||||
limit(&mut p.piston)
|
||||
}
|
||||
Target::Guest(conn) => {
|
||||
let host = self.lan.as_mut()?;
|
||||
let g = host.hub.guests.iter_mut().find(|g| g.conn == conn)?;
|
||||
limit(&mut g.motion.as_deref_mut()?.piston)
|
||||
}
|
||||
Target::Mob(i) => limit(&mut self.mobs[i].piston),
|
||||
_ => limit(accumulators.entry(target).or_default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_push(&mut self, target: Target, movement: Vec3) {
|
||||
match target {
|
||||
Target::Player => {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = p.position.add_vector(movement);
|
||||
}
|
||||
}
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_mut()
|
||||
&& let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
|
||||
{
|
||||
// The body and the hub's copy of its position move together:
|
||||
// `leave_guest` writes the body back over the hub's.
|
||||
for (slot, delta) in g.position.iter_mut().zip(movement.components()) {
|
||||
*slot += delta;
|
||||
}
|
||||
if let Some(m) = g.motion.as_deref_mut() {
|
||||
m.position = m.position.add_vector(movement);
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Mob(i) => {
|
||||
let m = &mut self.mobs[i];
|
||||
m.position = m.position.add_vector(movement);
|
||||
}
|
||||
Target::Item(i) => {
|
||||
self.dropped[i].position = self.dropped[i].position.add_vector(movement)
|
||||
}
|
||||
Target::Boat(i) => self.boats[i].position = self.boats[i].position.add_vector(movement),
|
||||
Target::Cart(i) => self.carts[i].position = self.carts[i].position.add_vector(movement),
|
||||
Target::ArmorStand(i) => {
|
||||
self.armor_stands[i].position = self.armor_stands[i].position.add_vector(movement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `causeBounce`: a slime block sets the velocity along the movement axis.
|
||||
fn bounce(&mut self, target: Target, movement: Direction) {
|
||||
match target {
|
||||
Target::Player => {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.velocity = piston_push::bounce_velocity(movement, p.velocity);
|
||||
}
|
||||
}
|
||||
Target::Mob(i) => {
|
||||
let m = &mut self.mobs[i];
|
||||
m.velocity = piston_push::bounce_velocity(movement, m.velocity);
|
||||
}
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_mut()
|
||||
&& let Some(guest) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
|
||||
&& let Some(motion) = guest.motion.as_deref_mut()
|
||||
{
|
||||
motion.velocity = piston_push::bounce_velocity(movement, motion.velocity);
|
||||
}
|
||||
}
|
||||
Target::Item(i) => {
|
||||
self.dropped[i].velocity =
|
||||
piston_push::bounce_velocity(movement, self.dropped[i].velocity)
|
||||
}
|
||||
Target::Boat(i) => {
|
||||
self.boats[i].velocity =
|
||||
piston_push::bounce_velocity(movement, self.boats[i].velocity)
|
||||
}
|
||||
Target::Cart(i) => {
|
||||
self.carts[i].velocity =
|
||||
piston_push::bounce_velocity(movement, self.carts[i].velocity)
|
||||
}
|
||||
Target::ArmorStand(i) => {
|
||||
self.armor_stands[i].velocity =
|
||||
piston_push::bounce_velocity(movement, self.armor_stands[i].velocity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.move(MoverType.PISTON, movement)` for a pushed body: the movement
|
||||
/// it makes. The world's blocks stop it, and so does the world border:
|
||||
/// `Entity.collectCollidersIgnoringWorldBorder` adds the border's walls when
|
||||
/// the body is inside close to the border, as ordinary movement does.
|
||||
fn pushed_movement<W>(
|
||||
blocks: W,
|
||||
border: &BorderGeometry,
|
||||
at: [f64; 2],
|
||||
before: Aabb,
|
||||
movement: Vec3,
|
||||
step_height: f32,
|
||||
on_ground: bool,
|
||||
) -> horizon_client::Result<Vec3>
|
||||
where
|
||||
W: BlockCollisionWorld<Error = std::io::Error> + EntityMovementWorld<Error = std::io::Error>,
|
||||
{
|
||||
EntityCollisions::with_border(blocks, border, at).resolve(
|
||||
before,
|
||||
movement,
|
||||
step_height,
|
||||
on_ground,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::collisions::EmptyCollisions;
|
||||
|
||||
/// A border 10 wide around the origin: its wall's inner face is at x = 5.
|
||||
fn border() -> BorderGeometry {
|
||||
BorderGeometry::new([0.0, 0.0], 10.0, 29_999_984).expect("a border")
|
||||
}
|
||||
|
||||
/// `Entity.collectCollidersIgnoringWorldBorder`: the border's walls stop a
|
||||
/// pushed body that is inside close to the border.
|
||||
#[test]
|
||||
fn a_pushed_body_stops_at_the_world_border() {
|
||||
// A body 0.6 wide, 0.4 blocks short of the wall, pushed east into it.
|
||||
let before = Aabb::new(Vec3::new(4.0, 0.0, 0.0), Vec3::new(4.6, 1.8, 0.6));
|
||||
let border = border();
|
||||
let moved = pushed_movement(
|
||||
EmptyCollisions,
|
||||
&border,
|
||||
[4.3, 0.3],
|
||||
before,
|
||||
Vec3::new(0.51, 0.0, 0.0),
|
||||
0.0,
|
||||
false,
|
||||
)
|
||||
.expect("the push resolves");
|
||||
// The wall stops the body 0.4 blocks short of the whole push.
|
||||
assert!(
|
||||
(moved.x - 0.4).abs() < 1.0e-9,
|
||||
"the body was not stopped by the border: {moved:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Away from the border, the same push moves the whole way.
|
||||
#[test]
|
||||
fn a_pushed_body_moves_whole_away_from_the_border() {
|
||||
let before = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.6, 1.8, 0.6));
|
||||
let border = border();
|
||||
let moved = pushed_movement(
|
||||
EmptyCollisions,
|
||||
&border,
|
||||
[0.3, 0.3],
|
||||
before,
|
||||
Vec3::new(0.51, 0.0, 0.0),
|
||||
0.0,
|
||||
false,
|
||||
)
|
||||
.expect("the push resolves");
|
||||
assert!(
|
||||
(moved.x - 0.51).abs() < 1.0e-9,
|
||||
"the body did not move the whole push: {moved:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,8 @@ use horizon_client::{
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::RandomSource,
|
||||
geometry::{Aabb, Direction, Vec3},
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
|
||||
/// A thrown splash or lingering potion in flight.
|
||||
@@ -70,7 +70,7 @@ impl PotionCloud {
|
||||
}
|
||||
}
|
||||
/// A mob's effect ids carry the `minecraft:` namespace.
|
||||
fn mob_effect_id(id: &str) -> Option<&'static str> {
|
||||
pub(super) fn mob_effect_id(id: &str) -> Option<&'static str> {
|
||||
use std::{collections::HashMap, sync::OnceLock};
|
||||
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
let id = id.trim_start_matches("minecraft:");
|
||||
@@ -120,6 +120,153 @@ fn mob_can_be_affected(
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `LivingEntity.triggerOnDeathMobEffects` (`onMobRemoved` with the reason
|
||||
/// KILLED) for what Oozing, Weaving and Wind Charged do when their bearer
|
||||
/// dies. `bearer` is the dying mob's id, `player` whether it is a player,
|
||||
/// `random` the dying mob's own (`mob.getRandom()`; the world's for the
|
||||
/// player).
|
||||
pub(super) fn effects_on_death(
|
||||
&mut self,
|
||||
effects: &[&str],
|
||||
bounds: Aabb,
|
||||
player: bool,
|
||||
bearer: Option<u32>,
|
||||
mut random: Option<&mut LegacyRandom>,
|
||||
) {
|
||||
let feet = bounds.bottom_center();
|
||||
if effects.contains(&"oozing") {
|
||||
// OozingMobEffect.numberOfSlimesToSpawn: two, fewer when the
|
||||
// slimes close by already fill the cramming limit.
|
||||
let limit = self.ui.rules.int("max_entity_cramming");
|
||||
let mut count = 2;
|
||||
if limit >= 1 {
|
||||
let near = bounds.inflate(Vec3::new(2.0, 2.0, 2.0));
|
||||
let nearby = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.species() == horizon_client::mobs::Species::Slime
|
||||
&& Some(m.id) != bearer
|
||||
&& m.bounds().intersects(near)
|
||||
})
|
||||
.count()
|
||||
.min(limit as usize) as i32;
|
||||
count = (limit - nearby).clamp(0, count);
|
||||
}
|
||||
for _ in 0..count {
|
||||
// spawnSlimeOffspring: a size 2 slime half a block up.
|
||||
if let Some(id) = self.spawn_mob_for(
|
||||
"minecraft:slime",
|
||||
[feet.x, feet.y + 0.5, feet.z],
|
||||
false,
|
||||
true,
|
||||
) && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
horizon_client::mobs::cube::set_size(m, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
if effects.contains(&"weaving") && (player || self.ui.rules.bool("mob_griefing")) {
|
||||
self.spawn_cobwebs_around(
|
||||
[
|
||||
feet.x.floor() as i32,
|
||||
feet.y.floor() as i32,
|
||||
feet.z.floor() as i32,
|
||||
],
|
||||
random.as_deref_mut(),
|
||||
);
|
||||
}
|
||||
if effects.contains(&"wind_charged") {
|
||||
// A burst centred at half the height, radius 3 to 5.
|
||||
let center = [feet.x, feet.y + bounds.size().y / 2.0, feet.z];
|
||||
let r = random.unwrap_or(&mut self.world.random);
|
||||
let radius = 3.0 + r.next_float() * 2.0;
|
||||
let _ = self.wind_burst(center, radius, 1.0, false, false);
|
||||
}
|
||||
}
|
||||
/// `WeavingMobEffect.spawnCobwebsRandomlyAround`: two or three cobwebs in
|
||||
/// free spaces with a solid floor, picked among 15 random blocks within one
|
||||
/// block of `pos`.
|
||||
fn spawn_cobwebs_around(&mut self, pos: [i32; 3], mut random: Option<&mut LegacyRandom>) {
|
||||
let r = random.as_deref_mut().unwrap_or(&mut self.world.random);
|
||||
let wanted = 2 + r.next_int_bound(2).unwrap_or(0) as usize;
|
||||
let mut chosen: Vec<[i32; 3]> = Vec::new();
|
||||
for _ in 0..15 {
|
||||
let r = random.as_deref_mut().unwrap_or(&mut self.world.random);
|
||||
let at = [
|
||||
pos[0] - 1 + r.next_int_bound(3).unwrap_or(0),
|
||||
pos[1] - 1 + r.next_int_bound(3).unwrap_or(0),
|
||||
pos[2] - 1 + r.next_int_bound(3).unwrap_or(0),
|
||||
];
|
||||
let facts = self.res.facts();
|
||||
let here = facts.get(self.world.block(at[0], at[1], at[2]));
|
||||
let below = facts.get(self.world.block(at[0], at[1] - 1, at[2]));
|
||||
if !chosen.contains(&at)
|
||||
&& here.replaceable
|
||||
&& below.sturdy_full & (1 << Direction::Up as u8) != 0
|
||||
{
|
||||
chosen.push(at);
|
||||
if chosen.len() >= wanted {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for at in chosen {
|
||||
if self.set_state_named(at, "minecraft:cobweb", &[]).is_ok() {
|
||||
self.level_event_at(3018, at, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `InfestedMobEffect.onMobHurt` for the player: from half its height up,
|
||||
/// along where it looks.
|
||||
pub(super) fn player_infested(&mut self) {
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let at = [
|
||||
p.position.x,
|
||||
p.position.y + f64::from(p.dimensions.height) / 2.0,
|
||||
p.position.z,
|
||||
];
|
||||
let look = super::view_vector(self.yaw, self.pitch);
|
||||
self.infested_hurt(at, look, None);
|
||||
}
|
||||
}
|
||||
/// A living mob's own random (`Entity.random`), else the world's.
|
||||
fn bearer_random(&mut self, bearer: Option<u32>) -> &mut LegacyRandom {
|
||||
match bearer.and_then(|id| self.mobs.iter().position(|m| m.id == id)) {
|
||||
Some(i) => &mut self.mobs[i].random,
|
||||
None => &mut self.world.random,
|
||||
}
|
||||
}
|
||||
/// `InfestedMobEffect.onMobHurt`: a 10% chance of one or two silverfish at
|
||||
/// `at` (half the bearer's height up), thrown along its look direction.
|
||||
/// The draws are the bearer's (`mob.getRandom()`): `bearer` is the mob,
|
||||
/// none for the player (the world's random).
|
||||
pub(super) fn infested_hurt(&mut self, at: [f64; 3], look: [f64; 3], bearer: Option<u32>) {
|
||||
if self.bearer_random(bearer).next_float() > 0.1 {
|
||||
return;
|
||||
}
|
||||
let count = 1 + self.bearer_random(bearer).next_int_bound(2).unwrap_or(0);
|
||||
for _ in 0..count {
|
||||
// Mth.randomBetween(-PI / 2, PI / 2), then the look direction
|
||||
// scaled by (0.3, 0.45, 0.3) and turned about the y axis.
|
||||
let half = std::f32::consts::FRAC_PI_2;
|
||||
let angle = self.bearer_random(bearer).next_float() * (2.0 * half) - half;
|
||||
let (sin, cos) = angle.sin_cos();
|
||||
let (x, z) = (look[0] * 0.3, look[2] * 0.3);
|
||||
let velocity = Vec3::new(
|
||||
f64::from(x as f32 * cos + z as f32 * sin),
|
||||
look[1] * 0.3 * 1.5,
|
||||
f64::from(-x as f32 * sin + z as f32 * cos),
|
||||
);
|
||||
if let Some(id) = self.spawn_mob_for("minecraft:silverfish", at, false, true)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
m.velocity = velocity;
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:entity.silverfish.hurt", Some(at), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
/// A splash potion, lingering potion or experience bottle in flight (the
|
||||
/// player's throws and dispensers both end up here).
|
||||
pub(super) fn launch_thrown(
|
||||
@@ -473,12 +620,34 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
if player.health.dead_or_dying() {
|
||||
// LivingEntity.die → removeAllEffects.
|
||||
// LivingEntity.die → removeAllEffects. Vanilla runs the effects
|
||||
// that act on death (`onMobRemoved`) when the dead player is
|
||||
// removed, up to 20 ticks later; here it is as the effects go.
|
||||
let w = f64::from(player.dimensions.width) / 2.0;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(
|
||||
player.position.x - w,
|
||||
player.position.y,
|
||||
player.position.z - w,
|
||||
),
|
||||
Vec3::new(
|
||||
player.position.x + w,
|
||||
player.position.y + f64::from(player.dimensions.height),
|
||||
player.position.z + w,
|
||||
),
|
||||
);
|
||||
let ids: Vec<&'static str> = self.potions.effects.list.iter().map(|e| e.id).collect();
|
||||
self.potions.effects.clear();
|
||||
if !ids.is_empty() {
|
||||
self.effects_on_death(&ids, bounds, true, None, None);
|
||||
}
|
||||
}
|
||||
// The omens act from inside the effect tick (BadOmenMobEffect,
|
||||
// RaidOmenMobEffect).
|
||||
let absorption_empty = player.health.absorption() <= 0.0;
|
||||
let absorption_empty = self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.health.absorption() <= 0.0);
|
||||
let mut effects = std::mem::take(&mut self.potions.effects);
|
||||
let (actions, raid_omen_added) =
|
||||
effects.tick_with(&mut |e| self.omen_tick(e), absorption_empty);
|
||||
|
||||
@@ -762,7 +762,7 @@ impl Game<'_> {
|
||||
}
|
||||
positions.push((m.id, m.position, s.active_raid));
|
||||
}
|
||||
let banners: Vec<(usize, Vec3)> = self
|
||||
let banners: Vec<(usize, u32, Vec3)> = self
|
||||
.dropped
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -771,7 +771,7 @@ impl Game<'_> {
|
||||
&& e.stack.data().is_some_and(|d| d.ominous_banner)
|
||||
&& e.pickup_delay == 0
|
||||
})
|
||||
.map(|(i, e)| (i, e.position))
|
||||
.map(|(i, e)| (i, e.id, e.position))
|
||||
.collect();
|
||||
let mut taken: Vec<usize> = Vec::new();
|
||||
let mut collectors: Vec<(usize, u32)> = Vec::new();
|
||||
@@ -858,20 +858,33 @@ impl Game<'_> {
|
||||
&& raids.get(id).is_some_and(|r| r.leader(s.wave).is_none())
|
||||
&& !leaders.contains(&(id, s.wave))
|
||||
{
|
||||
let in_reach = |(i, _, p): &&(usize, u32, Vec3)| {
|
||||
!taken.contains(i)
|
||||
&& (p.x - position.x).abs() <= range
|
||||
&& (p.z - position.z).abs() <= range
|
||||
&& (p.y - position.y).abs() <= 8.0
|
||||
};
|
||||
let by_distance = |a: &&(usize, u32, Vec3), b: &&(usize, u32, Vec3)| {
|
||||
a.2.distance_squared(position)
|
||||
.total_cmp(&b.2.distance_squared(position))
|
||||
};
|
||||
// The unreachable cache only picks the navigation target;
|
||||
// a banner next to the raider is still picked up.
|
||||
let near = banners
|
||||
.iter()
|
||||
.filter(|(i, p)| {
|
||||
!taken.contains(i)
|
||||
&& (p.x - position.x).abs() <= range
|
||||
&& (p.z - position.z).abs() <= range
|
||||
&& (p.y - position.y).abs() <= 8.0
|
||||
.filter(in_reach)
|
||||
.filter(|(_, item, _)| {
|
||||
!s.unreachable_banners
|
||||
.iter()
|
||||
.any(|&(b, until)| b == *item && now < until)
|
||||
})
|
||||
.min_by(|a, b| {
|
||||
a.1.distance_squared(position)
|
||||
.total_cmp(&b.1.distance_squared(position))
|
||||
});
|
||||
if let Some((i, p)) = near {
|
||||
.min_by(by_distance);
|
||||
let nearest = banners.iter().filter(in_reach).min_by(by_distance);
|
||||
let adjacent =
|
||||
nearest.filter(|(_, _, p)| p.distance_squared(position) < 1.414 * 1.414);
|
||||
if let Some((i, item, p)) = adjacent.or(near) {
|
||||
s.banner_at = Some([p.x, p.y, p.z]);
|
||||
s.banner_item = *item;
|
||||
if p.distance_squared(position) < 1.414 * 1.414 {
|
||||
// pickUpItem: the banner becomes the wave's leader.
|
||||
taken.push(*i);
|
||||
|
||||
@@ -186,6 +186,9 @@ pub(super) struct Remote {
|
||||
online_mode: bool,
|
||||
/// Player entities in the level: entity id to profile UUID.
|
||||
level_players: HashMap<i32, u128>,
|
||||
/// Entities with shared flag 6 (`Entity.isCurrentlyGlowing` on a client); mobs keep
|
||||
/// theirs in `Mob::glowing`.
|
||||
glowing: std::collections::HashSet<i32>,
|
||||
/// The local player's profile UUID (always in the level).
|
||||
profile: u128,
|
||||
/// The local player's profile name: its score holder and team member.
|
||||
@@ -200,6 +203,8 @@ pub(super) struct Remote {
|
||||
stop_mining: bool,
|
||||
placing: Option<([i32; 3], u64)>,
|
||||
death_message: Option<String>,
|
||||
/// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`.
|
||||
show_death_screen: bool,
|
||||
player_type: Option<Arc<MobType>>,
|
||||
/// Inventory as last synchronised, for creative-mode slot updates.
|
||||
synced: Vec<Option<ItemStack>>,
|
||||
@@ -483,6 +488,7 @@ impl Game<'_> {
|
||||
listed: Default::default(),
|
||||
online_mode: false,
|
||||
level_players: HashMap::new(),
|
||||
glowing: Default::default(),
|
||||
profile: 0,
|
||||
profile_name: String::new(),
|
||||
destroying: None,
|
||||
@@ -492,6 +498,7 @@ impl Game<'_> {
|
||||
stop_mining: false,
|
||||
placing: None,
|
||||
death_message: None,
|
||||
show_death_screen: true,
|
||||
player_type,
|
||||
synced: vec![None; 36],
|
||||
decode_errors: 0,
|
||||
@@ -1430,6 +1437,13 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
// So does a firework rocket: its own tick flies it on.
|
||||
if path.is_some()
|
||||
&& let Some(f) = self.rockets.iter_mut().find(|f| f.id == id as u32)
|
||||
{
|
||||
f.position = Vec3::new(position[0], position[1], position[2]);
|
||||
return;
|
||||
}
|
||||
// A dragon fireball keeps Entity's NO_OP InterpolationHandler too:
|
||||
// the server's position replaces its own at once.
|
||||
if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
|
||||
@@ -1488,7 +1502,9 @@ impl Game<'_> {
|
||||
self.remove_remote_dragon(id);
|
||||
self.remove_remote_object(id as u32);
|
||||
self.remove_remote_projectile(id as u32);
|
||||
self.persist.uuids.remove(&(id as u32));
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.glowing.remove(&id);
|
||||
r.entities.remove(&id);
|
||||
r.lerps.remove(&id);
|
||||
r.stepped.remove(&id);
|
||||
@@ -1631,6 +1647,41 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleUpdateAttributes` for a mob: its maximum
|
||||
/// health (the hearts drawn over its mount) takes the server's value, from
|
||||
/// `AttributeInstance.calculateValue`.
|
||||
fn remote_mob_attributes(&mut self, id: i32, values: &[play::AttributeSnapshot]) {
|
||||
let catalog = &self.motion.attributes;
|
||||
let Some(max_health) = catalog.id("minecraft:max_health") else {
|
||||
return;
|
||||
};
|
||||
let Some(s) = values
|
||||
.iter()
|
||||
.find(|s| s.attribute == max_health.registry_id())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let amounts = |op: i32| {
|
||||
s.modifiers
|
||||
.iter()
|
||||
.filter(move |m| m.operation == op)
|
||||
.map(|m| m.amount)
|
||||
};
|
||||
let base = s.base + amounts(0).sum::<f64>();
|
||||
let mut value = base + amounts(1).map(|a| base * a).sum::<f64>();
|
||||
for amount in amounts(2) {
|
||||
value *= 1.0 + amount;
|
||||
}
|
||||
let value = catalog.attribute(max_health).definition.sanitize(value);
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
horizon_client::mobs::finalize::set_base(
|
||||
m,
|
||||
horizon_client::mobs::finalize::Attr::MaxHealth,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_packet(&mut self, packet: C) -> Result<()> {
|
||||
match packet {
|
||||
// ClientCommonPacketListenerImpl.handlePing runs on the game
|
||||
@@ -1651,6 +1702,7 @@ impl Game<'_> {
|
||||
r.online_mode = p.online_mode;
|
||||
r.entity_id = p.player_id;
|
||||
r.view_distance = p.chunk_radius;
|
||||
r.show_death_screen = p.show_death_screen;
|
||||
}
|
||||
self.streaming.radius =
|
||||
self.settings.render_distance.min(p.chunk_radius + 1).max(2);
|
||||
@@ -1805,6 +1857,11 @@ impl Game<'_> {
|
||||
r.level_players.remove(&p.id);
|
||||
}
|
||||
}
|
||||
// Entity.getTeam: a mob is on a team by its UUID.
|
||||
self.persist.uuids.insert(
|
||||
p.id as u32,
|
||||
horizon_client::lan::playerdata::uuid_ints(p.uuid),
|
||||
);
|
||||
self.spawn_entity(p.id, &kind, p.position, rotation);
|
||||
// Entity.recreateFromPacket: setDeltaMovement(packet.getMovement()).
|
||||
self.remote_motion(p.id, p.velocity);
|
||||
@@ -1896,7 +1953,8 @@ impl Game<'_> {
|
||||
}
|
||||
C::SetPassengers(p) => self.remote_set_passengers(p.vehicle, &p.passengers),
|
||||
// ClientPacketListener.handleEntityLinkPacket: who holds a mob's
|
||||
// lead (this player, another player or a mob), for the rope.
|
||||
// lead (this player, another player, a mob or a fence knot), for
|
||||
// the rope.
|
||||
C::SetEntityLink(p) => {
|
||||
let me = self.remote.as_ref().map(|r| r.entity_id);
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.source as u32) {
|
||||
@@ -1904,7 +1962,12 @@ impl Game<'_> {
|
||||
m.extra.leash = match p.dest {
|
||||
0 => None,
|
||||
d if Some(d) == me => Some(Holder::Player),
|
||||
d => Some(Holder::Mob(d as u32)),
|
||||
d => match self.remote_knots.get(&(d as u32)) {
|
||||
Some(&block) => Some(Holder::Knot(block)),
|
||||
// Not a known knot yet: kept by id until the knot
|
||||
// arrives (game/remote_objects.rs).
|
||||
None => Some(Holder::Mob(d as u32)),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1923,7 +1986,10 @@ impl Game<'_> {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
for (index, _, value) in &p.values {
|
||||
match (index, value) {
|
||||
(0, Value::Byte(flags)) => m.invisible = flags & 0x20 != 0,
|
||||
(0, Value::Byte(flags)) => {
|
||||
m.invisible = flags & 0x20 != 0;
|
||||
m.glowing = flags & 0x40 != 0;
|
||||
}
|
||||
(2, Value::OptText(name)) => {
|
||||
let text = name.as_ref().map(horizon_client::entity_nbt::name_text);
|
||||
m.custom_name =
|
||||
@@ -1940,6 +2006,15 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((_, _, Value::Byte(flags))) = p.values.iter().find(|v| v.0 == 0)
|
||||
&& let Some(r) = self.remote.as_mut()
|
||||
{
|
||||
if flags & 0x40 != 0 {
|
||||
r.glowing.insert(p.id);
|
||||
} else {
|
||||
r.glowing.remove(&p.id);
|
||||
}
|
||||
}
|
||||
self.remote_mob_data(p.id, &p.values);
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& m.species() == horizon_client::mobs::Species::Creeper
|
||||
@@ -2164,8 +2239,9 @@ impl Game<'_> {
|
||||
}
|
||||
C::HurtAnimation(p) => {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
// Player.animateHurt: the tilt turns around the yaw.
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.health.animate_hurt();
|
||||
pl.health.animate_hurt_dir(p.yaw);
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.hurt_time = 10;
|
||||
@@ -2202,6 +2278,10 @@ impl Game<'_> {
|
||||
{
|
||||
m.health = 0.0;
|
||||
}
|
||||
// The events that start a mob's animation (the warden's).
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
horizon_client::mobs::sync::entity_event(m, p.event as u8);
|
||||
}
|
||||
// LivingEntity.handleEntityEvent 60: the poof as the dead body goes.
|
||||
if p.event == 60
|
||||
&& let Some(m) = self.mobs.iter().find(|m| m.id == p.id as u32)
|
||||
@@ -2212,6 +2292,37 @@ impl Game<'_> {
|
||||
if p.event == super::totem::TOTEM_EVENT {
|
||||
self.remote_totem(p.id);
|
||||
}
|
||||
// Mob.handleEntityEvent 20: the spawn poof.
|
||||
if p.event == 20 && self.mobs.iter().any(|m| m.id == p.id as u32) {
|
||||
self.poof_particles(p.id as u32);
|
||||
}
|
||||
// Villager 12, 13 and 14, TamableAnimal and AbstractHorse 6 and
|
||||
// 7, Animal and Allay 18: the particles about the mob.
|
||||
if matches!(p.event, 6 | 7 | 12 | 13 | 14 | 18) {
|
||||
self.entity_event_particles(p.id as u32, p.event);
|
||||
}
|
||||
// Snowball and ThrownEgg.handleEntityEvent 3: the crumbs where
|
||||
// the projectile is (the server removes it right after).
|
||||
if p.event == 3 {
|
||||
let crumbs = self
|
||||
.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.find(|pr| pr.id == p.id as u32)
|
||||
.and_then(|pr| {
|
||||
let item = match pr.kind {
|
||||
horizon_client::projectiles::ProjectileKind::Snowball => {
|
||||
"minecraft:snowball"
|
||||
}
|
||||
horizon_client::projectiles::ProjectileKind::Egg => "minecraft:egg",
|
||||
_ => return None,
|
||||
};
|
||||
Some(([pr.position.x, pr.position.y, pr.position.z], item))
|
||||
});
|
||||
if let Some((at, item)) = crumbs {
|
||||
self.item_burst(at, item);
|
||||
}
|
||||
}
|
||||
if p.event == super::combat::DROWN_EVENT {
|
||||
self.remote_drown(p.id);
|
||||
}
|
||||
@@ -2262,6 +2373,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.apply_remote_health();
|
||||
}
|
||||
C::UpdateAttributes(p) => self.remote_mob_attributes(p.id, &p.values),
|
||||
C::UpdateMobEffect(p) => self.remote_effect(&p),
|
||||
C::RemoveMobEffect(p) => {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
|
||||
@@ -2286,7 +2398,11 @@ impl Game<'_> {
|
||||
pl.health.flash_health_change(p.health);
|
||||
}
|
||||
self.apply_remote_health();
|
||||
if p.health <= 0.0 && self.screen != Screen::Dead && self.player.is_some() {
|
||||
// The death screen opens (or the respawn is asked for) with the
|
||||
// death packet when the rule hides it.
|
||||
let shown = self.remote.as_ref().is_none_or(|r| r.show_death_screen);
|
||||
if p.health <= 0.0 && shown && self.screen != Screen::Dead && self.player.is_some()
|
||||
{
|
||||
self.screen = Screen::Dead;
|
||||
self.ui.death_time = 0;
|
||||
self.capture(false);
|
||||
@@ -2302,11 +2418,16 @@ impl Game<'_> {
|
||||
r.death_message = Some(message);
|
||||
}
|
||||
// ClientPacketListener.handlePlayerCombatKill: the death
|
||||
// screen, whatever health was last sent.
|
||||
// screen, whatever health was last sent, or `respawn()` at
|
||||
// once when it is hidden.
|
||||
if me && self.screen != Screen::Dead && self.player.is_some() {
|
||||
self.screen = Screen::Dead;
|
||||
self.ui.death_time = 0;
|
||||
self.capture(false);
|
||||
if self.remote.as_ref().is_none_or(|r| r.show_death_screen) {
|
||||
self.screen = Screen::Dead;
|
||||
self.ui.death_time = 0;
|
||||
self.capture(false);
|
||||
} else {
|
||||
self.respawn_player()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
C::TrackedWaypoint(p) => self.handle_waypoint(&p),
|
||||
@@ -2382,6 +2503,12 @@ impl Game<'_> {
|
||||
r.game_mode = mode;
|
||||
}
|
||||
}
|
||||
// IMMEDIATE_RESPAWN: LocalPlayer.setShowDeathScreen.
|
||||
11 => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.show_death_screen = p.param == 0.0;
|
||||
}
|
||||
}
|
||||
7 => {
|
||||
self.world.weather.rain_level = (p.param, p.param);
|
||||
self.world.weather.raining = p.param > 0.0;
|
||||
@@ -2899,7 +3026,7 @@ impl Game<'_> {
|
||||
resolved.push((uuid, skin));
|
||||
}
|
||||
}
|
||||
resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.show_skin(skin));
|
||||
resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.again_skin(skin));
|
||||
let Some((level, skins, own)) = self.remote.as_mut().map(|r| {
|
||||
r.skins = resolved.into_iter().collect();
|
||||
(
|
||||
@@ -2938,11 +3065,9 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.filter(|m| &*m.kind.id == "minecraft:mannequin" && m.profile.is_some())
|
||||
.map(|m| {
|
||||
let t = m
|
||||
.profile
|
||||
.as_ref()
|
||||
.and_then(|p| p.properties.iter().find(|p| p.name == "textures"));
|
||||
let textures = t.map_or_else(Default::default, |t| {
|
||||
let profile = m.profile.as_ref();
|
||||
let t = profile.and_then(|p| p.properties.iter().find(|p| p.name == "textures"));
|
||||
let textures = if let Some(t) = t {
|
||||
let signature = if t.signature.is_some() {
|
||||
horizon_client::skins::Signature::Signed
|
||||
} else {
|
||||
@@ -2953,7 +3078,30 @@ impl Game<'_> {
|
||||
signature,
|
||||
&horizon_client::skins::TEXTURE_URIS,
|
||||
)
|
||||
});
|
||||
} else {
|
||||
// `ResolvableProfile.resolveProfile`: no `textures` and
|
||||
// only a name or an id resolves through the services.
|
||||
let key = profile.and_then(|p| match (&p.name, p.uuid) {
|
||||
(Some(name), None) => {
|
||||
Some(horizon_client::skins::NameOrId::Name(name.clone()))
|
||||
}
|
||||
(None, Some(id)) => Some(horizon_client::skins::NameOrId::Id(id)),
|
||||
_ => None,
|
||||
});
|
||||
key.and_then(|key| match self.skin_profiles.borrow_mut().state(&key) {
|
||||
horizon_client::skins::Resolve::Done(Some(profile)) => {
|
||||
profile.textures().map(|(value, signature)| {
|
||||
horizon_client::skins::unpack(
|
||||
&value,
|
||||
signature,
|
||||
&horizon_client::skins::TEXTURE_URIS,
|
||||
)
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
(m.id, textures)
|
||||
})
|
||||
.collect();
|
||||
@@ -2968,7 +3116,7 @@ impl Game<'_> {
|
||||
resolved.push((id, skin));
|
||||
}
|
||||
for (id, skin) in resolved {
|
||||
let skin = skin.filter(|s| !s.starts_with("skins/") || self.show_skin(s));
|
||||
let skin = skin.filter(|s| !s.starts_with("skins/") || self.again_skin(s));
|
||||
if let Some(mob) = self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
@@ -3018,6 +3166,10 @@ impl Game<'_> {
|
||||
self.skin_slots.push(e);
|
||||
true
|
||||
}
|
||||
/// `show_skin`, or — its slot evicted — the image queued for upload again.
|
||||
fn again_skin(&mut self, location: &str) -> bool {
|
||||
self.show_skin(location) || self.skin_downloads.borrow_mut().again(location)
|
||||
}
|
||||
/// ChatType decoration: `chat.translation_key` with sender/content parameters.
|
||||
/// ChatType.Bound.decorate: the chat type's translation, with the
|
||||
/// sender, the target and the message as its parameters, in its style.
|
||||
@@ -3121,6 +3273,8 @@ impl Game<'_> {
|
||||
p.velocity = v;
|
||||
} else if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
|
||||
b.velocity = v;
|
||||
} else if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id as u32) {
|
||||
r.velocity = v;
|
||||
} else if let Some(p) = self
|
||||
.remote
|
||||
.as_mut()
|
||||
@@ -3234,6 +3388,15 @@ impl Game<'_> {
|
||||
|
||||
fn tick_remote_level(&mut self) -> Result<()> {
|
||||
self.ticks += 1;
|
||||
// AbstractSignEditScreen.tick: the editor closes once its sign is removed.
|
||||
if let Some(edit) = &self.sign_edit
|
||||
&& !matches!(
|
||||
self.world.block_entities.get(&edit.pos),
|
||||
Some(BlockEntity::Sign(_))
|
||||
)
|
||||
{
|
||||
self.close_sign_editor();
|
||||
}
|
||||
self.track_experience_display();
|
||||
// Leaving a slot clears its bundle's selection here and on the server.
|
||||
self.tick_bundle_hover();
|
||||
@@ -3282,6 +3445,7 @@ impl Game<'_> {
|
||||
self.border.tick();
|
||||
self.player_border();
|
||||
self.tick_remote_entities(running);
|
||||
self.tick_remote_guardian_eyes(running);
|
||||
// ClientLevel.tickBlockEntities: chest lids, shulker boxes, bells,
|
||||
// and the moving pistons a block event's client-side moveBlocks made
|
||||
// (their progress advances between the server's block updates).
|
||||
@@ -3289,6 +3453,8 @@ impl Game<'_> {
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.client_tick_block_entities(level, ticks);
|
||||
});
|
||||
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
|
||||
self.push_piston_entities(pushes);
|
||||
self.sync_moving_pistons();
|
||||
self.tick_hand();
|
||||
self.tick_particles();
|
||||
@@ -3583,6 +3749,43 @@ impl Game<'_> {
|
||||
}
|
||||
/// ClientLevel.tickEntities; a frozen level only ticks the players
|
||||
/// (`TickRateManager.isEntityFrozen`).
|
||||
/// `GuardianRenderer.getEntityToLookAt`: a guardian's eye follows its
|
||||
/// synched attack target, else the camera.
|
||||
fn tick_remote_guardian_eyes(&mut self, running: bool) {
|
||||
use horizon_client::mobs::{Species, Who, guardian};
|
||||
if !running {
|
||||
return;
|
||||
}
|
||||
let camera = self.player.as_ref().map(|p| {
|
||||
[
|
||||
p.position.x,
|
||||
p.position.y + f64::from(p.dimensions.eye_height),
|
||||
p.position.z,
|
||||
]
|
||||
});
|
||||
let looks: Vec<_> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| {
|
||||
matches!(m.species(), Species::Guardian | Species::ElderGuardian) && m.alive()
|
||||
})
|
||||
.filter_map(|(i, m)| {
|
||||
let look = match m.ai.target.filter(|_| m.extra.flag) {
|
||||
Some(Who::Mob(id)) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|t| t.id == id)
|
||||
.map(|t| [t.position.x, t.eye_y(), t.position.z]),
|
||||
_ => camera,
|
||||
};
|
||||
look.map(|look| (i, look))
|
||||
})
|
||||
.collect();
|
||||
for (i, look) in looks {
|
||||
guardian::look_at(&mut self.mobs[i], look);
|
||||
}
|
||||
}
|
||||
fn tick_remote_entities(&mut self, running: bool) {
|
||||
if running {
|
||||
self.tick_text_displays();
|
||||
@@ -3591,22 +3794,25 @@ impl Game<'_> {
|
||||
// server never sends the fluid contact, and the renderers read it (cod, salmon and
|
||||
// tropical fish lie on their side without `in_water`, #1591).
|
||||
// A frozen level ticks only the players, as below.
|
||||
let fluids: Vec<_> = {
|
||||
let (fluids, thundering): (Vec<_>, bool) = {
|
||||
let w = self.mob_world_base(&[]);
|
||||
let players = self.remote.as_ref().map(|r| &r.level_players);
|
||||
self.mobs
|
||||
let fluids = self
|
||||
.mobs
|
||||
.iter()
|
||||
.map(|m| {
|
||||
(running || players.is_some_and(|p| p.contains_key(&(m.id as i32))))
|
||||
.then(|| w.fluids(m.bounds(), m.eye_y()))
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
(fluids, w.thundering)
|
||||
};
|
||||
for (m, fluids) in self.mobs.iter_mut().zip(fluids) {
|
||||
if let Some(fluids) = fluids {
|
||||
m.fluids = fluids;
|
||||
}
|
||||
}
|
||||
let game_time = self.world.game_time;
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -3681,6 +3887,10 @@ impl Game<'_> {
|
||||
m.head_yaw += wrap_degrees(*yaw - m.head_yaw) / *steps as f32;
|
||||
*steps -= 1;
|
||||
}
|
||||
// Fox.tick: the head tilt and crouch follow the synched flags.
|
||||
if m.species() == horizon_client::mobs::Species::Fox {
|
||||
horizon_client::mobs::fox::client_tick(m);
|
||||
}
|
||||
// Chicken.aiStep: the wings follow the server's on-ground flag.
|
||||
if m.species() == horizon_client::mobs::Species::Chicken {
|
||||
horizon_client::mobs::farm::chicken_flap(m);
|
||||
@@ -3730,6 +3940,11 @@ impl Game<'_> {
|
||||
if m.species() == horizon_client::mobs::Species::Bat {
|
||||
horizon_client::mobs::bat::setup_animation_states(m);
|
||||
}
|
||||
// The animation states a server computes in its own `ai_step`.
|
||||
horizon_client::mobs::sync::client_tick(m, game_time);
|
||||
if m.species() == horizon_client::mobs::Species::Panda {
|
||||
horizon_client::mobs::panda::client_tick(m, thundering);
|
||||
}
|
||||
}
|
||||
for o in self.orbs.iter_mut().filter(|_| running) {
|
||||
o.previous = o.position;
|
||||
@@ -4331,6 +4546,16 @@ impl Game<'_> {
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) && hands_full {
|
||||
return (false, DryUse::default());
|
||||
}
|
||||
// BeehiveBlock.useItemOn: shears or a glass bottle on a full hive
|
||||
// (honey level 5) is SUCCESS, so the click ends after UseItemOn and
|
||||
// no UseItem follows (the server does the harvest, see `use_beehive`).
|
||||
if held.is_some_and(|s| matches!(&*s.item, "minecraft:shears" | "minecraft:glass_bottle"))
|
||||
&& self
|
||||
.hive_state(hit.pos)
|
||||
.is_some_and(|(honey, _)| honey == Some(5))
|
||||
{
|
||||
return (true, DryUse::default());
|
||||
}
|
||||
use horizon_core::geometry::Direction;
|
||||
let face = match hit.face {
|
||||
[0, 1, 0] => Direction::Up,
|
||||
@@ -5394,6 +5619,11 @@ impl Game<'_> {
|
||||
batch.text(atlas, message, (w - tw) / 2.0, 85.0, u32::MAX, true);
|
||||
}
|
||||
}
|
||||
/// Shared flag 6 of an entity on a server's client (`None` without a server).
|
||||
pub(super) fn remote_glowing(&self, id: u32) -> Option<bool> {
|
||||
let r = self.remote.as_ref()?;
|
||||
Some(r.glowing.contains(&(id as i32)))
|
||||
}
|
||||
/// Status for `horizon-ctl status`.
|
||||
/// The other players' entities in the level and their names (from the
|
||||
/// player list).
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
//! own simulation, which a client of a server doesn't run.
|
||||
use super::Game;
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
use horizon_client::mobs::{Mob, Species, Who, bat, tame};
|
||||
use horizon_client::mobs::{
|
||||
Mob, Species, Who, armadillo, bat, bee, camel, creaking, finalize, panda, sniffer, sync, tame,
|
||||
};
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_protocol::entity_data::{Particle, ParticleData, Value};
|
||||
use horizon_protocol::registry::Builtin;
|
||||
use horizon_protocol::registry::{Builtin, Dynamic};
|
||||
|
||||
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
|
||||
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
|
||||
@@ -27,6 +29,8 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
// client, which draws the flames. A client doesn't count the
|
||||
// fire down: the flag goes off when the server's fire is out.
|
||||
(0, Value::Byte(flags)) => m.fire_ticks = i32::from(flags & 1),
|
||||
// Entity.DATA_POSE: the pose the species animates by.
|
||||
(6, Value::Id(pose)) => sync::set_pose(m, (*pose).clamp(0, 17) as u8),
|
||||
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the
|
||||
// off hand 2. The flag starts and stops the use; the client ticks
|
||||
// the count itself (a bow's pull follows it).
|
||||
@@ -70,10 +74,23 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
Who::Mob(*target as u32)
|
||||
});
|
||||
}
|
||||
// Panda: the counters, the two genes and DATA_ID_FLAGS (after
|
||||
// AgeableMob's two entries).
|
||||
(Species::Panda, 18, Value::Int(n)) => panda::set_unhappy(m, *n),
|
||||
(Species::Panda, 19, Value::Int(n)) => panda::set_sneeze(m, *n),
|
||||
(Species::Panda, 20, Value::Int(n)) => panda::set_eat(m, *n),
|
||||
(Species::Panda, 21, Value::Byte(gene)) => m.extra.variant = (*gene).clamp(0, 6) as u8,
|
||||
(Species::Panda, 22, Value::Byte(gene)) => m.extra.variant2 = (*gene).clamp(0, 6) as u8,
|
||||
(Species::Panda, 23, Value::Byte(flags)) => panda::set_flags(m, *flags as u8),
|
||||
// PolarBear.DATA_STANDING_ID: the client animates the rearing.
|
||||
(Species::PolarBear, 18, Value::Bool(standing)) => m.extra.flag = *standing,
|
||||
// Bat.DATA_ID_FLAGS: resting in bit 0.
|
||||
(Species::Bat, 16, Value::Byte(flags)) => bat::set_resting(m, flags & 1 != 0),
|
||||
// Fox.DATA_TYPE_ID and DATA_FLAGS_ID.
|
||||
(Species::Fox, 18, Value::Int(variant)) => {
|
||||
m.extra.variant = (*variant).clamp(0, 1) as u8
|
||||
}
|
||||
(Species::Fox, 19, Value::Byte(flags)) => m.extra.ints[0] = i32::from(*flags as u8),
|
||||
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 sheared.
|
||||
(Species::Sheep, 18, Value::Byte(wool)) => {
|
||||
m.extra.color = (*wool & 15) as u8;
|
||||
@@ -100,6 +117,98 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
|
||||
m.extra.size = (*size).clamp(1, 127);
|
||||
}
|
||||
// Warden.CLIENT_ANGER_LEVEL: the heartbeat's pace.
|
||||
(Species::Warden, 16, Value::Int(anger)) => {
|
||||
if let Some(st) = m.extra.warden.as_mut() {
|
||||
st.client_anger = *anger;
|
||||
}
|
||||
}
|
||||
// Sniffer.DATA_STATE (after AgeableMob's two): the animation
|
||||
// follows it in `sync::client_tick`.
|
||||
(Species::Sniffer, 18, Value::Id(state)) => {
|
||||
sniffer::set_state(m, (*state).clamp(0, 6) as u8);
|
||||
}
|
||||
// Armadillo.ARMADILLO_STATE (after AgeableMob's two): the shell and
|
||||
// its animations follow it in `sync::client_tick`.
|
||||
(Species::Armadillo, 18, Value::Id(state)) => {
|
||||
armadillo::set_state(m, (*state).clamp(0, 3) as u8);
|
||||
}
|
||||
// Goat.DATA_HAS_LEFT_HORN and DATA_HAS_RIGHT_HORN: the model hides
|
||||
// a horn the goat lost.
|
||||
(Species::Goat, 19, Value::Bool(has)) => m.extra.lost_horns[0] = !*has,
|
||||
(Species::Goat, 20, Value::Bool(has)) => m.extra.lost_horns[1] = !*has,
|
||||
// Shulker.DATA_ATTACH_FACE_ID, DATA_PEEK_ID and DATA_COLOR_ID.
|
||||
(Species::Shulker, 16, Value::Id(face)) => m.extra.variant3 = (*face).clamp(0, 5) as u8,
|
||||
(Species::Shulker, 17, Value::Byte(peek)) => m.extra.counter = i32::from(*peek),
|
||||
(Species::Shulker, 18, Value::Byte(color)) => m.extra.color = *color as u8,
|
||||
// Camel.DASH and LAST_POSE_CHANGE_TICK (after AbstractHorse's
|
||||
// flags).
|
||||
(Species::Camel | Species::CamelHusk, 19, Value::Bool(dashing)) => {
|
||||
camel::set_dashing(m, *dashing);
|
||||
}
|
||||
(Species::Camel | Species::CamelHusk, 20, Value::Long(tick)) => {
|
||||
camel::set_synced_pose_tick(m, *tick);
|
||||
}
|
||||
// Vex.DATA_FLAGS_ID: charging in bit 0.
|
||||
(Species::Vex, 16, Value::Byte(flags)) => m.extra.flag = flags & 1 != 0,
|
||||
// Creaking.CAN_MOVE, IS_ACTIVE and IS_TEARING_DOWN.
|
||||
(Species::Creaking, 16, Value::Bool(can_move)) => m.extra.flag2 = !*can_move,
|
||||
(Species::Creaking, 17, Value::Bool(active)) => m.extra.flag = *active,
|
||||
(Species::Creaking, 18, Value::Bool(true)) => creaking::set_tearing_down(m),
|
||||
// Allay.DATA_DANCING and DATA_CAN_DUPLICATE (a cooldown running
|
||||
// is any non-zero `egg_time`).
|
||||
(Species::Allay, 16, Value::Bool(dancing)) => m.extra.flag = *dancing,
|
||||
(Species::Allay, 17, Value::Bool(can)) => m.extra.egg_time = i32::from(!*can),
|
||||
// Axolotl.DATA_PLAYING_DEAD (after DATA_VARIANT): the factors
|
||||
// ease in `sync::client_tick`.
|
||||
(Species::Axolotl, 19, Value::Bool(dead)) => m.extra.axolotl.playing_dead = *dead,
|
||||
// CopperGolem.COPPER_GOLEM_STATE (after DATA_WEATHER_STATE).
|
||||
(Species::CopperGolem, 17, Value::Id(state)) => {
|
||||
m.extra.variant3 = (*state).clamp(0, 4) as u8;
|
||||
}
|
||||
// AbstractHorse's flags take 18. Horse.DATA_ID_TYPE_VARIANT: the
|
||||
// colour in the low byte, the markings in the next.
|
||||
(Species::Horse, 19, Value::Int(v)) => {
|
||||
m.extra.variant = (v & 0xFF).clamp(0, 6) as u8;
|
||||
m.extra.variant2 = ((v >> 8) & 0xFF).clamp(0, 4) as u8;
|
||||
}
|
||||
// AbstractChestedHorse.DATA_ID_CHEST, then Llama.DATA_STRENGTH_ID
|
||||
// and DATA_VARIANT_ID.
|
||||
(
|
||||
Species::Donkey | Species::Mule | Species::Llama | Species::TraderLlama,
|
||||
19,
|
||||
Value::Bool(chested),
|
||||
) => m.extra.chested = *chested,
|
||||
(Species::Llama | Species::TraderLlama, 20, Value::Int(strength)) => {
|
||||
m.extra.variant2 = (*strength).clamp(1, 5) as u8;
|
||||
}
|
||||
(Species::Llama | Species::TraderLlama, 21, Value::Int(variant)) => {
|
||||
m.extra.variant = (*variant).clamp(0, 3) as u8;
|
||||
}
|
||||
// Axolotl.DATA_VARIANT (lucy 0 to blue 4) and Parrot.DATA_VARIANT_ID
|
||||
// (after TamableAnimal's flags and owner).
|
||||
(Species::Axolotl, 18, Value::Int(v)) => m.extra.variant = (*v).clamp(0, 4) as u8,
|
||||
(Species::Parrot, 20, Value::Int(v)) => m.extra.variant = (*v).clamp(0, 4) as u8,
|
||||
// Rabbit.DATA_TYPE_ID: the killer bunny's 99 has no model here.
|
||||
(Species::Rabbit, 18, Value::Int(t)) if (0..=5).contains(t) => {
|
||||
m.extra.variant = *t as u8;
|
||||
}
|
||||
// Bee.DATA_FLAGS_ID and DATA_ANGER_END_TIME (after Animal's).
|
||||
(Species::Bee, 18, Value::Byte(flags)) => bee::set_synced(m, Some(*flags as u8), None),
|
||||
(Species::Bee, 19, Value::Long(end)) => bee::set_synced(m, None, Some(*end)),
|
||||
// AbstractFish.FROM_BUCKET takes 16: Salmon.DATA_TYPE (small 0
|
||||
// to large 2), Pufferfish.PUFF_STATE and
|
||||
// TropicalFish.DATA_ID_TYPE_VARIANT.
|
||||
(Species::Salmon, 17, Value::Int(v)) => m.extra.variant = (*v).clamp(0, 2) as u8,
|
||||
(Species::Pufferfish, 17, Value::Int(state)) => m.extra.size = (*state).clamp(0, 2),
|
||||
(Species::TropicalFish, 17, Value::Int(v)) => tropical_fish(m, *v),
|
||||
// Ghast.DATA_IS_CHARGING and WitherBoss.DATA_ID_INV.
|
||||
(Species::Ghast, 16, Value::Bool(charging)) => m.extra.flag = *charging,
|
||||
(Species::Wither, 19, Value::Int(ticks)) => m.extra.counter = (*ticks).max(0),
|
||||
// CopperGolem.DATA_WEATHER_STATE: the oxidation the texture shows.
|
||||
(Species::CopperGolem, 16, Value::Id(state)) => {
|
||||
m.extra.variant = (*state).clamp(0, 3) as u8;
|
||||
}
|
||||
// Pillager.IS_CHARGING_CROSSBOW: the charge pose (Raider's
|
||||
// IS_CELEBRATING takes 16).
|
||||
(Species::Pillager, 17, Value::Bool(charging)) => m.extra.flag = *charging,
|
||||
@@ -121,6 +230,56 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
}
|
||||
}
|
||||
|
||||
/// A tropical fish's packed variant: the pattern's id (the size, then the
|
||||
/// index << 8), the base colour << 16 and the pattern colour << 24. Here the
|
||||
/// pattern is 0 to 5 for the small ones and 6 to 11 for the large ones.
|
||||
fn tropical_fish(m: &mut Mob, packed: i32) {
|
||||
let (size, index) = (packed & 0xFF, (packed >> 8) & 0xFF);
|
||||
// TropicalFish.Pattern.byId: an unknown id is a kob.
|
||||
m.extra.variant = if size <= 1 && index < 6 {
|
||||
(size * 6 + index) as u8
|
||||
} else {
|
||||
0
|
||||
};
|
||||
m.extra.variant2 = ((packed >> 16) & 0xFF) as u8 & 15;
|
||||
m.extra.variant3 = ((packed >> 24) & 0xFF) as u8 & 15;
|
||||
}
|
||||
|
||||
/// The values of one SetEntityData packet that name an entry of a
|
||||
/// synchronized registry (the variants of the cat, wolf, frog, pig, cow,
|
||||
/// chicken and zombie nautilus) or count game time (a wolf's anger).
|
||||
/// `game_time` is the client level's.
|
||||
fn apply_registry_data(
|
||||
m: &mut Mob,
|
||||
values: &[(u8, i32, Value)],
|
||||
registries: &Dynamic,
|
||||
game_time: i64,
|
||||
) {
|
||||
let species = m.species();
|
||||
for (index, _, value) in values {
|
||||
let registry = match (species, *index, value) {
|
||||
(Species::Cat, 20, Value::Id(_)) => "minecraft:cat_variant",
|
||||
(Species::Wolf, 23, Value::Id(_)) => "minecraft:wolf_variant",
|
||||
(Species::Frog, 18, Value::Id(_)) => "minecraft:frog_variant",
|
||||
(Species::Pig, 19, Value::Id(_)) => "minecraft:pig_variant",
|
||||
(Species::Cow, 18, Value::Id(_)) => "minecraft:cow_variant",
|
||||
(Species::Chicken, 18, Value::Id(_)) => "minecraft:chicken_variant",
|
||||
(Species::ZombieNautilus, 21, Value::Id(_)) => "minecraft:zombie_nautilus_variant",
|
||||
// Wolf.DATA_ANGER_END_TIME: the angry texture while it lies ahead.
|
||||
(Species::Wolf, 22, Value::Long(end)) => {
|
||||
m.extra.anger = (end - game_time).clamp(0, i64::from(i32::MAX)) as i32;
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
if let Value::Id(id) = value
|
||||
&& let Some(name) = registries.name(registry, *id)
|
||||
{
|
||||
m.extra.variant = finalize::variant_index(species, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The options of a synched particle list: an entity effect carries its ARGB
|
||||
/// colour, the omens none.
|
||||
fn swirls(list: &[Particle], builtin: &Builtin) -> Vec<ParticleOptions> {
|
||||
@@ -175,6 +334,7 @@ impl Game<'_> {
|
||||
};
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
apply(m, values, &r.builtin, r.entity_id);
|
||||
apply_registry_data(m, values, &r.registries, self.world.game_time);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Boats, minecarts, armor stands, item frames and paintings on a server's
|
||||
//! Boats, minecarts, armor stands, item frames, paintings and fence knots on a server's
|
||||
//! client (`ClientPacketListener.handleAddEntity`, `handleSetEntityData`,
|
||||
//! `handleSetEquipment`): the server simulates them, the client places them
|
||||
//! where it says and interpolates their moves like a mob's.
|
||||
use super::Game;
|
||||
use horizon_client::entities::{ArmorStand, Cushion, ItemFrame, Painting};
|
||||
use horizon_client::mobs::leash::Holder;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use std::sync::Arc;
|
||||
@@ -24,7 +25,17 @@ impl Game<'_> {
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
let block = position.map(|v| v.floor() as i32);
|
||||
self.remove_remote_object(id);
|
||||
if kind == "minecraft:text_display" {
|
||||
if kind == "minecraft:leash_knot" {
|
||||
// A lead that came before its knot waits as a mob holder with the
|
||||
// knot's id (handleEntityLinkPacket's delayed holder); now it can
|
||||
// be tied to the fence.
|
||||
self.remote_knots.insert(id, block);
|
||||
for m in &mut self.mobs {
|
||||
if m.extra.leash == Some(Holder::Mob(id)) {
|
||||
m.extra.leash = Some(Holder::Knot(block));
|
||||
}
|
||||
}
|
||||
} else if kind == "minecraft:text_display" {
|
||||
self.text_displays.insert(
|
||||
id as i32,
|
||||
super::text_displays::RemoteTextDisplay::new(position, yaw),
|
||||
@@ -153,6 +164,10 @@ impl Game<'_> {
|
||||
}
|
||||
(3, Value::Bool(visible)) => a.name_visible = *visible,
|
||||
(9, Value::Float(health)) => a.health = *health,
|
||||
// ArmorStand.DATA_HEAD_POSE to DATA_RIGHT_LEG_POSE.
|
||||
(16..=21, Value::Floats(r)) if r.len() == 3 => {
|
||||
a.pose[usize::from(index - 16)] = [r[0], r[1], r[2]];
|
||||
}
|
||||
// ArmorStand.DATA_CLIENT_FLAGS.
|
||||
(15, Value::Byte(flags)) => {
|
||||
a.small = flags & 1 != 0;
|
||||
@@ -304,6 +319,7 @@ impl Game<'_> {
|
||||
/// Every object a server sent (a new connection, level or dimension).
|
||||
pub(super) fn clear_remote_objects(&mut self) {
|
||||
self.text_displays.clear();
|
||||
self.remote_knots.clear();
|
||||
self.carts.clear();
|
||||
self.boats.clear();
|
||||
self.armor_stands.clear();
|
||||
@@ -314,6 +330,7 @@ impl Game<'_> {
|
||||
|
||||
pub(super) fn remove_remote_object(&mut self, id: u32) {
|
||||
self.text_displays.remove(&(id as i32));
|
||||
self.remote_knots.remove(&id);
|
||||
self.carts.retain(|c| c.id != id);
|
||||
self.boats.retain(|b| b.id != id);
|
||||
self.armor_stands.retain(|a| a.id != id);
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
//! and `handleSetEntityData`): the server simulates them, the client draws
|
||||
//! them where it says and interpolates their moves.
|
||||
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector};
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_client::projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld};
|
||||
use horizon_client::{
|
||||
collisions::SceneCollisions,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
@@ -71,7 +74,8 @@ pub(super) fn cloud_client_tick(
|
||||
];
|
||||
(particle.clone(), v)
|
||||
};
|
||||
particles.add(&options, at, velocity);
|
||||
// `addAlwaysVisibleParticle`.
|
||||
particles.add_with(&options, at, velocity, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,31 +448,58 @@ impl Game<'_> {
|
||||
let id = t.id;
|
||||
t.remote_step(|at| step(id, at));
|
||||
}
|
||||
// FireworkRocketEntity.tick: a rocket attached to the gliding local
|
||||
// player boosts it client-side too (attachedToEntity.isFallFlying).
|
||||
// FireworkRocketEntity.tick: a rocket attached to an entity follows it
|
||||
// and boosts it client-side too (attachedToEntity.isFallFlying), and
|
||||
// every rocket leaves its spark trail on a client.
|
||||
let me = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| u32::try_from(r.entity_id).ok());
|
||||
for r in &mut self.rockets {
|
||||
let id = r.id;
|
||||
r.remote_step(|at| step(id, at));
|
||||
if me.is_some()
|
||||
&& r.attached_to == me
|
||||
&& let Some(p) = self.player.as_mut()
|
||||
&& p.fall_flying
|
||||
{
|
||||
let look = view_vector(self.yaw, self.pitch);
|
||||
let m = p.velocity;
|
||||
p.velocity = Vec3::new(
|
||||
m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5,
|
||||
m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5,
|
||||
m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5,
|
||||
);
|
||||
r.position = p.position;
|
||||
r.velocity = p.velocity;
|
||||
let mut rockets = std::mem::take(&mut self.rockets);
|
||||
for r in &mut rockets {
|
||||
r.remote_step(&mut SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
});
|
||||
if let Some(target) = r.attached_to {
|
||||
if me == Some(target) {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
if p.fall_flying {
|
||||
let look = view_vector(self.yaw, self.pitch);
|
||||
let m = p.velocity;
|
||||
p.velocity = Vec3::new(
|
||||
m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5,
|
||||
m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5,
|
||||
m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5,
|
||||
);
|
||||
}
|
||||
r.position = p.position;
|
||||
r.velocity = p.velocity;
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter().find(|m| m.id == target) {
|
||||
// Another player's motion is not known here: its step.
|
||||
r.velocity = Vec3::new(
|
||||
m.position.x - r.previous.x,
|
||||
m.position.y - r.previous.y,
|
||||
m.position.z - r.previous.z,
|
||||
);
|
||||
r.position = m.position;
|
||||
}
|
||||
}
|
||||
let g = |g: &mut horizon_core::random::LegacyRandom| g.next_gaussian() * 0.05;
|
||||
let v = [
|
||||
g(&mut self.world.random),
|
||||
-r.velocity.y * 0.5,
|
||||
g(&mut self.world.random),
|
||||
];
|
||||
self.particles.add(
|
||||
&ParticleOptions::simple(ParticleType::Firework),
|
||||
[r.position.x, r.position.y, r.position.z],
|
||||
v,
|
||||
);
|
||||
}
|
||||
self.rockets = rockets;
|
||||
for c in &mut self.crystals {
|
||||
c.time += 1;
|
||||
c.position = step(c.id, c.position);
|
||||
|
||||
@@ -245,6 +245,7 @@ impl Game<'_> {
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
self.particles.particle_level = self.settings.particles;
|
||||
if let Some(e) = eye {
|
||||
self.particles.camera = e;
|
||||
}
|
||||
@@ -288,13 +289,16 @@ impl Game<'_> {
|
||||
e[2].floor() as i32,
|
||||
];
|
||||
animate_tick(self.res.animate(), &mut self.particles, &ctx, block);
|
||||
// The Horizon Weather option off means no rain visuals at all.
|
||||
// The Horizon Weather option off means no rain visuals or sounds at all.
|
||||
if rain > 0.0 && self.settings.horizon.weather {
|
||||
// ClientLevel.tickWeatherEffects: rain splashes within the weather radius.
|
||||
// ClientLevel.tickWeatherEffects: rain splashes within the weather
|
||||
// radius, and the rain sound.
|
||||
let mut random = LegacyRandom::new(self.world.game_time.wrapping_mul(312_987_231));
|
||||
let radius = self.settings.weather_radius;
|
||||
let diameter = 2 * radius + 1;
|
||||
let count = (0.225 * (diameter * diameter) as f32 * rain * rain) as i32;
|
||||
let count = (0.225 * (diameter * diameter) as f32 * rain * rain) as i32
|
||||
/ if self.settings.particles == 1 { 2 } else { 1 };
|
||||
let mut rain_position = None;
|
||||
for _ in 0..count {
|
||||
let x = block[0] + random.next_int_bound(diameter).unwrap_or(0) - radius;
|
||||
let z = block[2] + random.next_int_bound(diameter).unwrap_or(0) - radius;
|
||||
@@ -307,6 +311,12 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let below = top - 1;
|
||||
rain_position = Some([x, below, z]);
|
||||
// The Minimal option stops at the first spot that rains (the
|
||||
// rain sound still plays there).
|
||||
if self.settings.particles >= 2 {
|
||||
break;
|
||||
}
|
||||
let (bx, bz) = (random.next_double(), random.next_double());
|
||||
let state = world.block(x, below, z);
|
||||
let f = facts.get(state);
|
||||
@@ -338,6 +348,26 @@ impl Game<'_> {
|
||||
self.particles
|
||||
.add(&ParticleOptions::simple(ty), p, [0.0; 3]);
|
||||
}
|
||||
// The last rain column of the tick makes the sound, more often the
|
||||
// longer it has been quiet; indoors it is the quieter one.
|
||||
if let Some(at) = rain_position {
|
||||
let waited = self.rain_sound_time;
|
||||
self.rain_sound_time += 1;
|
||||
if random.next_int_bound(3).unwrap_or(0) < waited {
|
||||
self.rain_sound_time = 0;
|
||||
let roof =
|
||||
motion_blocking(world, facts, block[0], block[2], MIN_Y + HEIGHT);
|
||||
let (name, volume, pitch) = if at[1] > block[1] + 1 && roof > block[1] {
|
||||
("minecraft:weather.rain.above", 0.1, 0.5)
|
||||
} else {
|
||||
("minecraft:weather.rain", 0.2, 1.0)
|
||||
};
|
||||
let p = at.map(|c| f64::from(c) + 0.5);
|
||||
// playLocalSound: guests make their own rain, so a host
|
||||
// must not capture it for them.
|
||||
self.audio.play_unshared(name, Some(p), volume, pitch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(r) = &self.renderer {
|
||||
|
||||
@@ -322,6 +322,58 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// LocalPlayer.respawn: the player comes back (the death screen's
|
||||
/// button, or at once with `immediate_respawn`).
|
||||
pub(super) fn respawn_player(&mut self) -> Result<()> {
|
||||
if self.remote.is_none() {
|
||||
// PlayerList.respawn removes the dead player, and Player.remove's
|
||||
// inventoryMenu.removed drops what the cursor held, whatever
|
||||
// keep_inventory says (the 20-tick branch handles a later respawn).
|
||||
if let Some(stack) = self.carried.take()
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
self.throw_from_eyes(stack, eye, false);
|
||||
}
|
||||
let keep = self.ui.rules.bool("keep_inventory") || self.spectator();
|
||||
self.experience.respawn(keep);
|
||||
// The guests are sent the respawned host as a new entity, not the dead body.
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
let id = host.host_entity;
|
||||
host.hub.respawned(id);
|
||||
}
|
||||
// Autosaves keep a login position; death must resolve the respawn point instead.
|
||||
self.level.position = None;
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
self.ui.death_text = None;
|
||||
self.toggles.respawn();
|
||||
self.remote_respawn();
|
||||
} else if self.respawn_config.is_some() {
|
||||
// LocalPlayer.respawn: KeyMapping.resetToggleKeys.
|
||||
self.toggles.respawn();
|
||||
// PlayerList.respawn at the respawn anchor, once it is loaded.
|
||||
self.begin_anchor_respawn()?;
|
||||
self.screen = Screen::Playing;
|
||||
self.ui.death_text = None;
|
||||
self.spawn_player()?;
|
||||
self.capture(true);
|
||||
} else {
|
||||
// LocalPlayer.respawn: KeyMapping.resetToggleKeys.
|
||||
self.toggles.respawn();
|
||||
self.respawn_out_of_end()?;
|
||||
self.player = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.ui.death_text = None;
|
||||
self.spawn_player()?;
|
||||
self.capture(true);
|
||||
}
|
||||
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
|
||||
// respawned player only watches.
|
||||
if self.remote.is_none() && self.level.hardcore {
|
||||
self.set_mode(GameMode::Spectator);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn death_click(&mut self) -> Result<()> {
|
||||
if self
|
||||
.ui
|
||||
@@ -339,51 +391,11 @@ impl Game<'_> {
|
||||
.into_iter()
|
||||
.find(|d| ui::hovered(self.cursor, d.0, d.1, 200.0, 20.0))
|
||||
.map(|d| d.3);
|
||||
if hit == Some(0) && self.remote.is_none() {
|
||||
let keep = self.ui.rules.bool("keep_inventory") || self.spectator();
|
||||
self.experience.respawn(keep);
|
||||
// The guests are sent the respawned host as a new entity, not the dead body.
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
let id = host.host_entity;
|
||||
host.hub.respawned(id);
|
||||
}
|
||||
// Autosaves keep a login position; death must resolve the respawn point instead.
|
||||
self.level.position = None;
|
||||
}
|
||||
match hit {
|
||||
Some(0) if self.remote.is_some() => {
|
||||
self.ui.death_text = None;
|
||||
self.toggles.respawn();
|
||||
self.remote_respawn();
|
||||
}
|
||||
Some(0) if self.respawn_config.is_some() => {
|
||||
// LocalPlayer.respawn: KeyMapping.resetToggleKeys.
|
||||
self.toggles.respawn();
|
||||
// PlayerList.respawn at the respawn anchor, once it is loaded.
|
||||
self.begin_anchor_respawn()?;
|
||||
self.screen = Screen::Playing;
|
||||
self.ui.death_text = None;
|
||||
self.spawn_player()?;
|
||||
self.capture(true);
|
||||
}
|
||||
Some(0) => {
|
||||
// LocalPlayer.respawn: KeyMapping.resetToggleKeys.
|
||||
self.toggles.respawn();
|
||||
self.respawn_out_of_end()?;
|
||||
self.player = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.ui.death_text = None;
|
||||
self.spawn_player()?;
|
||||
self.capture(true);
|
||||
}
|
||||
Some(0) => self.respawn_player()?,
|
||||
Some(1) => self.close_world()?,
|
||||
_ => {}
|
||||
}
|
||||
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
|
||||
// respawned player only watches.
|
||||
if hit == Some(0) && self.remote.is_none() && self.level.hardcore {
|
||||
self.set_mode(GameMode::Spectator);
|
||||
}
|
||||
if hit.is_some() {
|
||||
self.audio.play_button_click();
|
||||
}
|
||||
|
||||
@@ -1515,6 +1515,16 @@ impl Game<'_> {
|
||||
fn tick_entity_events(&mut self) {
|
||||
self.tick_player_sculk_events();
|
||||
self.tick_mob_sculk_events();
|
||||
// Dropped items are entities too: Entity.applyEffectsFromBlocks.
|
||||
let items: Vec<[f64; 3]> = self
|
||||
.dropped
|
||||
.iter()
|
||||
.filter(|i| i.on_ground)
|
||||
.map(|i| [i.position.x, i.position.y, i.position.z])
|
||||
.collect();
|
||||
for at in items {
|
||||
self.step_on_redstone_ore(at, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Entity.move emits on the authoritative server too, for each ServerPlayer.
|
||||
@@ -1530,6 +1540,9 @@ impl Game<'_> {
|
||||
});
|
||||
if let Some((at, on_ground, health, flying)) = player {
|
||||
let crouch = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
if on_ground && !self.spectator() {
|
||||
self.step_on_redstone_ore(at, crouch);
|
||||
}
|
||||
let in_water = self.in_water_at(at);
|
||||
self.entity_motion_events(
|
||||
Who::Player,
|
||||
@@ -1548,6 +1561,26 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `RedStoneOreBlock.stepOn`: standing on the ore lights it, unless the
|
||||
/// entity is stepping carefully (`attack_block` only lights an unlit ore).
|
||||
fn step_on_redstone_ore(&mut self, at: [f64; 3], carefully: bool) {
|
||||
if carefully {
|
||||
return;
|
||||
}
|
||||
// getOnPosLegacy: 0.2 below the feet.
|
||||
let pos = [
|
||||
at[0].floor() as i32,
|
||||
(at[1] - 0.2).floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
];
|
||||
if matches!(
|
||||
&*self.block_name(pos),
|
||||
"minecraft:redstone_ore" | "minecraft:deepslate_redstone_ore"
|
||||
) {
|
||||
let _ = self.attack_block(pos);
|
||||
}
|
||||
}
|
||||
|
||||
fn tick_mob_sculk_events(&mut self) {
|
||||
let snapshot: Vec<(u32, [f64; 3], bool, bool, Species, Option<i64>)> = self
|
||||
.mobs
|
||||
@@ -1565,6 +1598,9 @@ impl Game<'_> {
|
||||
})
|
||||
.collect();
|
||||
for (id, at, on_ground, in_water, species, damaged) in snapshot {
|
||||
if on_ground {
|
||||
self.step_on_redstone_ore(at, false);
|
||||
}
|
||||
let emits = !matches!(species, Species::Shulker);
|
||||
if emits {
|
||||
self.entity_motion_events(Who::Mob(id), at, on_ground, in_water, false, false);
|
||||
|
||||
@@ -67,6 +67,13 @@ impl PackPrompt {
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerPackState {
|
||||
/// The `ToastManager` slots the download toasts hold.
|
||||
pub(super) fn toast_slots(&self) -> impl Iterator<Item = usize> + '_ {
|
||||
self.notifiers.iter().filter_map(|n| n.toast.as_ref()?.slot)
|
||||
}
|
||||
}
|
||||
|
||||
/// The game's `DownloadedPackSource`.
|
||||
#[derive(Default)]
|
||||
pub(super) struct ServerPackState {
|
||||
@@ -503,15 +510,22 @@ impl Game<'_> {
|
||||
.layout(atlas, (w, h))
|
||||
.draw(batch, atlas, self.cursor, [&yes, &no]);
|
||||
}
|
||||
/// The download notifiers' toasts, each in the next 32-pixel slot
|
||||
/// below an advancement or recipe toast (`ToastManager`).
|
||||
pub(super) fn draw_pack_toasts(&mut self, batch: &mut HudBatch) {
|
||||
/// The download notifiers' toasts, each in a free 32-pixel slot of the
|
||||
/// `ToastManager` (`hidden`, F1, keeps them timing out undrawn).
|
||||
pub(super) fn draw_pack_toasts(&mut self, batch: &mut HudBatch, hidden: bool) {
|
||||
let multiplier = f64::from(self.settings.notification_display_time);
|
||||
let mut slot = usize::from(!self.adv.toasts.is_empty());
|
||||
let mut used = self.toast_slots_used();
|
||||
for notifier in &mut self.server_packs.notifiers {
|
||||
let Some(toast) = &mut notifier.toast else {
|
||||
continue;
|
||||
};
|
||||
if toast.slot.is_none() {
|
||||
let Some(slot) = (0..Self::TOAST_SLOTS).find(|s| used & (1 << s) == 0) else {
|
||||
continue;
|
||||
};
|
||||
used |= 1 << slot;
|
||||
toast.slot = Some(slot);
|
||||
}
|
||||
let (sound, finished) = toast.update(multiplier);
|
||||
if let Some(show) = sound {
|
||||
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
|
||||
@@ -522,8 +536,9 @@ impl Game<'_> {
|
||||
notifier.toast = None;
|
||||
continue;
|
||||
}
|
||||
toast.draw(&self.gui.atlas, batch, 32.0 * slot as f32);
|
||||
slot += 1;
|
||||
if let (false, Some(slot)) = (hidden, toast.slot) {
|
||||
toast.draw(&self.gui.atlas, batch, 32.0 * slot as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A left click on the prompt: its answer when it hit a button.
|
||||
|
||||
@@ -182,12 +182,17 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let (mx, mz) = (m.position.x, m.position.z);
|
||||
let disable = m
|
||||
.extra
|
||||
.main_hand
|
||||
.as_deref()
|
||||
.and_then(|item| self.res.items().get(item))
|
||||
.map_or(0.0, |i| i.disable_blocking_seconds);
|
||||
// LivingEntity.getSecondsToDisableBlocking: the held weapon's value;
|
||||
// Warden overrides it with a flat 5 seconds.
|
||||
let disable = if m.species() == horizon_client::mobs::Species::Warden {
|
||||
5.0
|
||||
} else {
|
||||
m.extra
|
||||
.main_hand
|
||||
.as_deref()
|
||||
.and_then(|item| self.res.items().get(item))
|
||||
.map_or(0.0, |i| i.disable_blocking_seconds)
|
||||
};
|
||||
// Ravager.blockedByItem: stunned half the time (the defender pushed
|
||||
// apart), else the defender flung away; no roar in progress.
|
||||
if m.species() == horizon_client::mobs::Species::Ravager {
|
||||
|
||||
@@ -21,6 +21,9 @@ pub(super) struct SignEditor {
|
||||
pub line: usize,
|
||||
/// The line being edited (`AbstractSignEditScreen`'s text field).
|
||||
edit: EditBox,
|
||||
/// `AbstractSignEditScreen.messages`: the side's lines as edited, sent
|
||||
/// when the editor closes, even if the sign is gone by then.
|
||||
lines: [String; 4],
|
||||
}
|
||||
|
||||
fn sign_box() -> EditBox {
|
||||
@@ -185,11 +188,19 @@ impl Game<'_> {
|
||||
}
|
||||
/// `SignBlock.openTextEdit` for the front side after placement.
|
||||
pub(super) fn open_sign_editor(&mut self, pos: (i32, i32, i32), front: bool) {
|
||||
// Minecraft.setScreen: the editor already open is removed first.
|
||||
self.close_sign_editor();
|
||||
let lines = match self.world.block_entities.get(&pos) {
|
||||
Some(BlockEntity::Sign(sign)) if front => sign.front.lines.clone(),
|
||||
Some(BlockEntity::Sign(sign)) => sign.back.lines.clone(),
|
||||
_ => Default::default(),
|
||||
};
|
||||
self.sign_edit = Some(SignEditor {
|
||||
pos,
|
||||
front,
|
||||
line: 0,
|
||||
edit: sign_box(),
|
||||
lines,
|
||||
});
|
||||
self.sign_load();
|
||||
self.keys.clear();
|
||||
@@ -202,14 +213,11 @@ impl Game<'_> {
|
||||
pub(super) fn close_sign_editor(&mut self) {
|
||||
if let Some(edit) = self.sign_edit.take() {
|
||||
if self.remote.is_some() {
|
||||
if let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&edit.pos) {
|
||||
let side = if edit.front { &sign.front } else { &sign.back };
|
||||
self.send(S::SignUpdate(play::SignUpdate {
|
||||
pos: [edit.pos.0, edit.pos.1, edit.pos.2],
|
||||
lines: side.lines.clone(),
|
||||
front: edit.front,
|
||||
}));
|
||||
}
|
||||
self.send(S::SignUpdate(play::SignUpdate {
|
||||
pos: [edit.pos.0, edit.pos.1, edit.pos.2],
|
||||
lines: edit.lines,
|
||||
front: edit.front,
|
||||
}));
|
||||
} else {
|
||||
self.mark_block_entity_updated(edit.pos);
|
||||
}
|
||||
@@ -358,7 +366,8 @@ impl Game<'_> {
|
||||
edit.edit.set_value(&text);
|
||||
}
|
||||
}
|
||||
/// The box's text onto the sign, when it still fits the line.
|
||||
/// The box's text onto the editor's lines and the sign
|
||||
/// (`AbstractSignEditScreen.setMessage`), when it still fits the line.
|
||||
fn sign_write(&mut self) {
|
||||
let Some(edit) = self.sign_edit.as_ref() else {
|
||||
return;
|
||||
@@ -367,6 +376,9 @@ impl Game<'_> {
|
||||
if !self.sign_fits(&value) {
|
||||
return;
|
||||
}
|
||||
if let Some(edit) = self.sign_edit.as_mut() {
|
||||
edit.lines[line].clone_from(&value);
|
||||
}
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&pos) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -257,8 +257,17 @@ impl Game<'_> {
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height()));
|
||||
let at = m.position;
|
||||
self.lan_entity_event(id as i32, 20, [at.x, at.y, at.z]);
|
||||
self.poof_particles(id);
|
||||
}
|
||||
/// `Mob.handleEntityEvent` 20 (`spawnAnim` on a client): the poof about
|
||||
/// mob `id`.
|
||||
pub(super) fn poof_particles(&mut self, id: u32) {
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height()));
|
||||
let options = ParticleOptions::simple(ParticleType::Poof);
|
||||
for _ in 0..20 {
|
||||
let r = self.particles.level_random();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! player's `PlayerEnderChestContainer`, saved as `EnderItems`).
|
||||
use super::Game;
|
||||
use horizon_client::{containers::BlockEntity, items::ItemStack, text::Text};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
pub(super) fn is_shulker_box(name: &str) -> bool {
|
||||
name.ends_with("shulker_box")
|
||||
@@ -156,6 +157,79 @@ impl Game<'_> {
|
||||
);
|
||||
paired.then_some((other, kind == "right"))
|
||||
}
|
||||
/// The open or close sound of a container block (`ChestBlockEntity.playSound`,
|
||||
/// `BarrelBlockEntity.playSound`, `ShulkerBoxBlockEntity.startOpen`): volume
|
||||
/// 0.5 and pitch 0.9 to 1.0. A double chest sounds once from the middle of
|
||||
/// both halves, a barrel from the face it opens on.
|
||||
pub(super) fn container_sound(&mut self, sound: &str, key: (i32, i32, i32), name: &str) {
|
||||
let mut at = [
|
||||
f64::from(key.0) + 0.5,
|
||||
f64::from(key.1) + 0.5,
|
||||
f64::from(key.2) + 0.5,
|
||||
];
|
||||
if let Some((other, _)) = self.chest_partner(key) {
|
||||
at[0] = f64::from(key.0 + other.0) / 2.0 + 0.5;
|
||||
at[2] = f64::from(key.2 + other.2) / 2.0 + 0.5;
|
||||
} else if name == "minecraft:barrel"
|
||||
&& let Some(state) = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(self.world.block(key.0, key.1, key.2))
|
||||
&& let Some(facing) = state.properties.get("facing")
|
||||
{
|
||||
let (dx, dy, dz) = match facing.as_str() {
|
||||
"up" => (0.0, 0.5, 0.0),
|
||||
"down" => (0.0, -0.5, 0.0),
|
||||
f => {
|
||||
let (x, z) = facing_step(f);
|
||||
(f64::from(x) / 2.0, 0.0, f64::from(z) / 2.0)
|
||||
}
|
||||
};
|
||||
at = [at[0] + dx, at[1] + dy, at[2] + dz];
|
||||
}
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
self.audio.play(sound, Some(at), 0.5, pitch);
|
||||
}
|
||||
/// The open or close sound of the container block at `key`, if it has one.
|
||||
/// Callers play it only when the opener count crosses zero
|
||||
/// (`ContainerOpenersCounter.onOpen` / `onClose`).
|
||||
pub(super) fn container_edge_sound(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
let name = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(key.0, key.1, key.2))
|
||||
.name
|
||||
.clone();
|
||||
let sound = if is_shulker_box(&name) {
|
||||
if open {
|
||||
"minecraft:block.shulker_box.open"
|
||||
} else {
|
||||
"minecraft:block.shulker_box.close"
|
||||
}
|
||||
} else if &*name == "minecraft:ender_chest" {
|
||||
if open {
|
||||
"minecraft:block.ender_chest.open"
|
||||
} else {
|
||||
"minecraft:block.ender_chest.close"
|
||||
}
|
||||
} else if name.contains("chest") {
|
||||
if open {
|
||||
"minecraft:block.chest.open"
|
||||
} else {
|
||||
"minecraft:block.chest.close"
|
||||
}
|
||||
} else if &*name == "minecraft:barrel" {
|
||||
if open {
|
||||
"minecraft:block.barrel.open"
|
||||
} else {
|
||||
"minecraft:block.barrel.close"
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
self.container_sound(sound, key, &name);
|
||||
}
|
||||
/// The other half of a double chest at `key`, for neighbour updates.
|
||||
pub(super) fn chest_other_half(&self, key: (i32, i32, i32)) -> Option<(i32, i32, i32)> {
|
||||
self.chest_partner(key).map(|p| p.0)
|
||||
|
||||
@@ -269,6 +269,8 @@ impl Game<'_> {
|
||||
light,
|
||||
layer: if through { 2 } else { 0 },
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +178,9 @@ impl Game<'_> {
|
||||
];
|
||||
self.tnts.remove(i);
|
||||
count -= 1;
|
||||
self.server_explode(center, 4.0, BlockInteraction::Destroy, false)?;
|
||||
self.with_hive_emptying_blast(|g| {
|
||||
g.server_explode(center, 4.0, BlockInteraction::Destroy, false)
|
||||
})?;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
@@ -332,6 +334,8 @@ impl Game<'_> {
|
||||
layer: if lit { 5 } else { 0 },
|
||||
color,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use super::{Game, view_vector};
|
||||
use horizon_client::{
|
||||
collisions::SceneCollisions,
|
||||
enchant::EffectContext,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Damage, DamageKind, Who, anim::Poses},
|
||||
@@ -32,6 +33,8 @@ pub(super) struct ThrownTrident {
|
||||
pub(super) no_physics: bool,
|
||||
returning: u32,
|
||||
creative_only: bool,
|
||||
/// The guest that threw it (`Projectile.getOwner`); `None` for the host.
|
||||
guest: Option<u32>,
|
||||
}
|
||||
impl ThrownTrident {
|
||||
/// A trident a server sent (`handleAddEntity`): placed and moved by it.
|
||||
@@ -51,6 +54,7 @@ impl ThrownTrident {
|
||||
no_physics: false,
|
||||
returning: 0,
|
||||
creative_only: false,
|
||||
guest: None,
|
||||
}
|
||||
}
|
||||
/// One tick of a server's client: the previous position, then `step`.
|
||||
@@ -302,11 +306,33 @@ impl Game<'_> {
|
||||
no_physics: false,
|
||||
returning: 0,
|
||||
creative_only: self.creative,
|
||||
guest: self.lan_acting,
|
||||
});
|
||||
self.player_projectile_shot();
|
||||
self.audio
|
||||
.play("minecraft:item.trident.throw", at, 1.0, 1.0);
|
||||
}
|
||||
/// The thrower's `PLAYER_HURT_ENTITY` for a trident hit: run as the
|
||||
/// guest that threw it, and not at all once they have left.
|
||||
fn thrower_hit(
|
||||
&mut self,
|
||||
guest: Option<u32>,
|
||||
victim: &horizon_client::advancements::EntityView,
|
||||
direct: horizon_client::advancements::EntityView,
|
||||
damage: f32,
|
||||
) {
|
||||
let Some(conn) = guest.filter(|c| self.lan_acting != Some(*c)) else {
|
||||
self.player_hit(victim, "minecraft:trident", Some(direct), damage);
|
||||
return;
|
||||
};
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
self.as_guest(&mut host, conn, |g| {
|
||||
g.player_hit(victim, "minecraft:trident", Some(direct), damage)
|
||||
});
|
||||
self.lan = Some(host);
|
||||
}
|
||||
pub(super) fn tick_tridents(&mut self) {
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return;
|
||||
@@ -367,11 +393,29 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if let Some((id, _)) = hit_mob {
|
||||
let impaling = t.stack.enchantment_level("minecraft:impaling") as f32;
|
||||
let aquatic = self.mobs.iter().find(|m| m.id == id).is_some_and(|m| {
|
||||
m.kind.flags.aquatic_swimmer || m.kind.flags.breathes_water
|
||||
});
|
||||
let damage = 8.0 + if aquatic { 2.5 * impaling } else { 0.0 };
|
||||
// ThrownTrident.getDamage: `EnchantmentHelper.modifyDamage` on 8
|
||||
// (impaling hits `#sensitive_to_impaling`, the aquatic tag).
|
||||
let victim = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| m.kind.id.clone());
|
||||
let ctx = EffectContext {
|
||||
this: victim.as_deref(),
|
||||
attacker: Some("minecraft:player"),
|
||||
direct_attacker: Some("minecraft:trident"),
|
||||
is_direct: true,
|
||||
damage_type: Some("minecraft:trident"),
|
||||
items: Some(self.res.items()),
|
||||
..Default::default()
|
||||
};
|
||||
let damage = self.res.stations().enchantments.modify_value(
|
||||
Some(&t.stack),
|
||||
"minecraft:damage",
|
||||
&ctx,
|
||||
&mut self.world.random,
|
||||
8.0,
|
||||
);
|
||||
t.dealt_damage = true;
|
||||
let mut hurt_at = None;
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
@@ -394,6 +438,18 @@ impl Game<'_> {
|
||||
{
|
||||
self.channel_lightning(at);
|
||||
}
|
||||
if hurt_at.is_some()
|
||||
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
|
||||
{
|
||||
// LivingEntity.hurtServer: PLAYER_HURT_ENTITY.
|
||||
let victim = self.mob_entity(m);
|
||||
let direct = horizon_client::advancements::EntityView {
|
||||
kind: "minecraft:trident".into(),
|
||||
position: [t.position.x, t.position.y, t.position.z],
|
||||
..Default::default()
|
||||
};
|
||||
self.thrower_hit(t.guest, &victim, direct, damage);
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:item.trident.hit",
|
||||
Some([t.position.x, t.position.y, t.position.z]),
|
||||
|
||||
Loaded 100 of 229 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user