Compare commits

..
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 5bb7be3765 Merge main into feat/lua-mods (Cargo.toml members one per line; stability.md server-mods row from main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:04:24 +01:00
Ludovic MarieandClaude Opus 5.5 bad95e4f6a horizon-mod-lua: string.rep and table.concat keep bytes that are not UTF-8
Lua strings are byte strings. Both converted their arguments with
to_str_lossy, so a byte such as \255 (binary values a mod keeps in
horizon.storage, for one) came back as U+FFFD's three bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:33:52 +01:00
Ludovic Marie b03d1411d1 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-27 02:24:19 +01:00
Ludovic Marie a24a6fa26c Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 23:40:35 +01:00
Ludovic Marie 6d9bb375c5 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 21:43:18 +01:00
Ludovic Marie 10c9d8440c Merge remote-tracking branch 'origin/main' into feat/mods-lua 2026-10-26 19:13:17 +01:00
Ludovic MarieandClaude Opus 5.5 7f6d556e68 docs: Lua mods' page goes to horizon-site with the rest of website/
docs/mods.md keeps the reference, plus what a capability missing from the features does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:09:10 +01:00
Ludovic MarieandClaude Opus 5.5 d33f547bf7 Merge origin/main into feat/mods-lua
website/ moved to its own repo (#2286): this branch's changes to make-a-mod.md and nav.json go there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:08:55 +01:00
Ludovic Marie 2e7283d01c Merge remote-tracking branch 'origin/main' into feat/mods-lua 2026-10-26 16:10:40 +01:00
Ludovic Marie 9a7f955a77 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
#	docs/api/stability.md
2026-10-26 11:54:53 +01:00
Ludovic Marie 6b7617c277 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 09:44:13 +01:00
Ludovic MarieandClaude Opus 5.5 7c5efff307 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:31:10 +01:00
Ludovic MarieandClaude Opus 5.5 1f6e498238 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:48:21 +01:00
Ludovic MarieandClaude Opus 5.5 32f6079134 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:03:23 +01:00
Ludovic MarieandClaude Opus 5.5 ce1db1b0c3 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:20:16 +01:00
Ludovic Marie ad3ddeb43f Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	crates/horizon-mods/tests/sandbox.rs
2026-10-26 01:43:14 +01:00
Ludovic Marie 34f4d3b451 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 00:25:27 +01:00
Ludovic MarieandClaude Opus 5.5 27de895fdb Mods in Lua: horizon-mod-lua, a Lua 5.4 interpreter built into the mod
A mod's code can be a Lua script. The crate horizon-mod-lua embeds
piccolo (pure Rust Lua, MIT) in the wasm guest with the script, so a Lua
mod is an .hmod like any other: same sandbox, capabilities, fuel and
checks. `horizon_mod_lua::lua_mod!("main.lua")` is the whole Rust side.

- The script runs once at load and registers commands with
  horizon.command; hooks are globals (on_tick, on_frame, on_command,
  on_chat). Capabilities are tables of `horizon` (game, chat, hud,
  storage), each behind a cargo feature so unused imports aren't linked.
- piccolo's stdlib lacks them: string.format (C flags, width,
  precision), string.rep, table.insert/remove/concat.
- The sandbox has no entropy: getrandom 0.2 and 0.3 get a custom
  SplitMix64 backend (cfg getrandom_backend="custom", set by
  cargo horizon-mod build).
- Example coords-hud-lua: coords-hud line for line. The sandbox tests
  run the same checks on both, and measure a tick of each.
- cargo horizon-mod new --template lua.
- Docs: docs/mods.md, website "Write it in Lua", changelog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:32:50 +01:00
297 changed files with 4104 additions and 21889 deletions

No files matched your search

-274
View File
@@ -1,274 +0,0 @@
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
-166
View File
@@ -1,166 +0,0 @@
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
+245 -10
View File
@@ -40,10 +40,7 @@ permissions:
# The open release-blocker issues (decide).
issues: read
concurrency:
# One release at a time. A hand-started dry run publishes nothing, so it can't race a real release: it gets a
# group of its own. In the shared group, a release queued behind it replaced it as the pending run and GitHub
# 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' || '' }}
group: game-release
cancel-in-progress: false
jobs:
decide:
@@ -82,14 +79,252 @@ 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
build:
exporters:
needs: decide
if: needs.decide.outputs.release == 'true'
# Every platform's archive, and macOS's smoke test (game-build.yml).
uses: ./.github/workflows/game-build.yml
with:
ref: ${{ needs.decide.outputs.head }}
version: ${{ needs.decide.outputs.version }}
# 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
sign:
needs: [decide, build]
Generated
+115 -9
View File
@@ -477,6 +477,13 @@ dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "coords-hud-lua"
version = "0.1.0"
dependencies = [
"horizon-mod-lua",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -1100,6 +1107,30 @@ dependencies = [
"slab",
]
[[package]]
name = "gc-arena"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cd70cf88a32937834aae9614ff2569b5d9467fa0c42c5d7762fd94a8de88266"
dependencies = [
"allocator-api2",
"gc-arena-derive",
"hashbrown 0.14.5",
"sptr",
]
[[package]]
name = "gc-arena-derive"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c612a69f5557a11046b77a7408d2836fe77077f842171cd211c5ef504bd3cddd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure 0.13.2",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -1264,6 +1295,16 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1405,7 +1446,6 @@ dependencies = [
"sha1",
"sha2",
"symphonia",
"unicode-bidi",
"ureq",
"wgpu",
"winit",
@@ -1469,6 +1509,16 @@ dependencies = [
"wgpu",
]
[[package]]
name = "horizon-mod-lua"
version = "0.1.0"
dependencies = [
"getrandom 0.2.17",
"getrandom 0.3.4",
"horizon-mod-sdk",
"piccolo",
]
[[package]]
name = "horizon-mod-sdk"
version = "0.1.0"
@@ -2745,6 +2795,21 @@ dependencies = [
"indexmap",
]
[[package]]
name = "piccolo"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "003bf52de285e1ff1adcbc6572588db3849988ea660a2d55af3a2ffbc81f597f"
dependencies = [
"ahash",
"allocator-api2",
"anyhow",
"gc-arena",
"hashbrown 0.14.5",
"rand",
"thiserror 1.0.69",
]
[[package]]
name = "pin-project"
version = "1.1.13"
@@ -2872,6 +2937,15 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "predator-missile"
version = "0.1.0"
@@ -2972,6 +3046,27 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -3451,6 +3546,12 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "sptr"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -3568,6 +3669,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "synstructure"
version = "0.14.0"
@@ -3775,12 +3887,6 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-ident"
version = "1.0.26"
@@ -5235,7 +5341,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
@@ -5276,7 +5382,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
+2
View File
@@ -30,6 +30,7 @@ members = [
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"crates/horizon-mod-sdk/examples/coords-hud",
"crates/horizon-mod-sdk/examples/coords-hud-lua",
"crates/horizon-mod-sdk/examples/predator-missile",
"crates/horizon-mod-sdk/examples/welcome",
"crates/horizon-mod-sdk/examples/beacons",
@@ -40,6 +41,7 @@ members = [
"mods/official/zoom",
"xtask",
"mods/official/recipes",
"crates/horizon-mod-lua",
]
resolver = "2"
+54 -8
View File
@@ -1,7 +1,7 @@
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
//!
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
//! a mod from a template: a Rust crate, or a content mod (no code).
//! - `new <path> [--template code|content|lua] [--name "Name"] [--sdk-path <dir>]`:
//! a mod from a template: a Rust crate, a content mod (no code), or a Lua mod.
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
@@ -26,6 +26,8 @@ use std::{
const TARGET: &str = "wasm32-unknown-unknown";
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
/// The Lua runtime from the same repository, with the template's capability.
const LUA_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\", features = [\"chat\"] }";
fn main() -> ExitCode {
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
@@ -42,7 +44,7 @@ fn main() -> ExitCode {
Some("test") => test(&args[1..]),
Some("check") => check(&args[1..]),
Some("registry") => registry::main(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
_ => Err("usage: cargo horizon-mod new <path> [--template code|content|lua] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -83,18 +85,26 @@ fn new(args: &[String]) -> Result<(), String> {
})
.collect();
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
let sdk = match flag(args, "--sdk-path") {
let (sdk, lua) = match flag(args, "--sdk-path") {
Some(p) => {
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
format!("{{ path = {:?} }}", p.display().to_string())
let lua = p.parent().unwrap_or(&p).join("horizon-mod-lua");
(
format!("{{ path = {:?} }}", p.display().to_string()),
format!(
"{{ path = {:?}, features = [\"chat\"] }}",
lua.display().to_string()
),
)
}
None => SDK_GIT.to_string(),
None => (SDK_GIT.to_string(), LUA_GIT.to_string()),
};
let fill = |t: &str| {
t.replace("{crate}", &id.replace('_', "-"))
.replace("{id}", &id)
.replace("{name}", &name)
.replace("{sdk}", &sdk)
.replace("{lua}", &lua)
};
let code = [
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
@@ -146,10 +156,19 @@ fn new(args: &[String]) -> Result<(), String> {
),
(".gitignore", include_str!("../template/gitignore")),
];
// The code template's mod, written in Lua.
let lua_files = [
("Cargo.toml", include_str!("../template/lua/Cargo.toml.in")),
(MANIFEST, include_str!("../template/lua/horizon-mod.toml")),
("src/lib.rs", include_str!("../template/lua/src/lib.rs")),
("main.lua", include_str!("../template/lua/main.lua")),
(".gitignore", include_str!("../template/gitignore")),
];
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
"code" => &code,
"content" => &content,
other => return Err(format!("no template {other}: code or content")),
"lua" => &lua_files,
other => return Err(format!("no template {other}: code, content or lua")),
};
for &(file, text) in files {
let file = fill(file);
@@ -215,6 +234,13 @@ fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut cmd = Command::new(cargo);
// The Lua runtime's seeds (crates/horizon-mod-lua): getrandom 0.3
// takes a custom source only with this cfg.
let flags = std::env::var("CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS").unwrap_or_default();
cmd.env(
"CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS",
format!("{flags} --cfg getrandom_backend=\"custom\"").trim(),
);
cmd.current_dir(root).args([
"build",
"--lib",
@@ -386,6 +412,26 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_lua_template_is_a_valid_mod() {
let dir = std::env::temp_dir().join(format!("horizon-mod-lua-{}", std::process::id()));
let path = dir.join("hello-lua");
new(&[
path.display().to_string(),
"--template".into(),
"lua".into(),
])
.unwrap();
let manifest =
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
assert_eq!(manifest.capabilities, [horizon_mods::Capability::Chat]);
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
assert!(cargo.contains("horizon-mod-lua = ") && cargo.contains(LUA_GIT));
let script = std::fs::read_to_string(path.join("main.lua")).unwrap();
assert!(script.contains("horizon.command(\"hello\")"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_content_template_builds_and_checks_with_no_code() {
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
@@ -424,7 +470,7 @@ mod tests {
new(&[
dir.join("x").display().to_string(),
"--template".into(),
"lua".into()
"python".into()
])
.is_err()
);
@@ -0,0 +1,17 @@
[package]
name = "{crate}"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
# The Lua interpreter, built into the mod with main.lua. Its features are the
# capabilities horizon-mod.toml lists (game-read, chat, hud, storage): keep
# the two lists the same.
[dependencies]
horizon-mod-lua = {lua}
[profile.release]
opt-level = "s"
lto = true
@@ -0,0 +1,12 @@
id = "{id}"
name = "{name}"
version = "0.1.0"
description = ""
authors = []
# The Horizon mod API the Lua runtime targets.
api = "0.1"
# client: runs in the player's game.
side = "client"
# What the mod may do, shown to players before they turn it on:
# game-read (position, health, time), chat (show messages), hud (draw text).
capabilities = ["chat"]
@@ -0,0 +1,12 @@
-- {name}: a Horizon mod in Lua (https://playhorizon.dev/docs/mod-code).
-- Commands come from horizon.command; hooks are global functions:
-- on_tick(tick), on_frame(seconds), on_command(name, args), on_chat(text).
local greeted = 0
horizon.command("hello")
function on_command(name, args)
greeted = greeted + 1
horizon.chat.show(string.format("Hello %s! (%d so far)", args, greeted))
end
@@ -0,0 +1,2 @@
// The mod is main.lua: this builds it into the mod with its interpreter.
horizon_mod_lua::lua_mod!("main.lua");
@@ -61,16 +61,6 @@ 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,
@@ -434,7 +424,10 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
}
"minecraft:end_gateway" => {
f.long("Age", 0);
f.keep("exit_portal", |t| gateway_exit(t).is_some());
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)
});
if flag(tag.get("ExactTeleport")) == Some(true) {
f.put("ExactTeleport", boolean(true));
}
@@ -173,22 +173,6 @@ 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 -18
View File
@@ -4,10 +4,7 @@
//! language used by every screen.
use std::{
collections::{BTreeMap, HashMap},
sync::{
Arc, RwLock,
atomic::{AtomicBool, Ordering},
},
sync::{Arc, RwLock},
};
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -26,16 +23,11 @@ impl LanguageInfo {
#[derive(Default)]
pub struct Language {
pub code: String,
/// `LanguageInfo.bidirectional`: text without a strong character reads
/// right to left (`ClientLanguage.defaultRightToLeft`).
bidirectional: bool,
map: HashMap<String, String>,
}
pub const DEFAULT: &str = "en_us";
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::new(None);
/// The current language's `bidirectional`, read for every drawn string.
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
/// A source of resource files: the prepared root plus packs, highest priority last.
pub trait ResourceSource {
@@ -142,12 +134,8 @@ impl Language {
{
apply_deprecated(&mut map, &bytes);
}
let bidirectional = available(sources)
.iter()
.any(|info| info.code == code && info.bidirectional);
Self {
code: code.to_owned(),
bidirectional,
map,
}
}
@@ -209,16 +197,11 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
}
pub fn set(language: Language) {
BIDIRECTIONAL.store(language.bidirectional, Ordering::Relaxed);
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
}
pub fn current() -> Option<Arc<Language>> {
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
}
/// `ClientLanguage.defaultRightToLeft` of the current language.
pub fn bidirectional() -> bool {
BIDIRECTIONAL.load(Ordering::Relaxed)
}
pub fn code() -> String {
current().map_or_else(|| DEFAULT.to_owned(), |l| l.code.clone())
}
+2 -5
View File
@@ -1,9 +1,8 @@
//! 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, and the `pack.mcmeta` filter regex.
//! horizon-client re-exports each module under its old path
//! (`horizon_client::text`, ...).
//! sound source tables commands name. horizon-client re-exports each module
//! under its old path (`horizon_client::text`, ...).
#![forbid(unsafe_code)]
pub mod block_entity_nbt;
pub mod bossbar;
@@ -15,8 +14,6 @@ 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;
-249
View File
@@ -1,249 +0,0 @@
//! 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,
}
}
-6
View File
@@ -42,10 +42,6 @@ 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.
@@ -533,8 +529,6 @@ 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 })
}
-1
View File
@@ -62,7 +62,6 @@ gilrs = { version = "0.11", optional = true }
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
@@ -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,21 +1050,17 @@ 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,
allies: Default::default(),
remote: false,
passenger: false,
}
});
let mut scratch = LegacyRandom::new(0);
@@ -1074,8 +1070,6 @@ 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) => {
@@ -1087,7 +1081,6 @@ pub fn main() -> horizon_client::Result<()> {
world: &world,
registry,
facts: &facts,
fluid_tables: &motion.fluids,
movement: &motion.blocks,
collision: &collision,
data: &data,
@@ -1105,14 +1098,12 @@ 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,
}
};
}
@@ -353,7 +353,6 @@ pub fn main() -> Result<()> {
horizon_world::food::Difficulty::Hard,
][op["difficulty"].as_u64().unwrap() as usize],
natural_regeneration: op["regenerate"].as_bool().unwrap(),
..Default::default()
};
}
"food" => {
+21 -117
View File
@@ -437,8 +437,6 @@ 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>,
@@ -460,7 +458,7 @@ fn range_f(v: &Value, x: f64) -> bool {
_ => false,
}
}
pub fn range_i(v: &Value, x: i64) -> bool {
fn range_i(v: &Value, x: i64) -> bool {
range_f(v, x as f64)
}
/// A registry id, `#tag`, or list matched against one id.
@@ -475,51 +473,6 @@ 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() {
@@ -661,18 +614,6 @@ 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() {
@@ -688,7 +629,21 @@ 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" => distance(p, e.position, origin),
"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: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)
@@ -723,16 +678,11 @@ 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)))
}) && (p["looking_at"].is_null()
|| e.looking_at
.as_ref()
.is_some_and(|t| entity(&p["looking_at"], t, w, origin)))
}
"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/lightning" => {
range_i(&p["blocks_set_on_fire"], 0)
&& (p["entity_struck"].is_null()
@@ -753,52 +703,6 @@ 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>,
+26 -171
View File
@@ -36,9 +36,8 @@ 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); and
/// whether they have the poplar ambient sound (`AmbientLeavesBlockSoundPlayer`).
Leaves(Option<(T, f32)>, Option<u32>, bool),
/// the biome foliage colour unless a fixed colour is given (azalea).
Leaves(Option<(T, f32)>, Option<u32>),
NetherPortal,
EnchantingTable,
EndRod([i32; 3]),
@@ -49,9 +48,8 @@ enum Animate {
CandleCake,
BrewingStand,
EnderChest,
/// FallingBlock with its dust colour; sand also has the ambient desert sound
/// (`SandBlock.animateTick`).
Falling(u32, bool),
/// FallingBlock with its dust colour.
Falling(u32),
/// A beehive or bee nest full of honey (`BeehiveBlock.animateTick`).
Beehive,
RedstoneOre,
@@ -75,8 +73,6 @@ enum Animate {
waterlogged: bool,
},
EndPortal,
/// `WetSpongeBlock.animateTick`.
WetSponge,
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -111,17 +107,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, 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),
| "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),
// 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), false)
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D))
}
"spruce_leaves" => Animate::Leaves(None, None, false),
"spruce_leaves" => Animate::Leaves(None, None),
"nether_portal" => Animate::NetherPortal,
"enchanting_table" => Animate::EnchantingTable,
"end_rod" => Animate::EndRod(facing()),
@@ -130,17 +126,16 @@ 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, true),
"red_sand" => Animate::Falling(0xA95821, true),
"gravel" => Animate::Falling(0x807C7B, false),
"sand" => Animate::Falling(0xDBD3A0),
"red_sand" => Animate::Falling(0xA95821),
"gravel" => Animate::Falling(0x807C7B),
// AnvilBlock/ConcretePowderBlock.getDustColor is the map colour;
// DragonEggBlock's is black.
"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,
),
"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"),
)),
"beehive" | "bee_nest" if prop("honey_level").as_deref() == Some("5") => Animate::Beehive,
"redstone_ore" | "deepslate_redstone_ore" if lit => Animate::RedstoneOre,
"redstone_wire" => {
@@ -172,10 +167,6 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
waterlogged: prop("waterlogged").as_deref() == Some("true"),
},
"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, false),
"suspicious_gravel" => Animate::Falling(0x707070, false),
"sculk_sensor" | "calibrated_sculk_sensor"
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
{
@@ -242,10 +233,6 @@ 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`).
@@ -321,8 +308,6 @@ 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,
})
@@ -411,10 +396,6 @@ 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));
}
@@ -790,7 +771,7 @@ fn block_animate_tick(
}
}
}
Animate::Leaves(falling, fixed_tint, poplar) => {
Animate::Leaves(falling, fixed_tint) => {
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
@@ -802,9 +783,6 @@ 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
@@ -977,7 +955,7 @@ fn block_animate_tick(
fy + 1.0 + f64::from(r.next_float()),
fz + f64::from(r.next_float()),
];
add_visible(engine, T::SulfurBubbles, p, [0.0; 3]);
add(engine, T::SulfurBubbles, p, [0.0; 3]);
}
if next_int(engine.random(), 10) == 0 {
let name = "minecraft:block.potent_sulfur.noxious_gas";
@@ -985,36 +963,6 @@ fn block_animate_tick(
}
}
}
Animate::WetSponge => {
// Direction.getRandom: DOWN, UP, NORTH, SOUTH, WEST, EAST.
const DIRECTIONS: [[i32; 3]; 6] = [
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
[-1, 0, 0],
[1, 0, 0],
];
let d = DIRECTIONS[next_int(engine.random(), 6) as usize];
// The sponge occludes, so only a sturdy neighbour face hides the drip.
if d != [0, 1, 0] && !ctx.sturdy([pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]]) {
let r = engine.random();
let p = match d {
[0, -1, 0] => [fx + r.next_double(), fy - 0.05, fz + r.next_double()],
[dx, _, 0] => {
let y = fy + r.next_double() * 0.8;
let z = fz + r.next_double();
[fx + if dx > 0 { 1.1 } else { 0.05 }, y, z]
}
[_, _, dz] => {
let y = fy + r.next_double() * 0.8;
let x = fx + r.next_double();
[x, y, fz + if dz > 0 { 1.1 } else { 0.05 }]
}
};
add(engine, T::DrippingWater, p, [0.0; 3]);
}
}
Animate::Mycelium => {
let r = engine.random();
if next_int(r, 10) == 0 {
@@ -1097,7 +1045,7 @@ fn block_animate_tick(
add(engine, T::Portal, p, v);
}
}
Animate::Falling(color, sand) => {
Animate::Falling(color) => {
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.
@@ -1114,14 +1062,6 @@ 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.
@@ -1239,7 +1179,7 @@ fn block_animate_tick(
}
Animate::BubbleColumn { drag } => {
if drag {
add_visible(engine, T::CurrentDown, [fx + 0.5, fy + 0.8, fz], [0.0; 3]);
add(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);
@@ -1252,7 +1192,7 @@ fn block_animate_tick(
);
}
} else {
add_visible(
add(
engine,
T::BubbleColumnUp,
[fx + 0.5, fy, fz + 0.5],
@@ -1264,7 +1204,7 @@ fn block_animate_tick(
fy + f64::from(r.next_float()),
fz + f64::from(r.next_float()),
];
add_visible(engine, T::BubbleColumnUp, p, [0.0, 0.04, 0.0]);
add(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);
@@ -1342,91 +1282,6 @@ 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();
+1 -1
View File
@@ -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,
+5
View File
@@ -719,6 +719,11 @@ impl Audio {
self.play(event, at, volume, pitch);
self.capture = capture;
}
/// `LivingEntity.handleDamageEvent`'s pitch, `(r - r) * 0.2 + 1`, drawn on the
/// client (the entity's own random there, never the server's world random).
pub fn hurt_pitch(&mut self) -> f32 {
(self.random.next_float() - self.random.next_float()) * 0.2 + 1.0
}
/// `AbstractWidget.playButtonClickSound`: an unseeded, relative UI sound.
pub fn play_button_click(&mut self) {
self.play_instance(
+1 -1
View File
@@ -179,7 +179,7 @@ fn agent() -> ureq::Agent {
}
/// The discovery document, fetched once per game (a failure is retried next time).
pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
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);
-48
View File
@@ -450,54 +450,6 @@ mod tests {
assert_eq!(view_chunks(32), 2573);
assert_eq!(view_chunks(0), 0);
}
/// The status a fake control answers with: the player is where it should
/// be, nothing is queued, and the world holds `chunks` resident chunks.
fn status(chunks: u64) -> String {
serde_json::json!({
"player": {"position": [0.0, 64.0, 0.0]},
"pending_chunks": 0,
"screen": "playing",
"render_distance": 12,
"chunks": chunks,
"generated_chunks": chunks,
"meshed_sections": 0,
"streaming": {},
})
.to_string()
}
#[test]
fn the_load_waits_for_the_chunks_a_teleport_starts() {
// Right after a teleport the game reports steady counts and nothing
// queued for over a second, before the integrated server has sent the
// first chunk: the quiet rule alone would call that loaded, and the
// scene would be measured over an empty view (#1749).
const LATE: usize = 30;
let (tx, rx) = mpsc::channel::<Request>();
let polls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counted = std::sync::Arc::clone(&polls);
let server = thread::spawn(move || {
let mut seen = 0;
while let Ok(request) = rx.recv() {
if request.line == "status" {
let chunks = if seen < LATE { 0 } else { 400 };
seen += 1;
counted.store(seen, std::sync::atomic::Ordering::SeqCst);
let _ = request.reply.send(status(chunks));
} else {
let _ = request.reply.send("{}".to_owned());
}
}
});
let control = Control(tx);
wait_loaded(&control, |_| true).expect("the scene loads");
assert!(
polls.load(std::sync::atomic::Ordering::SeqCst) > LATE,
"the load finished before the chunks arrived"
);
drop(control);
let _ = server.join();
}
#[test]
fn yaw_matches_vanilla() {
-302
View File
@@ -1,302 +0,0 @@
//! Right-to-left text (`FormattedBidiReorder.reorder`, behind
//! `Language.getVisualOrder`): the logical text a player types becomes the
//! glyphs a font draws left to right. Arabic letters are shaped into their
//! joined presentation forms (ICU's `ArabicShaping` with `LETTERS_SHAPE`), the
//! text is reordered by the Unicode bidirectional algorithm, and characters in
//! right-to-left runs are mirrored. Left-to-right text comes back unchanged.
use crate::text::{Span, Text};
use std::borrow::Cow;
use unicode_bidi::{
BidiClass, Direction, Level, ParagraphBidiInfo, bidi_class, get_base_direction,
};
/// How a letter joins its neighbours, as ICU's shaper sees it: `Right` joins
/// the letter before it only, `Dual` both neighbours, `Mark` is skipped over
/// (diacritics) and `None` never joins.
#[derive(Clone, Copy, PartialEq)]
enum Join {
None,
Right,
Dual,
Mark,
}
use Join::{Dual, Mark, Right};
/// ICU's joining table (`araLink`): code point ranges and their class, with the
/// lam-alef ligatures at the end. Anything else is `Join::None`.
const JOINS: &[(u32, u32, Join)] = &[
(0x0622, 0x0625, Right),
(0x0626, 0x0626, Dual),
(0x0627, 0x0627, Right),
(0x0628, 0x0628, Dual),
(0x0629, 0x0629, Right),
(0x062A, 0x062E, Dual),
(0x062F, 0x0632, Right),
(0x0633, 0x063A, Dual),
(0x0640, 0x0647, Dual),
(0x0648, 0x0649, Right),
(0x064A, 0x064A, Dual),
(0x064B, 0x0656, Mark),
(0x0670, 0x0670, Mark),
(0x0671, 0x0673, Right),
(0x0675, 0x0677, Right),
(0x0678, 0x0687, Dual),
(0x0688, 0x0699, Right),
(0x069A, 0x06B9, Dual),
(0x06BA, 0x06BA, Right),
(0x06BB, 0x06BF, Dual),
(0x06C0, 0x06C0, Right),
(0x06C1, 0x06C1, Dual),
(0x06C2, 0x06CB, Right),
(0x06CC, 0x06CC, Dual),
(0x06CD, 0x06CD, Right),
(0x06CE, 0x06D1, Dual),
(0x06D2, 0x06D3, Right),
(0x200D, 0x200D, Dual),
(0x206D, 0x206F, Mark),
(0xFEF5, 0xFEFC, Right),
];
/// Letters that have joined forms and the code point of the isolated one; the
/// final, initial and medial forms follow it (a right-joining letter has the
/// isolated and final only). The lam-alef ligatures are their own letters.
/// ICU leaves the extended letters (پ چ ژ ک گ ی and the rest of U+0679 to
/// U+06D3) unchanged, so they are in `JOINS` but not here.
#[rustfmt::skip]
const FORMS: &[(u32, u32)] = &[
(0x0622, 0xFE81), (0x0623, 0xFE83), (0x0624, 0xFE85), (0x0625, 0xFE87), (0x0626, 0xFE89),
(0x0627, 0xFE8D), (0x0628, 0xFE8F), (0x0629, 0xFE93), (0x062A, 0xFE95), (0x062B, 0xFE99),
(0x062C, 0xFE9D), (0x062D, 0xFEA1), (0x062E, 0xFEA5), (0x062F, 0xFEA9), (0x0630, 0xFEAB),
(0x0631, 0xFEAD), (0x0632, 0xFEAF), (0x0633, 0xFEB1), (0x0634, 0xFEB5), (0x0635, 0xFEB9),
(0x0636, 0xFEBD), (0x0637, 0xFEC1), (0x0638, 0xFEC5), (0x0639, 0xFEC9), (0x063A, 0xFECD),
(0x0641, 0xFED1), (0x0642, 0xFED5), (0x0643, 0xFED9), (0x0644, 0xFEDD), (0x0645, 0xFEE1),
(0x0646, 0xFEE5), (0x0647, 0xFEE9), (0x0648, 0xFEED), (0x0649, 0xFEEF), (0x064A, 0xFEF1),
(0xFEF5, 0xFEF5), (0xFEF7, 0xFEF7), (0xFEF9, 0xFEF9), (0xFEFB, 0xFEFB),
];
/// `Bidi_Mirroring_Glyph` (`UCharacter.getMirror`): each pair is two
/// neighbouring characters that mirror each other.
const MIRRORED: &str = "\
()<>[]{}«»༺༻༼༽᚛᚜‹›⁅⁆⁽⁾₍₎∈∋∉∌∊∍∕⧵∟⯾∠⦣∡⦛∢⦠∤⫮∼∽≃⋍≅≌≒≓\
≔≕≤≥≦≧≨≩≪≫≮≯≰≱≲≳≴≵≶≷≸≹≺≻≼≽≾≿⊀⊁⊂⊃⊄⊅⊆⊇⊈⊉⊊⊋⊏⊐⊑⊒⊘⦸⊢⊣⊦⫞\
⊨⫤⊩⫣⊫⫥⊰⊱⊲⊳⊴⊵⊶⊷⊸⟜⋉⋊⋋⋌⋐⋑⋖⋗⋘⋙⋚⋛⋜⋝⋞⋟⋠⋡⋢⋣⋤⋥⋦⋧⋨⋩⋪⋫⋬⋭⋰⋱⋲⋺\
⋳⋻⋴⋼⋶⋽⋷⋾⌈⌉⌊⌋〈〉❨❩❪❫❬❭❮❯❰❱❲❳❴❵⟃⟄⟅⟆⟈⟉⟋⟍⟕⟖⟝⟞⟢⟣⟤⟥⟦⟧⟨⟩⟪⟫\
⟬⟭⟮⟯⦃⦄⦅⦆⦇⦈⦉⦊⦋⦌⦍⦐⦎⦏⦑⦒⦓⦔⦕⦖⦗⦘⦤⦥⦨⦩⦪⦫⦬⦭⦮⦯⧀⧁⧄⧅⧏⧐⧑⧒⧔⧕⧘⧙⧚⧛\
⧨⧩⧸⧹⧼⧽⨫⨬⨭⨮⨴⨵⨼⨽⩤⩥⩹⩺⩻⩼⩽⩾⩿⪀⪁⪂⪃⪄⪅⪆⪇⪈⪉⪊⪋⪌⪍⪎⪏⪐⪑⪒⪓⪔⪕⪖⪗⪘⪙⪚\
⪛⪜⪝⪞⪟⪠⪡⪢⪦⪧⪨⪩⪪⪫⪬⪭⪯⪰⪱⪲⪳⪴⪵⪶⪷⪸⪹⪺⪻⪼⪽⪾⪿⫀⫁⫂⫃⫄⫅⫆⫇⫈⫉⫊⫋⫌⫍⫎⫏⫐\
⫑⫒⫓⫔⫕⫖⫬⫭⫷⫸⫹⫺⸂⸃⸄⸅⸉⸊⸌⸍⸜⸝⸠⸡⸢⸣⸤⸥⸦⸧⸨⸩⹕⹖⹗⹘⹙⹚⹛⹜〈〉《》「」『』【】\
〔〕〖〗〘〙〚〛﹙﹚﹛﹜﹝﹞﹤﹥()<>[]{}⦅⦆「」";
fn join(c: char) -> Join {
let c = c as u32;
JOINS
.iter()
.find(|&&(from, to, _)| (from..=to).contains(&c))
.map_or(Join::None, |&(_, _, join)| join)
}
/// The letter whose joined form `c` is (ICU shapes the forms again), or `c`.
fn letter_of(c: char) -> char {
let c32 = c as u32;
if !matches!(c32, 0xFE70..=0xFEFC) {
return c;
}
if (0xFE70..0xFE80).contains(&c32) {
// The diacritics' forms pair up from fathatan on.
return char::from_u32(0x064B + (c32 - 0xFE70) / 2).unwrap_or(c);
}
FORMS
.iter()
.find(|&&(letter, iso)| {
let forms = if join(char::from_u32(letter).unwrap_or(c)) == Dual {
4
} else {
2
};
(iso..iso + forms).contains(&c32)
})
.and_then(|&(letter, _)| char::from_u32(letter))
.unwrap_or(c)
}
/// `ArabicShaping(LETTERS_SHAPE).shape`: lam followed by an alef (diacritics
/// may sit between) becomes one ligature, then every letter takes the isolated,
/// final, initial or medial form its neighbours call for. Diacritics take the
/// tatweel form between two joined letters. The text can only get shorter.
fn shape(text: &[char]) -> Vec<char> {
let letters: Vec<char> = text.iter().map(|&c| letter_of(c)).collect();
let mut chars = Vec::with_capacity(letters.len());
let mut i = 0;
while i < letters.len() {
if letters[i] == '\u{644}' {
let mut at = i + 1;
while letters.get(at).is_some_and(|&c| join(c) == Mark) {
at += 1;
}
let alef = letters.get(at).and_then(|&c| {
['\u{622}', '\u{623}', '\u{625}', '\u{627}']
.iter()
.position(|&a| a == c)
});
if let Some(alef) = alef {
chars.push(char::from_u32(0xFEF5 + 2 * alef as u32).unwrap_or('\u{644}'));
chars.extend_from_slice(&letters[i + 1..at]);
i = at + 1;
continue;
}
}
chars.push(letters[i]);
i += 1;
}
// Whether the nearest letter before joins the next one, and the joins of
// the nearest letter after.
let mut joins_next = false;
let mut out = Vec::with_capacity(chars.len());
for (i, &c) in chars.iter().enumerate() {
let next_joins = chars[i + 1..]
.iter()
.map(|&n| join(n))
.find(|&j| j != Mark)
.is_some_and(|j| j != Join::None);
let kind = join(c);
let shaped = match kind {
Join::None => {
joins_next = false;
c
}
Mark => match c as u32 {
m @ 0x064B..=0x0652 => {
let iso = 0xFE70 + 2 * (m - 0x064B);
let tatweel = joins_next && next_joins && !matches!(m, 0x064C | 0x064D);
char::from_u32(iso + u32::from(tatweel)).unwrap_or(c)
}
_ => c,
},
Right | Dual => {
let to_next = kind == Dual && next_joins;
let form = match (joins_next, to_next) {
(false, false) => 0,
(true, false) => 1,
(false, true) => 2,
(true, true) => 3,
};
joins_next = kind == Dual;
FORMS
.binary_search_by_key(&(c as u32), |&(letter, _)| letter)
.ok()
.and_then(|at| char::from_u32(FORMS[at].1 + form))
.unwrap_or(c)
}
};
out.push(shaped);
}
out
}
/// `UCharacter.getMirror`.
fn mirror(c: char) -> char {
if bidi_class(c) != BidiClass::ON {
return c;
}
let mut pairs = MIRRORED.chars();
while let (Some(a), Some(b)) = (pairs.next(), pairs.next()) {
if c == a {
return b;
}
if c == b {
return a;
}
}
c
}
/// Whether a character can change anything: right-to-left letters and numbers,
/// the explicit direction controls and the diacritics that shaping turns into
/// letters. Without any, left-to-right text keeps its order.
fn directional(c: char) -> bool {
c as u32 >= 0x0590
&& (matches!(c as u32, 0x064B..=0x0652)
|| matches!(
bidi_class(c),
BidiClass::R
| BidiClass::AL
| BidiClass::AN
| BidiClass::LRE
| BidiClass::RLE
| BidiClass::LRO
| BidiClass::RLO
| BidiClass::PDF
| BidiClass::LRI
| BidiClass::RLI
| BidiClass::FSI
| BidiClass::PDI
))
}
/// Whether `chars` keep their order and shapes: left-to-right text without
/// anything directional, in a language that reads left to right by default.
fn unchanged(mut chars: impl Iterator<Item = char>, default_rtl: bool) -> bool {
!default_rtl && !chars.any(directional)
}
/// `FormattedBidiReorder.reorder`: `chars` shaped, in visual order, with each
/// character's position in the shaped text. A character's style is the one at
/// its position in the shaped text, as `SubStringSource` takes it (the text
/// shrinks at a lam-alef ligature).
fn reorder(chars: &[char], default_rtl: bool) -> Vec<(char, usize)> {
let shaped = shape(chars);
let plain: String = shaped.iter().collect();
// DEFAULT_RTL only changes text without a strong character.
let level =
(default_rtl && get_base_direction(plain.as_str()) == Direction::Mixed).then(Level::rtl);
let info = ParagraphBidiInfo::new(&plain, level);
let levels = info.reordered_levels_per_char(0..plain.len());
ParagraphBidiInfo::reorder_visual(&levels)
.into_iter()
.map(|i| {
let c = if levels[i].is_rtl() {
mirror(shaped[i])
} else {
shaped[i]
};
(c, i)
})
.collect()
}
/// `Language.getVisualOrder` of a text (its `§` codes already resolved), for
/// the current language's direction.
pub fn visual(text: &Text) -> Cow<'_, Text> {
let default_rtl = crate::lang::bidirectional();
if unchanged(text.chars().map(|(c, _)| c), default_rtl) {
return Cow::Borrowed(text);
}
let chars: Vec<char> = text.chars().map(|(c, _)| c).collect();
let styles: Vec<_> = text.chars().map(|(_, style)| style).collect();
let mut out = Text::default();
for (c, at) in reorder(&chars, default_rtl) {
match out.spans.last_mut() {
Some(span) if span.style == *styles[at] => span.text.push(c),
_ => out.spans.push(Span {
text: c.to_string(),
style: styles[at].clone(),
}),
}
}
Cow::Owned(out)
}
/// The same for a plain string.
pub fn visual_str(s: &str) -> Cow<'_, str> {
let default_rtl = crate::lang::bidirectional();
if unchanged(s.chars(), default_rtl) {
return Cow::Borrowed(s);
}
let chars: Vec<char> = s.chars().collect();
Cow::Owned(
reorder(&chars, default_rtl)
.into_iter()
.map(|(c, _)| c)
.collect(),
)
}
@@ -149,8 +149,6 @@ pub fn draw_part(
layer: 0,
color,
light,
glint_uv: [0.0, 0.0],
});
}
}
@@ -612,9 +610,7 @@ fn sign_text_calls(
.iter()
.enumerate()
.map(|(i, line)| {
// The line that fits, in the order it is drawn (getVisualOrder).
let shown = first_line(line, data.max_line_width(), &advance);
let shown = crate::bidi::visual_str(&shown).into_owned();
TextCall {
matrix: m,
x: -((width(&shown) as i32) / 2) as f32,
@@ -652,8 +648,6 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
layer: 0,
color: c,
light: call.light,
glint_uv: [0.0, 0.0],
});
}
}
@@ -740,7 +734,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<String>,
pub skull: &'a dyn Fn([i32; 3]) -> Option<&'a str>,
/// Font glyph cell (none for blank glyphs such as the space) and advance.
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
}
@@ -1292,12 +1286,11 @@ impl BlockEntityRenderer {
};
// SkullBlockRenderer.getRenderType: the profile's skin when it
// resolves, `SKIN_BY_TYPE` (steve) otherwise.
let skin = if *kind == SkullType::Player {
(scene.skull)(pos)
let texture = if *kind == SkullType::Player {
(scene.skull)(pos).unwrap_or(texture)
} else {
None
texture
};
let texture = skin.as_deref().unwrap_or(texture);
let (Some(model), Some(paint)) =
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
else {
@@ -1721,8 +1714,6 @@ impl BeamPart {
layer: 6,
color,
light: self.wrap,
glint_uv: [0.0, 0.0],
});
}
}
@@ -1793,7 +1784,6 @@ fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>)
layer,
color: 0,
light: 0,
glint_uv: [0.0, 0.0],
});
}
}
-2
View File
@@ -49,8 +49,6 @@ pub fn geometry(
color: 0xffff_ffff,
light: stage_light(stage),
normal: [0; 4],
glint_uv: [0.0, 0.0],
});
}
}
+226 -4
View File
@@ -9,10 +9,232 @@ use crate::{
use serde_json::Value;
use std::{collections::HashSet, fs, path::Path, sync::Arc};
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
potion, potion_color,
};
#[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)
}
/// 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,
}
}
/// `MobEffectUtil.formatDuration` at 20 TPS: m:ss or h:mm:ss.
pub fn format_duration(ticks: i32) -> String {
let secs = ticks / 20;
+8 -23
View File
@@ -40,8 +40,6 @@ pub struct Interaction {
/// The cauldron's new state.
pub block: Option<BlockState>,
pub sound: Option<&'static str>,
/// The custom statistic the player earns (`use_cauldron`, ...).
pub stat: Option<&'static str>,
}
fn state(name: &str, level: Option<u8>) -> BlockState {
@@ -59,7 +57,6 @@ fn pass() -> Interaction {
outcome: Outcome::Pass,
block: None,
sound: None,
stat: None,
}
}
@@ -120,12 +117,11 @@ fn lowered(cauldron: &BlockState) -> BlockState {
}
}
fn changed(block: BlockState, sound: Option<&'static str>, stat: &'static str) -> Interaction {
fn changed(block: BlockState, sound: Option<&'static str>) -> Interaction {
Interaction {
outcome: Outcome::Success,
block: Some(block),
sound,
stat: Some(stat),
}
}
@@ -183,22 +179,18 @@ pub fn interact(
stack.data_mut().dyed_color = None;
stack.tidy();
}
return changed(lowered(cauldron), None, "clean_armor");
return changed(lowered(cauldron), None);
}
let fill_bucket = |player: &mut dyn Player, new: &str, can_fill: bool, sound| {
if !can_fill {
return pass();
}
filled(player, ItemStack::new(new, 1), true);
changed(
state("minecraft:cauldron", None),
Some(sound),
"use_cauldron",
)
changed(state("minecraft:cauldron", None), Some(sound))
};
let empty_bucket = |player: &mut dyn Player, block: BlockState, sound| {
filled(player, ItemStack::new("minecraft:bucket", 1), true);
changed(block, Some(sound), "fill_cauldron")
changed(block, Some(sound))
};
match item {
"minecraft:lava_bucket" => {
@@ -207,7 +199,6 @@ pub fn interact(
outcome: Outcome::Consume,
block: None,
sound: None,
stat: None,
}
} else {
empty_bucket(
@@ -230,7 +221,6 @@ pub fn interact(
outcome: Outcome::Consume,
block: None,
sound: None,
stat: None,
}
} else {
empty_bucket(
@@ -251,7 +241,6 @@ pub fn interact(
changed(
state("minecraft:water_cauldron", Some(1)),
Some("minecraft:item.bottle.empty"),
"use_cauldron",
)
}
("water", "minecraft:bucket") => fill_bucket(
@@ -266,11 +255,7 @@ pub fn interact(
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"),
true,
);
changed(
lowered(cauldron),
Some("minecraft:item.bottle.fill"),
"use_cauldron",
)
changed(lowered(cauldron), Some("minecraft:item.bottle.fill"))
}
("water", "minecraft:potion") => {
if level == 3 || !water_potion {
@@ -281,7 +266,7 @@ pub fn interact(
// BlockState.cycle on a 1..=3 property
next.properties
.insert("level".into(), (level % 3 + 1).to_string());
changed(next, Some("minecraft:item.bottle.empty"), "use_cauldron")
changed(next, Some("minecraft:item.bottle.empty"))
}
("water", name)
if name
@@ -298,7 +283,7 @@ pub fn interact(
cleaned.data_mut().banner_patterns.pop();
cleaned.tidy();
filled(player, cleaned, false);
changed(lowered(cauldron), None, "clean_banner")
changed(lowered(cauldron), None)
}
("water", name)
if name
@@ -307,7 +292,7 @@ pub fn interact(
.is_some_and(|c| COLORS.contains(&c)) =>
{
filled(player, held.transmute("minecraft:shulker_box", 1), false);
changed(lowered(cauldron), None, "clean_shulker_box")
changed(lowered(cauldron), None)
}
("lava", "minecraft:bucket") => fill_bucket(
player,
+1 -99
View File
@@ -16,9 +16,6 @@ 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;
@@ -83,10 +80,6 @@ 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('/') {
@@ -192,10 +185,6 @@ 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,
@@ -321,17 +310,6 @@ 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 {
@@ -339,21 +317,6 @@ 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;
@@ -475,71 +438,10 @@ impl Chat {
out.push(GuiDraw::Pop);
out
}
/// Draws [`Chat::layout`] into the HUD batch, and with the chat screen
/// open the restricted prompt above the lines.
/// Draws [`Chat::layout`] into the HUD batch.
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(
+1 -72
View File
@@ -2,7 +2,6 @@
//! 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};
@@ -156,7 +155,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<ChunkExtras>>,
pub saved: Option<Arc<crate::save::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`):
@@ -310,76 +309,6 @@ 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::*;
-2
View File
@@ -179,8 +179,6 @@ impl Mesh {
layer: 4,
color: u32::MAX,
light: shade,
glint_uv: [0.0, 0.0],
});
}
};
+5 -141
View File
@@ -54,27 +54,6 @@ 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>,
@@ -161,21 +140,8 @@ impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCo
border.collision_shape().map(Some).map_err(io::Error::other)
}
}
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.
impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
/// `SceneCollisions::resolve` that also stops at the entity boxes.
pub fn resolve(
&mut self,
bounds: Aabb,
@@ -183,11 +149,12 @@ where
max_step: f32,
on_ground: bool,
) -> Result<Vec3> {
let context = self.blocks.context(bounds);
Ok(resolve_world_movement(
self,
MovementRequest {
entity: EntityIdentity(0),
context: CollisionContext::position(bounds.min_position().y),
context,
bounds,
movement,
max_step,
@@ -357,7 +324,7 @@ impl DynamicSupportWorld for SceneCollisions<'_, '_> {
return Ok(None);
};
pistons
.collision_shape(self, p, piston, PISTON_NOCLIP.with(|value| value.get()))
.collision_shape(self, p, piston, None)
.map(Some)
.map_err(io::Error::other)
}
@@ -421,42 +388,6 @@ 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> {
@@ -544,70 +475,3 @@ 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:?}"
);
}
}
@@ -35,23 +35,6 @@ impl Mode {
Self::Redstone => "minecraft:command_block",
}
}
/// `Enum.ordinal`, which `ServerboundSetCommandBlockPacket` writes.
pub fn ordinal(self) -> i32 {
match self {
Self::Sequence => 0,
Self::Auto => 1,
Self::Redstone => 2,
}
}
/// The mode of a packet's ordinal; `handleSetCommandBlock` treats any
/// other value as a plain command block (its `default` branch).
pub fn from_ordinal(ordinal: i32) -> Self {
match ordinal {
0 => Self::Sequence,
1 => Self::Auto,
_ => Self::Redstone,
}
}
/// `CycleButton`: the next value, wrapping round.
pub fn next(self) -> Self {
match self {
@@ -124,18 +124,6 @@ impl Clicks {
self.skip_next_release = true;
}
/// The screen that takes the pointer over starts with its own state:
/// `AbstractContainerScreen` holds `isQuickCrafting` and the drag it
/// belongs to on the screen, so a screen that opens (or closes) drops
/// them. A press whose release never arrived -- the pointer left the
/// window -- would otherwise keep the drag running, and `press` answers
/// `None` for every later press, so the quick move and the clicks go
/// dead on every screen after it.
pub fn cancel_drag(&mut self) {
self.quick_crafting = None;
self.skip_next_release = false;
}
/// `mouseClicked` (no double click): the `slotClicked` input it sends on
/// `target`, if any. A press holding a stack starts a quick craft drag
/// instead. `cloning` is `keyPickItem` with infinite materials.
@@ -313,35 +301,4 @@ mod tests {
Some(Input::Pickup)
);
}
/// #1737: a press whose release never arrives (the pointer left the
/// window) leaves `isQuickCrafting` set, and `press` answers `None` for
/// every later press, so the Shift quick move and the clicks on the
/// inventory go dead. The drag belongs to the screen that has it
/// (`AbstractContainerScreen` holds `isQuickCrafting`), so the screen
/// that takes the pointer over drops it and the quick move works again.
#[test]
fn a_lost_release_does_not_wedge_the_quick_move() {
let mut clicks = Clicks::default();
let slot = Target::<usize>::Slot(3);
// Pressed with a stack carried: a quick craft drag starts, and its
// release never arrives.
assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None);
assert!(clicks.quick_crafting().is_some());
// The stuck drag swallows the next press: the Shift quick move stops
// working and the inventory stops answering.
assert_eq!(clicks.press(&slot, Button::Right, true, true, false), None);
// The screen that takes the pointer over starts with its own state.
clicks.cancel_drag();
assert!(clicks.quick_crafting().is_none());
// The quick move works again.
assert_eq!(
clicks.press(&slot, Button::Right, true, true, false),
Some(Input::QuickMove)
);
assert_eq!(
clicks.press(&slot, Button::Left, true, false, false),
Some(Input::Pickup)
);
}
}
+1 -66
View File
@@ -98,34 +98,9 @@ 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).
@@ -215,9 +190,6 @@ 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 {
@@ -226,21 +198,10 @@ 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,
dynamic,
fallback,
}
Self { download, fallback }
}
}
/// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every
@@ -405,9 +366,6 @@ 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);
}
@@ -423,9 +381,6 @@ 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,
@@ -463,10 +418,8 @@ 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> {
@@ -483,7 +436,6 @@ impl BlockEntity {
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
@@ -491,7 +443,6 @@ 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>] {
@@ -505,11 +456,9 @@ 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(_)
@@ -531,11 +480,9 @@ 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(_)
@@ -781,18 +728,6 @@ impl Furnace {
.recipes_used
.entry(recipe_used_key(&recipe.id))
.or_insert(0) += 1;
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
// the bucket in the fuel slot.
if self.items[0]
.as_ref()
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
&& self.items[1]
.as_ref()
.is_some_and(|f| &*f.item == "minecraft:bucket")
{
self.items[1] =
Some(ItemStack::new("minecraft:water_bucket", 1));
}
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
+3 -16
View File
@@ -105,13 +105,9 @@ impl Report {
}
}
/// 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.
/// 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.
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,
@@ -121,12 +117,8 @@ 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). A
/// nightly is its version as the launcher installs it (`nightly-2026-10-28-0c02067`).
/// the folder the launcher installed it in, named after the tag (releases from before 0.2).
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}"));
}
@@ -533,11 +525,6 @@ 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}")));
+13 -17
View File
@@ -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 horizon_client_core::potions;
use crate::brewing;
#[derive(Clone, Debug, PartialEq)]
pub struct EffectInstance {
@@ -179,14 +179,13 @@ impl PlayerEffects {
self.list.clear();
}
/// The effects of a saved player (`Player.active_effects`: id,
/// duration, amplifier, ambient, particles visible).
/// duration, amplifier, ambient, visible).
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) = potions::effect(id) {
for (id, duration, amplifier, ambient, _) in saved {
if let Some(def) = brewing::effect(id) {
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
e.ambient = *ambient;
e.visible = *particles;
effects.add(e);
}
}
@@ -214,9 +213,8 @@ impl PlayerEffects {
}
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
/// (on its last tick); `absorption_empty` is `AbsorptionMobEffect`'s
/// answer: the effect ends once the absorption is used up. Returns the
/// actions to apply, in order, and whether Raid Omen was newly added.
/// (on its last tick). Returns the actions to apply, in order, and
/// whether Raid Omen was newly added.
///
/// Bad Omen turning into a new Raid Omen adds an entry to the map being
/// iterated: vanilla's iterator then throws a
@@ -226,7 +224,6 @@ impl PlayerEffects {
pub fn tick_with(
&mut self,
omen: &mut dyn FnMut(&EffectInstance) -> OmenTick,
absorption_empty: bool,
) -> (Vec<EffectAction>, bool) {
self.tick_count = self.tick_count.wrapping_add(1);
let mut actions = Vec::new();
@@ -249,7 +246,6 @@ impl PlayerEffects {
let answer = match e.id {
"bad_omen" => omen(e),
"raid_omen" if t == 1 => omen(e),
"absorption" if absorption_empty => OmenTick::Remove,
_ => OmenTick::Continue,
};
match answer {
@@ -306,10 +302,10 @@ impl PlayerEffects {
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
let mut lasting = Vec::new();
let mut now = Vec::new();
if let Some(def) = potion.and_then(potions::potion) {
if let Some(def) = potion.and_then(brewing::potion) {
for (id, duration, amp) in def.effects {
let id = potions::effect(id).map_or("speed", |e| e.id);
if potions::effect(id).is_some_and(|e| e.instant) {
let id = brewing::effect(id).map_or("speed", |e| e.id);
if brewing::effect(id).is_some_and(|e| e.instant) {
now.extend(instant(id, *amp, 1.0));
} else {
// withScaledDuration: max(floor(d * scale), 1).
@@ -469,13 +465,13 @@ mod tests {
};
// The first tick adds Raid Omen and stops there (the iterator's
// ConcurrentModificationException): Bad Omen and Speed stay as they were.
let (_, added) = fx.tick_with(&mut convert, false);
let (_, added) = fx.tick_with(&mut convert);
assert!(added);
assert_eq!(fx.get("bad_omen").unwrap().duration, 100);
assert_eq!(fx.get("speed").unwrap().duration, 50);
assert_eq!(fx.get("raid_omen").unwrap().duration, 600);
// The second refreshes Raid Omen and drops Bad Omen.
let (_, added) = fx.tick_with(&mut convert, false);
let (_, added) = fx.tick_with(&mut convert);
assert!(!added);
assert!(fx.get("bad_omen").is_none());
assert_eq!(fx.get("speed").unwrap().duration, 49);
@@ -490,9 +486,9 @@ mod tests {
seen.push(e.duration);
OmenTick::Remove
};
fx.tick_with(&mut watch, false);
fx.tick_with(&mut watch);
assert!(fx.get("raid_omen").is_some());
fx.tick_with(&mut watch, false);
fx.tick_with(&mut watch);
assert!(fx.get("raid_omen").is_none());
assert_eq!(seen, vec![1]);
}
+4 -24
View File
@@ -582,21 +582,17 @@ impl Experience {
7 + level * 2
}
}
/// Returns the first multiple of 5 the player reached, where each level
/// gained would play the level-up sound (`Player.giveExperienceLevels`).
pub fn give(&mut self, points: i32) -> Option<i32> {
let mut sound_level = None;
/// Returns true when a level was gained.
pub fn give(&mut self, points: i32) -> bool {
let start = self.level;
self.progress += points as f32 / Self::needed(self.level) as f32;
self.total = self.total.saturating_add(points).max(0);
while self.progress >= 1.0 {
self.progress = (self.progress - 1.0) * Self::needed(self.level) as f32;
self.level += 1;
if self.level % 5 == 0 {
sound_level.get_or_insert(self.level);
}
self.progress /= Self::needed(self.level) as f32;
}
sound_level
self.level > start
}
}
/// A painting (`Painting`, a `HangingEntity`): the block it hangs in, the
@@ -771,24 +767,9 @@ 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,
@@ -807,7 +788,6 @@ impl ArmorStand {
custom_name: None,
name_visible: false,
last_hit: i64::MIN / 2,
pose: Self::DEFAULT_POSE,
}
}
pub fn environment_tick(
+14 -72
View File
@@ -14,8 +14,7 @@ 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,
// w: the glint scroll clock (`Glint::scroll_ticks`).
// y: Nether cardinal lighting, z: how many dynamic_lights are set.
time: vec4<f32>,
crumbling_sprite: vec4<f32>,
// Selection outline parameters (read by terrain.wgsl).
@@ -28,12 +27,6 @@ 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>,
@@ -51,9 +44,6 @@ 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 {
@@ -113,23 +103,18 @@ 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>, @location(6) glint_uv: vec2<f32>) -> Out {
var out = entity_vertex(position, uv, overlay, color, light, normal);
out.glint_uv = glint_uv;
return out;
@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);
}
// 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(7) offset: vec3<f32>) -> Out {
@location(6) offset: vec3<f32>) -> Out {
return entity_vertex(offset + position, uv, overlay, color, light, normal);
}
struct CloudInfo {
@@ -181,27 +166,6 @@ 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);
@@ -239,30 +203,22 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
// lit texture, then the lightmap, then the glint and fog.
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 {
// 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)),
);
if in.overlay == 7u || in.overlay == 8u {
// 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);
}
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;
}
@@ -281,20 +237,6 @@ 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,8 +186,6 @@ 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])];
+1 -155
View File
@@ -187,31 +187,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.remove(&horizon_world::nbt::JavaString::from(key));
}
}
// LivingEntity.addAdditionalSaveData: `active_effects` only when there are
// any (MobEffectInstance.CODEC; a mob's effects are never ambient, and one
// given with `hideParticles` shows neither particles nor its icon).
if m.extra.effects.is_empty() {
map.remove(&horizon_world::nbt::JavaString::from("active_effects"));
} else {
let effects = m
.extra
.effects
.iter()
.map(|&(effect, duration, amplifier)| {
let shown = !m.extra.hidden_effects.contains(&effect);
let mut e = vec![
("id", string(crate::brewing::namespaced(effect))),
("amplifier", Tag::Byte(amplifier.clamp(0, 255) as u8 as i8)),
("duration", Tag::Int(duration)),
];
if !shown {
e.push(("show_particles", Tag::Byte(0)));
}
e.push(("show_icon", Tag::Byte(i8::from(shown))));
Tag::compound(e)
});
map.insert("active_effects".into(), Tag::List(effects.collect()));
}
map.insert(
"Invulnerable".into(),
Tag::Byte(i8::from(m.invulnerable_flag)),
@@ -339,18 +314,6 @@ 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" {
@@ -503,32 +466,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
crate::mobs::villager::write_nbt(m, map);
}
"minecraft:wandering_trader" => put_offers(map, m),
"minecraft:axolotl" => {
write_axolotl_memories(m, map);
map.insert(
"Variant".into(),
Tag::Int(i32::from(m.extra.variant.min(4))),
);
}
"minecraft:parrot" => {
map.insert(
"Variant".into(),
Tag::Int(i32::from(m.extra.variant.min(4))),
);
}
"minecraft:salmon" => {
let size = SALMON_SIZES[usize::from(m.extra.variant).min(2)];
map.insert("type".into(), string(size));
}
// TropicalFish.addAdditionalSaveData: the packed variant.
"minecraft:tropical_fish" => {
let pattern = m.extra.variant.min(11);
let packed = i32::from(pattern >= 6)
| i32::from(pattern % 6) << 8
| i32::from(m.extra.variant2 & 15) << 16
| i32::from(m.extra.variant3 & 15) << 24;
map.insert("Variant".into(), Tag::Int(packed));
}
"minecraft:axolotl" => write_axolotl_memories(m, map),
"minecraft:allay" => {
// InventoryCarrier: the fetched stack; the liked player memory.
let mut inv = Vec::new();
@@ -589,10 +527,6 @@ 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));
@@ -966,25 +900,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.no_gravity = flag(tag, "NoGravity");
m.glowing = flag(tag, "Glowing");
m.ticks_frozen = number(tag.get("TicksFrozen")).unwrap_or(0) as i32;
// LivingEntity.readAdditionalSaveData: the saved `active_effects`, the last
// entry of an effect winning.
if let Some(Tag::List(effects)) = tag.get("active_effects") {
for e in effects {
let Some(def) = text(e.get("id")).and_then(|id| crate::brewing::effect(&id)) else {
continue;
};
let effect = crate::brewing::namespaced(def.id);
let duration = number(e.get("duration")).unwrap_or(0) as i32;
let amplifier = number(e.get("amplifier")).unwrap_or(0) as i32;
m.extra.effects.retain(|x| x.0 != effect);
m.extra.effects.push((effect, duration, amplifier));
// Details.CODEC: `show_particles` defaults to true.
m.extra.hidden_effects.retain(|h| *h != effect);
if number(e.get("show_particles")) == Some(0) {
m.extra.hidden_effects.push(effect);
}
}
}
m.silent = flag(tag, "Silent");
m.invulnerable_flag = flag(tag, "Invulnerable");
if m.species() == crate::mobs::Species::Bat {
@@ -1106,11 +1021,6 @@ 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"));
@@ -1138,7 +1048,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
if m.species() == crate::mobs::Species::Axolotl {
restore_axolotl_memories(&mut m, tag);
}
read_variant(&mut m, tag);
m.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
m.air = number(tag.get("Air")).unwrap_or(300) as i32;
if m.species() == crate::mobs::Species::Allay {
@@ -1219,12 +1128,6 @@ 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;
@@ -1849,15 +1752,6 @@ 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 [
@@ -1897,19 +1791,6 @@ 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`...):
@@ -2348,41 +2229,6 @@ fn write_axolotl_memories(m: &Mob, map: &mut Compound) {
}
}
}
/// The variant tags `Axolotl`, `Rabbit`, `Parrot`, `Salmon` and
/// `TropicalFish` read; a missing tag is the default variant (a medium
/// salmon, otherwise index 0).
fn read_variant(m: &mut Mob, tag: &Tag) {
use crate::mobs::Species as S;
let int = |key| number(tag.get(key)).unwrap_or(0) as i32;
match m.species() {
S::Axolotl | S::Parrot => m.extra.variant = int("Variant").clamp(0, 4) as u8,
// Rabbit.Variant.LEGACY_CODEC: an unknown id is brown.
S::Rabbit => {
m.extra.variant = match int("RabbitType") {
v @ (0..=5 | 99) => v as u8,
_ => 0,
}
}
S::Salmon => {
m.extra.variant = text(tag.get("type"))
.and_then(|t| SALMON_SIZES.iter().position(|v| *v == t))
.unwrap_or(1) as u8;
}
S::TropicalFish => {
let packed = int("Variant");
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) & 15) as u8;
m.extra.variant3 = ((packed >> 24) & 15) as u8;
}
_ => {}
}
}
fn restore_axolotl_memories(m: &mut Mob, tag: &Tag) {
let memories = tag.get("Brain").and_then(|b| b.get("memories"));
let ttl = memories
-2
View File
@@ -48,8 +48,6 @@ 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;
+3 -8
View File
@@ -889,17 +889,12 @@ impl Font {
if s.contains('§') {
return self.width_text(&Text::legacy(s));
}
crate::bidi::visual_str(s)
.chars()
.map(|c| self.glyph(c).advance)
.sum::<f32>()
.ceil()
s.chars().map(|c| self.glyph(c).advance).sum::<f32>().ceil()
}
/// Font.width of a component: `§` codes in the spans are formatting,
/// not glyphs. Right-to-left text is measured as it is drawn, shaped.
/// not glyphs.
pub fn width_text(&self, t: &Text) -> f32 {
let t = t.resolved();
crate::bidi::visual(&t)
t.resolved()
.chars()
.map(|(c, s)| self.advance(c, s))
.sum::<f32>()
File diff suppressed because it is too large. Load diff
+35 -304
View File
@@ -9,7 +9,6 @@ use horizon_client::{
text::{self, Style, Text},
toasts::Slide,
};
use horizon_core::geometry::Vec3;
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, VecDeque},
@@ -22,9 +21,6 @@ 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)>,
@@ -40,7 +36,6 @@ impl Toast {
frame: Frame::Task,
title: Text::literal(""),
slide: Slide::default(),
slot: None,
recipes: items,
changed: true,
last_changed: 0,
@@ -87,8 +82,6 @@ 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.
@@ -187,14 +180,6 @@ 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,
}
}
@@ -539,7 +524,6 @@ impl Game<'_> {
frame: d.frame,
title: text_of(&d.title),
slide: Slide::default(),
slot: None,
recipes: Vec::new(),
changed: false,
last_changed: 0,
@@ -663,28 +647,6 @@ 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| {
@@ -719,123 +681,6 @@ 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();
@@ -863,72 +708,6 @@ 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();
let villager = villager.clone();
let it = item_view(stack);
self.trigger("minecraft:villager_trade", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let vctx = adv::Entities {
this: Some(&villager),
..pctx
};
adv::conditions(&c["player"], &pctx, w)
&& adv::conditions(&c["villager"], &vctx, w)
&& 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 {
@@ -1027,105 +806,58 @@ impl Game<'_> {
c["from"].as_str().is_none_or(|f| f == from) && c["to"].as_str().is_none_or(|t| t == to)
});
}
/// `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) {
/// `ToastManager` with the `AdvancementToast` and the `RecipeToast`,
/// one at a time.
pub(super) fn draw_toasts(&mut self, batch: &mut HudBatch) {
let multiplier = f64::from(self.settings.notification_display_time);
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 {
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();
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, y);
batch.sprite(atlas, "gui/sprites/toast/recipe", x, 0.0);
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, y + 7.0, 0xFF50_0050, false);
batch.text(atlas, &description, x + 30.0, y + 18.0, 0xFF00_0000, false);
batch.text(atlas, &title, x + 30.0, 7.0, 0xFF50_0050, false);
batch.text(atlas, &description, x + 30.0, 18.0, 0xFF00_0000, false);
let start = batch.vertices.len();
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);
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);
return;
}
let atlas = &self.gui.atlas;
batch.sprite(atlas, "gui/sprites/toast/advancement", x, y);
batch.sprite(atlas, "gui/sprites/toast/advancement", x, 0.0);
let (key, color) = match frame {
Frame::Task => ("advancements.toast.task", 0xFF00_FFFF),
Frame::Goal => ("advancements.toast.goal", 0xFF00_FFFF),
@@ -1135,12 +867,12 @@ impl Game<'_> {
atlas,
&Text::translate(key, &[]),
x + 30.0,
y + 7.0,
7.0,
color,
false,
);
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);
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);
}
}
/// `Advancement.name`: the bracketed title in the frame's colour, with the
@@ -1321,7 +1053,6 @@ impl Game<'_> {
frame: d.frame,
title: text_of(&d.title),
slide: Slide::default(),
slot: None,
recipes: Vec::new(),
changed: false,
last_changed: 0,
-16
View File
@@ -568,22 +568,6 @@ 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,
+2 -3
View File
@@ -131,8 +131,7 @@ impl Game<'_> {
let m = &mut self.mobs[i];
m.extra.ints[1] -= 1;
m.extra.ints[3] = 60;
// ItemEntity.setDefaultPickUpDelay.
self.spawn_item(ItemStack::new(&item, 1), from, v, 10);
self.spawn_item(ItemStack::new(&item, 1), from, v, 40);
if self.world.game_time % 7 == 0 && self.world.random.next_double() < 0.9 {
const PITCHES: [f32; 16] = [
0.5625, 0.625, 0.75, 0.9375, 1.0, 1.0, 1.125, 1.25, 1.5, 1.875, 2.0, 2.25, 2.5,
@@ -161,7 +160,7 @@ impl Game<'_> {
}
// Allay.duplicateAllay: entity event 18, three spawnHeartParticle.
let id = self.mobs[index].id;
self.mob_event_particles(id, 18);
self.particles_around_mob(id, "heart", horizon_client::particles::Burst::ALLAY);
self.audio
.play("minecraft:block.amethyst_block.chime", Some(at), 2.0, 1.0);
self.consume_held();
+18 -49
View File
@@ -627,8 +627,21 @@ 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);
// ArmorStandModel.setupAnim: the stand's pose and part visibility.
apply_pose(&mut poses, &a.pose);
// 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]);
}
}
// Item layers pose from the hidden arms too (`visible` only skips drawing).
let posed = poses.clone();
let mut hide = |name: &str| {
@@ -698,23 +711,7 @@ impl Game<'_> {
let color = undyed.map_or(0xFFFF_FFFF, |c| {
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
});
// 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);
let layer_poses = Poses::new(part);
horizon_client::mobs::render::draw(
part,
base,
@@ -722,7 +719,7 @@ impl Game<'_> {
uv,
color,
packed,
overlay,
0,
out,
);
// EquipmentLayerRenderer: the trim layer over the armour.
@@ -737,7 +734,7 @@ impl Game<'_> {
trim,
0xFFFF_FFFF,
packed,
overlay,
0,
out,
);
}
@@ -746,33 +743,6 @@ 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(),
@@ -907,7 +877,6 @@ impl Game<'_> {
lights: Some(horizon_client::block_entity_render::diffuse_lights(
self.dim == super::Dim::Nether,
)),
glint: self.glint(),
};
let profile = stack
.extra
+5 -138
View File
@@ -3,10 +3,8 @@
//! 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::{
@@ -196,139 +194,19 @@ 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 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]) {
/// without a target go for a player in the same box.
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));
let mut players: Vec<Option<u32>> = Vec::new();
if self
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())
@@ -341,20 +219,9 @@ impl Game<'_> {
.collect();
bees.retain(|&i| self.mobs[i].ai.target.is_none());
for i in bees {
// Util.getRandom over the players.
let pick = self
.world
.random
.next_int_bound(players.len() as i32)
.unwrap_or(0) as usize;
// Util.getRandom over the one player.
let _ = self.world.random.next_int_bound(1);
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,24 +76,23 @@ 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`). 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 {
/// on both halves of a double chest (`CompoundContainer`).
pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) {
// Spectators never count as openers.
!self.spectator() && self.container_openers_changed(key, open)
if !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) -> bool {
pub(super) fn container_openers_changed(&mut self, key: (i32, i32, i32), open: 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) => {
let edge = self.lid_openers_changed(key, open);
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
@@ -103,27 +102,26 @@ 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, edge) = if open {
let (count, _) = 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. True when the count crossed zero.
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
/// and for the guests.
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
let (before, count) = self.count_opener(key, open);
let pos = [key.0, key.1, key.2];
self.block_entity_event(pos, 1, count);
@@ -141,15 +139,12 @@ 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`).
/// True when the count crossed zero.
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
let (before, count) = self.count_opener(key, open);
let edge = (before == 0) != (count == 0);
if edge {
if (before == 0) != (count == 0) {
self.set_bool_prop(
key,
"open",
@@ -157,7 +152,6 @@ 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`).
@@ -553,7 +547,7 @@ impl Game<'_> {
});
}
/// `Entity.isInRain`: rain at the block position or at the top of the box.
pub(super) fn in_rain(&mut self, at: Vec3, top: f64) -> bool {
fn in_rain(&mut self, at: Vec3, top: f64) -> bool {
let p = BlockPos::new(
at.x.floor() as i32,
at.y.floor() as i32,
@@ -731,38 +725,21 @@ 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<String> {
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<&str> {
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()?;
// 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);
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) {
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.to_owned())
Some(skin.fallback)
}
}
+20 -84
View File
@@ -8,7 +8,7 @@ use horizon_client::{
containers::{BlockEntity, Lectern},
first_person::Hand,
hud::{HudBatch, rgba},
item_data::{StackData, WrittenBook},
item_data::WrittenBook,
items::ItemStack,
menu::{Kind, Menu},
text::Text,
@@ -59,86 +59,14 @@ pub(super) enum BookScreen {
page_edit: ui::EditBox,
},
View {
pages: Vec<Text>,
pages: Vec<String>,
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.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),
_ => {}
}
self.gui.atlas.font.split(&Text::literal(page), 114.0)
}
/// `WritableBookItem.use` / `WrittenBookItem.use` (`Player.openItemGui`).
/// A server's player writes in its own client's editor
@@ -187,7 +115,11 @@ impl Game<'_> {
}
}
"minecraft:written_book" => BookScreen::View {
pages: view_pages(stack.data()),
pages: stack
.data()
.and_then(|d| d.written.as_ref())
.map(|w| w.pages.clone())
.unwrap_or_default(),
page: 0,
lectern: None,
},
@@ -283,7 +215,6 @@ impl Game<'_> {
author,
generation: 0,
pages,
page_tags: Vec::new(),
});
*stack = written;
} else {
@@ -754,7 +685,11 @@ impl Game<'_> {
return false;
};
let Some(book) = &l.book else { return false };
let pages = view_pages(book.data());
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 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 {
@@ -876,7 +811,11 @@ impl Game<'_> {
page,
}) = self.book.as_mut()
{
*pages = view_pages(book.as_ref().and_then(ItemStack::data));
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();
*page = (*page).min(pages.len().saturating_sub(1));
}
}
@@ -1016,10 +955,7 @@ impl Game<'_> {
lectern,
}) => {
let (page, count, lectern) = (*page, pages.len(), *lectern);
// 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) {
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);
@@ -1262,7 +1198,7 @@ impl Game<'_> {
} => {
indicator(batch, *page, pages.len());
if let Some(text) = pages.get(*page) {
for (i, line) in self.page_lines(text).iter().take(LINES).enumerate() {
for (i, line) in self.book_lines(text).iter().take(LINES).enumerate() {
batch.text_styled(
atlas,
line,
@@ -1,138 +0,0 @@
//! `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;
}
}
}
+36 -92
View File
@@ -106,18 +106,6 @@ 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 {
@@ -707,78 +695,12 @@ 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);
}
}
// 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);
None => {}
}
}
/// Chat lines over the HUD (focused while the chat screen is open).
@@ -867,11 +789,6 @@ impl Game<'_> {
pub(super) fn sync_rules(&mut self) {
self.world.health_rules.natural_regeneration =
self.ui.rules.bool("natural_health_regeneration");
// Player.isInvulnerableTo: the damage-type rules.
self.world.health_rules.drowning_damage = self.ui.rules.bool("drowning_damage");
self.world.health_rules.fall_damage = self.ui.rules.bool("fall_damage");
self.world.health_rules.fire_damage = self.ui.rules.bool("fire_damage");
self.world.health_rules.freeze_damage = self.ui.rules.bool("freeze_damage");
use horizon_world::food::Difficulty;
self.world.health_rules.difficulty = match self.ui.difficulty {
0 => Difficulty::Peaceful,
@@ -884,11 +801,7 @@ impl Game<'_> {
pub(super) fn load_world_state(&mut self) {
self.ui.rules = GameRules::load(&self.world_root);
self.ui.clock = WorldClock::default();
// PrimaryLevelData: the difficulty comes back from level.dat's difficulty_settings.
self.ui.difficulty = horizon_client::title::DIFFICULTIES
.iter()
.position(|d| *d == self.level.difficulty)
.map_or(2, |i| i as u8);
self.ui.difficulty = read_difficulty(&self.world_root).unwrap_or(2);
if let Ok(bytes) = std::fs::read(self.world_root.join("data/minecraft/world_clocks.dat"))
&& let Ok(raw) = horizon_world::compression::Compression::Gzip.decode(&bytes, 1 << 20)
&& let Ok((root, _)) =
@@ -913,9 +826,9 @@ impl Game<'_> {
self.build_registries();
self.sync_rules();
}
/// Save the clock beside level.dat (Level.write saves the game rules: a
/// second writer of game_rules.dat could leave it with the rules loaded
/// when the world opened).
/// Save the clock and the difficulty beside level.dat (Level.write saves
/// the game rules: a second writer of game_rules.dat could leave it with
/// the rules loaded when the world opened).
pub(super) fn save_world_state(&mut self) {
if self.world_root.as_os_str().is_empty() {
return;
@@ -963,6 +876,9 @@ impl Game<'_> {
if let Err(e) = horizon_client::save::write_dat(&path, root) {
log::warn!("the world clocks were not written: {e}");
}
if let Err(e) = write_difficulty(&self.world_root, self.ui.difficulty) {
log::warn!("the difficulty was not written: {e}");
}
}
/// Death message in chat (CombatTracker.getDeathMessage, as far as the
/// cause is known to the client).
@@ -1072,6 +988,34 @@ pub fn screenshot_name(dir: &std::path::Path) -> String {
n += 1;
}
}
fn read_difficulty(world: &std::path::Path) -> Option<u8> {
let bytes = std::fs::read(world.join("level.dat")).ok()?;
let raw = horizon_world::compression::Compression::Gzip
.decode(&bytes, 64 << 20)
.ok()?;
let (root, _) =
horizon_world::nbt::read_named(&raw, horizon_world::nbt::Limits::default()).ok()?;
match root.tag.get("Data")?.get("Difficulty")? {
horizon_world::nbt::Tag::Byte(b) => Some((*b).clamp(0, 3) as u8),
_ => None,
}
}
/// Patch `Data.Difficulty` in level.dat (Level::write stores the default).
fn write_difficulty(world: &std::path::Path, difficulty: u8) -> horizon_client::Result<()> {
use horizon_world::{compression::Compression, nbt};
let path = world.join("level.dat");
let bytes = std::fs::read(&path)?;
let raw = Compression::Gzip.decode(&bytes, 64 << 20)?;
let (mut root, _) = nbt::read_named(&raw, nbt::Limits::default()).map_err(|e| e.to_string())?;
if let Some(data) = root.tag.get_mut("Data")
&& let Some(d) = data.get_mut("Difficulty")
{
*d = nbt::Tag::Byte(difficulty as i8);
}
let out = nbt::write_named(&root, nbt::Limits::default()).map_err(|e| e.to_string())?;
std::fs::write(&path, Compression::Gzip.encode(&out)?)?;
Ok(())
}
/// `IntegratedServer.getProfilePermissions` for the owner: `OWNER` (4) when
/// commands are allowed, else `ALL` (0).
@@ -1,178 +0,0 @@
//! 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,10 +17,8 @@ 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 {
@@ -39,7 +37,6 @@ impl Game<'_> {
}
}
self.eye_particles();
self.panda_effects();
self.sprint_dust();
self.effect_swirls();
}
+3 -68
View File
@@ -1,16 +1,7 @@
//! The player's `CombatTracker`: the damage taken lately, who dealt it and
//! where the player fell from, turned into the vanilla death message.
use super::{Game, chat::PLAYER_NAME};
use horizon_client::{
health::HealthEvent,
motion::MotionEvent,
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::random::RandomSource;
/// `EntityEvent.DROWN_PARTICLES`: broadcast with the drowning damage.
pub(super) const DROWN_EVENT: i8 = 67;
use horizon_client::{health::HealthEvent, motion::MotionEvent, text::Text};
struct Entry {
damage_type: String,
@@ -45,13 +36,6 @@ 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.
@@ -61,38 +45,6 @@ pub(super) struct CombatTracker {
fall_before: f64,
}
impl Game<'_> {
/// `LivingEntity.makeDrownParticles`: eight bubbles around the entity,
/// moving with it.
pub(super) fn drown_particles(&mut self, at: [f64; 3], velocity: [f64; 3]) {
let bubble = ParticleOptions::simple(ParticleType::Bubble);
for _ in 0..8 {
let mut triangle = || {
let r = self.particles.random();
r.next_double() - r.next_double()
};
let p = [at[0] + triangle(), at[1] + triangle(), at[2] + triangle()];
self.particles.add(&bubble, p, velocity);
}
}
/// Entity event 67 from a server, for this client's own player or an
/// entity it draws.
pub(super) fn remote_drown(&mut self, id: i32) {
let own = self.remote.as_ref().is_some_and(|r| r.entity_id == id);
let body = if own {
self.player.as_ref().map(|p| {
let (at, v) = (p.position, p.velocity);
([at.x, at.y, at.z], [v.x, v.y, v.z])
})
} else {
self.mobs.iter().find(|m| m.id == id as u32).map(|m| {
let (at, v) = (m.position, m.velocity);
([at.x, at.y, at.z], [v.x, v.y, v.z])
})
};
if let Some((at, v)) = body {
self.drown_particles(at, v);
}
}
/// The display name of a mob: its custom name or its type's name.
pub(super) fn mob_display_name(&self, id: u32) -> Option<Text> {
let m = self.mobs.iter().find(|m| m.id == id)?;
@@ -130,27 +82,14 @@ impl Game<'_> {
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
/// source's `DamageEffects` (`Player.getHurtSound`).
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
// Player.getVoicePitch: always 1.0.
self.audio.play(sound, Some(at), 1.0, 1.0);
let pitch = self.audio.hurt_pitch();
self.audio.play(sound, Some(at), 1.0, pitch);
}
/// `CombatTracker.recordDamage` for the motion events of this tick.
pub(super) fn record_combat(&mut self, events: &[MotionEvent]) {
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;
@@ -197,10 +136,6 @@ 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();
@@ -2,7 +2,7 @@
//! tick (`execute`, `executeChain`), `BaseCommandBlock.performCommand`
//! through the command system with the block's output, and
//! `CommandBlockEditScreen` with what `handleSetCommandBlock` does with it.
use super::{Game, Screen, commands::execute::Source, lan::LanHost};
use super::{Game, Screen, commands::execute::Source};
use horizon_client::{
command_block::{self, CommandBlock, Mode},
containers::BlockEntity,
@@ -12,7 +12,6 @@ use horizon_client::{
world::World,
};
use horizon_core::position::BlockPos;
use horizon_protocol::packets::play::{self, Serverbound as S};
use horizon_world::{
chunk::BlockState,
level::flags,
@@ -36,16 +35,6 @@ pub(super) struct CommandBlockScreen {
auto: bool,
}
/// What `ServerboundSetCommandBlockPacket` carries.
struct CommandBlockEdit {
pos: Pos,
command: String,
track_output: bool,
mode: Mode,
conditional: bool,
auto: bool,
}
#[derive(Clone, Copy, PartialEq)]
enum Button {
Output,
@@ -197,18 +186,6 @@ impl Game<'_> {
pub(super) fn can_use_game_master_blocks(&self) -> bool {
self.creative && self.ui.registries.permission >= 2
}
/// `CommandBlock.useWithoutItem` on a server's client, which opens the
/// screen itself (`Player.openCommandBlock`) while the server only
/// answers the click: the block at `pos`, when it is a command block the
/// player may edit.
pub(super) fn open_command_block_if_editable(&mut self, pos: Pos) {
let is_command_block = self
.block_state_at(pos)
.is_some_and(|state| Mode::of_block(&state.name).is_some());
if is_command_block && self.can_use_game_master_blocks() {
self.open_command_block(pos);
}
}
fn command_block(&self, pos: Pos) -> Option<&CommandBlock> {
match self.world.block_entities.get(&pos)? {
BlockEntity::CommandBlock(c) => Some(c),
@@ -595,66 +572,12 @@ impl Game<'_> {
s.command.insert_text(&allowed);
}
}
/// The edit screen's Done: a server's client sends the packet, the
/// window's own world applies it (`handleSetCommandBlock`).
/// `ServerGamePacketListenerImpl.handleSetCommandBlock`.
fn set_command_block(&mut self, s: &CommandBlockScreen) {
if !self.can_use_game_master_blocks() {
self.system_message(Text::translate("advMode.notAllowed", &[]));
return;
}
let edit = CommandBlockEdit {
pos: s.pos,
command: s.command.value.clone(),
track_output: s.track_output,
mode: s.mode,
conditional: s.conditional,
auto: s.auto,
};
if self.remote.is_some() {
self.send(S::SetCommandBlock(play::SetCommandBlock {
pos: [edit.pos.0, edit.pos.1, edit.pos.2],
command: edit.command,
mode: edit.mode.ordinal(),
flags: i8::from(edit.track_output)
| (i8::from(edit.conditional) << 1)
| (i8::from(edit.auto) << 2),
}));
} else {
self.apply_command_block(&edit);
}
}
/// `ServerGamePacketListenerImpl.handleSetCommandBlock` for a guest: the
/// block is edited only by a creative player with game master commands.
pub(super) fn lan_set_command_block(
&mut self,
host: &mut LanHost,
conn: u32,
p: play::SetCommandBlock,
) {
let Some(g) = host.hub.guest(conn) else {
return;
};
let allowed = g.creative() && g.permission >= 2;
let edit = CommandBlockEdit {
pos: (p.pos[0], p.pos[1], p.pos[2]),
command: p.command,
track_output: p.flags & 1 != 0,
mode: Mode::from_ordinal(p.mode),
conditional: p.flags & 2 != 0,
auto: p.flags & 4 != 0,
};
self.as_guest(host, conn, |game| {
if allowed {
game.apply_command_block(&edit);
// BaseCommandBlock.onUpdated: the clients get the new command.
game.mark_block_entity_updated(edit.pos);
} else {
game.system_message(Text::translate("advMode.notAllowed", &[]));
}
});
}
/// What `handleSetCommandBlock` does once the player may edit the block.
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
let pos = s.pos;
let registry = self.pack.assets.registry;
let Some(current) = self.block_state_at(pos) else {
@@ -686,7 +609,7 @@ impl Game<'_> {
}
let _ = self.process_level_events();
}
let command = s.command.clone();
let command = s.command.value.clone();
let Some(c) = command_block_mut(&mut self.world, pos) else {
return;
};
+79 -232
View File
@@ -42,10 +42,9 @@ pub(super) enum Target {
Guest(u32),
Mob(u32),
Item(u32),
/// An experience orb, by its entity id.
Orb(u32),
/// A projectile, by its entity id.
Projectile(u32),
Orb(usize),
/// By index in `creatures.projectiles`.
Projectile(usize),
Stand(u32),
Frame(u32),
Painting(u32),
@@ -54,10 +53,6 @@ pub(super) enum Target {
/// An entity of a type Horizon does not simulate (markers, displays,
/// interactions...): kept as its saved data at a position.
Other(u32),
/// An End crystal, by its entity id (`minecraft:end_crystal`).
Crystal(u32),
/// The Ender Dragon, by its entity id (`minecraft:ender_dragon`).
Dragon(u32),
}
/// An entity of a type Horizon does not simulate: its compound lives in the
/// entity memory (`persist.raw`), its position and rotation here.
@@ -345,16 +340,14 @@ impl Game<'_> {
.iter()
.find(|i| i.id == id)
.map(|i| [i.position.x, i.position.y, i.position.z]),
Target::Orb(id) => self
Target::Orb(i) => self
.orbs
.iter()
.find(|o| o.id == id)
.get(i)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Projectile(id) => self
Target::Projectile(i) => self
.creatures
.projectiles
.iter()
.find(|a| a.id == id)
.get(i)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Stand(id) => self
.armor_stands
@@ -392,16 +385,6 @@ impl Game<'_> {
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Crystal(id) => self
.crystals
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Dragon(id) => self
.dragon
.as_ref()
.filter(|d| d.id == id)
.map(|d| [d.position.x, d.position.y, d.position.z]),
}
}
pub(super) fn target_type(&self, t: Target) -> String {
@@ -416,11 +399,10 @@ impl Game<'_> {
}),
Target::Item(_) => "minecraft:item".into(),
Target::Orb(_) => "minecraft:experience_orb".into(),
Target::Projectile(id) => self
Target::Projectile(i) => self
.creatures
.projectiles
.iter()
.find(|p| p.id == id)
.get(i)
.map_or_else(String::new, |p| p.kind.id().into()),
Target::Stand(_) => "minecraft:armor_stand".into(),
Target::Frame(id) => {
@@ -446,8 +428,6 @@ impl Game<'_> {
.iter()
.find(|o| o.id == id)
.map_or_else(String::new, |o| o.kind.clone()),
Target::Crystal(_) => "minecraft:end_crystal".into(),
Target::Dragon(_) => "minecraft:ender_dragon".into(),
}
}
/// The name of the player the game acts for: a guest's own while their
@@ -532,72 +512,50 @@ impl Game<'_> {
s.pos[2].unwrap_or(p[2]),
]
};
let mut source_candidates: Vec<Target> = Vec::new();
let mut candidates: Vec<Target> = Vec::new();
match (s.kind, self.command_source) {
// @s: the executing entity (none for a server source).
('s', Some(source)) => source_candidates.extend(source.entity),
('s', None) => source_candidates.extend(self.source_entity()),
('s', Some(source)) => candidates.extend(source.entity),
('s', None) => candidates.extend(self.source_entity()),
// A guest in place is among the players, in join order.
_ if self.player.is_some() && self.lan_acting.is_none() => {
source_candidates.push(Target::Player);
candidates.push(Target::Player);
}
_ => {}
}
if s.kind != 's' {
source_candidates.extend(self.player_candidates(s));
use horizon_client::projectiles::ProjectileKind as K;
candidates.extend(self.player_candidates(s));
candidates.extend(
self.mobs
.iter()
.filter(|m| m.death_time == 0)
.map(|m| Target::Mob(m.id)),
);
candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id)));
candidates.extend((0..self.orbs.len()).map(Target::Orb));
candidates.extend(
self.creatures
.projectiles
.iter()
.enumerate()
// The kinds /data can write (entity_nbt).
.filter(|(_, p)| {
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
})
.map(|(i, _)| Target::Projectile(i)),
);
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id)));
candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id)));
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
}
let source_dim = self.dim;
// A selector with a spatial constraint reaches the level the command
// runs in only; an unbounded one reaches every loaded level
// (EntitySelector.findEntities).
let dims = if s.world_limited() {
vec![source_dim]
} else {
self.loaded_dims()
};
let mut out: Vec<(Target, f64)> = Vec::new();
for dim in dims {
let back = self.enter_command_level(dim);
let mut candidates = Vec::new();
if dim == source_dim {
candidates.append(&mut source_candidates);
}
if s.kind != 's' {
candidates.extend(self.level_candidates());
}
out.extend(self.select_in_level(candidates, s, origin));
if let Some(back) = back {
self.enter_command_level(back);
}
}
match s.sort {
Sort::Nearest => out.sort_by(|a, b| a.1.total_cmp(&b.1)),
Sort::Furthest => out.sort_by(|a, b| b.1.total_cmp(&a.1)),
Sort::Random => {
for i in (1..out.len()).rev() {
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
out.swap(i, j);
}
}
Sort::Arbitrary => {}
}
out.into_iter()
.take(s.limit.max(1))
.map(|(t, _)| t)
.collect()
}
/// The candidates of the level in place that pass the selector
/// (EntitySelector.getPredicate).
fn select_in_level(
&mut self,
candidates: Vec<Target>,
s: &Selector,
origin: [f64; 3],
) -> Vec<(Target, f64)> {
let level = self.experience.level;
let creative = self.creative;
let passing = self.selector_extra_filter(&candidates, s);
candidates
let mut out: Vec<(Target, f64)> = candidates
.into_iter()
.filter_map(|t| {
let p = self.target_position(t)?;
@@ -669,101 +627,22 @@ impl Game<'_> {
}
Some((t, d2))
})
.collect()
}
/// The entities of the level in place a selector can find.
fn level_candidates(&self) -> Vec<Target> {
use horizon_client::projectiles::ProjectileKind as K;
let mut candidates = Vec::new();
candidates.extend(
self.mobs
.iter()
.filter(|m| m.death_time == 0)
.map(|m| Target::Mob(m.id)),
);
candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id)));
candidates.extend(self.orbs.iter().map(|o| Target::Orb(o.id)));
candidates.extend(
self.creatures
.projectiles
.iter()
// The kinds /data can write (entity_nbt).
.filter(|p| {
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
})
.map(|p| Target::Projectile(p.id)),
);
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id)));
candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id)));
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
// The End crystals and the dragon (EndCrystal and EnderDragon
// are entities of their own).
candidates.extend(self.crystals.iter().map(|c| Target::Crystal(c.id)));
if let Some(d) = &self.dragon {
candidates.push(Target::Dragon(d.id));
}
candidates
}
/// The loaded levels, in the order the server holds them: the
/// overworld, the nether, the end (MinecraftServer.getAllLevels).
fn loaded_dims(&self) -> Vec<Dim> {
let mut dims = vec![self.dim];
dims.extend(self.levels.parked_dims());
dims.sort_by_key(|d| match d {
Dim::Overworld => 0,
Dim::Nether => 1,
Dim::End => 2,
});
dims
}
/// Whether the level in place holds the entity a target names.
fn level_holds(&self, t: Target) -> bool {
match t {
// A player is in no level of its own.
Target::Player | Target::Guest(_) => true,
Target::Mob(id) => self.mobs.iter().any(|m| m.id == id),
Target::Item(id) => self.dropped.iter().any(|i| i.id == id),
Target::Orb(id) => self.orbs.iter().any(|o| o.id == id),
Target::Projectile(id) => self.creatures.projectiles.iter().any(|p| p.id == id),
Target::Stand(id) => self.armor_stands.iter().any(|a| a.id == id),
Target::Frame(id) => self.item_frames.iter().any(|f| f.id == id),
Target::Painting(id) => self.paintings.iter().any(|p| p.id == id),
Target::Boat(id) => self.boats.iter().any(|b| b.id == id),
Target::Cart(id) => self.carts.iter().any(|c| c.id == id),
Target::Other(id) => self.others.iter().any(|o| o.id == id),
Target::Crystal(id) => self.crystals.iter().any(|c| c.id == id),
Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id),
}
}
/// Run `f` with the level that holds the entity a target names in place
/// (the entity ids are unique across the loaded levels) and put the
/// level back afterwards. `f` does not run when no level holds the
/// entity.
pub(in crate::game) fn in_target_level<R>(
&mut self,
t: Target,
f: impl FnOnce(&mut Self) -> R,
) -> Option<R> {
if self.level_holds(t) {
return Some(f(self));
}
for dim in self.levels.parked_dims() {
let back = self.enter_command_level(dim);
if self.level_holds(t) {
let r = f(self);
if let Some(back) = back {
self.enter_command_level(back);
.collect();
match s.sort {
Sort::Nearest => out.sort_by(|a, b| a.1.total_cmp(&b.1)),
Sort::Furthest => out.sort_by(|a, b| b.1.total_cmp(&a.1)),
Sort::Random => {
for i in (1..out.len()).rev() {
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
out.swap(i, j);
}
return Some(r);
}
if let Some(back) = back {
self.enter_command_level(back);
}
Sort::Arbitrary => {}
}
None
out.into_iter()
.take(s.limit.max(1))
.map(|(t, _)| t)
.collect()
}
fn entity_tag_matches(&self, tag: &str, ty: &str) -> bool {
let path = self.options.resources.join(format!(
@@ -1224,10 +1103,6 @@ impl Game<'_> {
Ok(targets.len() as i32)
}
fn teleport_to(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
// An entity in another level is teleported in the level that holds it.
let _ = self.in_target_level(t, |g| g.teleport_to_in_level(t, pos, rotation));
}
fn teleport_to_in_level(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
let v = Vec3::new(pos[0], pos[1], pos[2]);
match t {
Target::Guest(_) => {
@@ -1276,14 +1151,14 @@ impl Game<'_> {
i.previous = v;
}
}
Target::Orb(id) => {
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
Target::Orb(i) => {
if let Some(o) = self.orbs.get_mut(i) {
o.position = v;
o.previous = v;
}
}
Target::Projectile(id) => {
if let Some(a) = self.creatures.projectiles.iter_mut().find(|a| a.id == id) {
Target::Projectile(i) => {
if let Some(a) = self.creatures.projectiles.get_mut(i) {
a.position = v;
a.previous = v;
}
@@ -1323,17 +1198,6 @@ impl Game<'_> {
}
}
}
Target::Crystal(id) => {
if let Some(c) = self.crystals.iter_mut().find(|c| c.id == id) {
c.position = v;
}
}
Target::Dragon(id) => {
if let Some(d) = self.dragon.as_mut().filter(|d| d.id == id) {
d.position = v;
d.previous = v;
}
}
// Wall-mounted entities do not move.
Target::Frame(_) | Target::Painting(_) => {}
}
@@ -1693,10 +1557,6 @@ impl Game<'_> {
Ok(duration.unwrap_or(-1))
}
fn kill_target(&mut self, t: Target) {
// An entity in another level is killed in the level that holds it.
let _ = self.in_target_level(t, |g| g.kill_target_in_level(t));
}
fn kill_target_in_level(&mut self, t: Target) {
match t {
Target::Guest(_) => {
self.for_player(t, |g| g.kill_target(Target::Player));
@@ -1716,13 +1576,16 @@ impl Game<'_> {
}
}
Target::Item(id) => self.dropped.retain(|i| i.id != id),
Target::Orb(id) => {
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
o.age = u32::MAX / 2;
Target::Orb(i) => {
if i < self.orbs.len() {
self.orbs[i].age = u32::MAX / 2;
}
}
Target::Projectile(id) => {
self.creatures.projectiles.retain(|p| p.id != id);
// cmd_kill goes from the highest index down.
Target::Projectile(i) => {
if i < self.creatures.projectiles.len() {
self.creatures.projectiles.remove(i);
}
}
Target::Stand(id) => self.armor_stands.retain(|a| a.id != id),
Target::Frame(id) => self.item_frames.retain(|f| f.id != id),
@@ -1730,11 +1593,7 @@ impl Game<'_> {
Target::Boat(id) => self.boats.retain(|b| b.id != id),
Target::Cart(id) => self.carts.retain(|c| c.id != id),
Target::Other(id) => self.others.retain(|o| o.id != id),
Target::Crystal(id) => self.kill_crystal(id),
Target::Dragon(id) => self.kill_dragon(id),
}
// The orbs marked for removal are gone (Entity.kill removes them).
self.orbs.retain(|o| o.age < u32::MAX / 2);
}
fn cmd_kill(&mut self, ctx: &Context) -> R<i32> {
let targets = if ctx.get("targets").is_some() {
@@ -1742,18 +1601,19 @@ impl Game<'_> {
} else {
vec![self.implied_player()]
};
let mut names: Vec<Text> = Vec::new();
for &t in &targets {
// The name of an entity in another level is read there.
let name = match self.in_target_level(t, |g| g.target_name(t)) {
Some(name) => name,
None => self.target_name(t),
};
names.push(name);
}
for &t in &targets {
let names: Vec<Text> = targets.iter().map(|&t| self.target_name(t)).collect();
// Remove indexed entities from the back so earlier indices stay valid.
let mut ordered = targets.clone();
ordered.sort_by_key(|t| {
std::cmp::Reverse(match t {
Target::Orb(i) | Target::Projectile(i) => *i,
_ => 0,
})
});
for t in ordered {
self.kill_target(t);
}
self.orbs.retain(|o| o.age < u32::MAX / 2);
let message = if targets.len() == 1 {
Text::translate("commands.kill.success.single", &[names[0].clone()])
} else {
@@ -1845,20 +1705,6 @@ impl Game<'_> {
self.spawn_bolt(pos, false);
name = Text::translate("entity.minecraft.lightning_bolt", &[]);
}
"end_crystal" => {
// EndCrystal's constructor takes an id from the entity
// counter, and the crystal is shown with its base.
self.next_entity += 1;
self.crystals.push(super::dragon::Crystal {
id: self.next_entity,
position: Vec3::new(pos[0], pos[1], pos[2]),
show_bottom: true,
time: self.world.random.next_int_bound(100_000).unwrap_or(0),
beam: None,
invulnerable: false,
});
name = Text::translate("entity.minecraft.end_crystal", &[]);
}
k if super::minecarts::CartKind::from_id(&kind).is_some() => {
let cart_kind = super::minecarts::CartKind::from_id(&kind)
.unwrap_or(super::minecarts::CartKind::Rideable);
@@ -2049,7 +1895,8 @@ impl Game<'_> {
return false;
}
self.world.block_entities.insert(key, new);
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
let state = self.world.block(pos[0], pos[1], pos[2]);
let _ = self.world.set(pos[0], pos[1], pos[2], state);
true
}
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
@@ -31,13 +31,8 @@ fn set_key(tag: &mut Tag, key: &str, value: Tag) {
impl Game<'_> {
// ---- entity data ---------------------------------------------------
/// `NbtPredicate.getEntityTagToCompare`: the entity as saved. An entity
/// in another level is read in the level that holds it.
/// `NbtPredicate.getEntityTagToCompare`: the entity as saved.
pub(in crate::game) fn entity_nbt(&mut self, t: Target) -> Option<Tag> {
self.in_target_level(t, |g| g.entity_nbt_in_level(t))
.flatten()
}
fn entity_nbt_in_level(&mut self, t: Target) -> Option<Tag> {
let player_uuid = self.level.player_uuid;
Some(match t {
Target::Guest(_) => {
@@ -118,11 +113,9 @@ impl Game<'_> {
let o = self.others.iter().find(|o| o.id == id)?.clone();
self.other_to_nbt(&o, uuid)
}
Target::Orb(id) => {
entity_nbt::orb_to_nbt(self.orbs.iter().find(|o| o.id == id)?, random_uuid())
}
Target::Projectile(id) => {
let p = self.creatures.projectiles.iter().find(|p| p.id == id)?;
Target::Orb(i) => entity_nbt::orb_to_nbt(self.orbs.get(i)?, random_uuid()),
Target::Projectile(i) => {
let p = self.creatures.projectiles.get(i)?;
let owner = if p.owner == Some(horizon_client::mobs::Who::Player) {
player_uuid
} else {
@@ -136,16 +129,6 @@ impl Game<'_> {
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))?
}
Target::Crystal(id) => {
let c = self.crystals.iter().find(|c| c.id == id)?;
let (uuid, raw) = self.persist_anon("minecraft:end_crystal", c.position);
c.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
}
Target::Dragon(id) => {
let d = self.dragon.as_ref().filter(|d| d.id == id)?;
let (uuid, raw) = self.persist_anon("minecraft:ender_dragon", d.position);
d.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
}
})
}
/// `Entity.load` of new data (`EntityDataAccessor.setData`): the entity is
@@ -414,19 +397,6 @@ impl Game<'_> {
}
pub(super) fn cmd_data(&mut self, id: &str, ctx: &Context) -> R<i32> {
let target = self.data_ref(ctx, 0)?;
// An entity in another level is read and written in the level that
// holds it.
if let DataRef::Entity(t) = &target {
let t = *t;
let target = target.clone();
return match self.in_target_level(t, |g| g.cmd_data_in(id, ctx, target)) {
Some(r) => r,
None => Err(err("argument.entity.notfound.entity", &[])),
};
}
self.cmd_data_in(id, ctx, target)
}
fn cmd_data_in(&mut self, id: &str, ctx: &Context, target: DataRef) -> R<i32> {
match id {
"data.merge" => {
let Some(Arg::Str(nbt)) = ctx.get("nbt") else {
@@ -1,10 +1,11 @@
//! `/effect` (EffectCommands) and `/enchant` (EnchantCommand).
use super::{Arg, Context, Game, R, Target, err, texts};
use horizon_client::{
brewing::{self, namespaced},
brewing,
effects::EffectInstance,
text::{self, Style, Text},
};
use std::{collections::HashMap, sync::OnceLock};
/// `CommandResponseTracker` over targets: elements with a non-zero result.
#[derive(Default)]
@@ -20,6 +21,24 @@ impl Tracker {
}
}
}
/// Namespaced effect ids for mob effect storage.
fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
IDS.get_or_init(|| {
brewing::EFFECTS
.iter()
.map(|e| {
(
e.id,
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
)
})
.collect()
})
.get(id)
.copied()
.unwrap_or(id)
}
fn effect_name(id: &str) -> Text {
Text::translate(&format!("effect.minecraft.{id}"), &[])
}
@@ -663,11 +663,10 @@ impl Game<'_> {
// TraceableEntity.getOwner: who shot a projectile, who threw an
// item, who summoned a vex.
Some("origin") => match entity {
Target::Projectile(id) => self
Target::Projectile(i) => self
.creatures
.projectiles
.iter()
.find(|p| p.id == id)
.get(i)
.and_then(|p| match (p.owner?, p.guest) {
// A guest's shot: `Who::Player` is them.
(horizon_client::mobs::Who::Player, Some(conn))
@@ -119,7 +119,6 @@ impl Game<'_> {
"magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic,
"sting" => DamageKind::Sting,
"cactus" | "sweet_berry_bush" => DamageKind::Cactus,
"freeze" => DamageKind::Freeze,
"in_wall" => DamageKind::InWall,
"thorns" => DamageKind::Thorns,
_ => DamageKind::Generic,
-11
View File
@@ -67,17 +67,6 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.player.burp", self.feet(), 0.5, pitch);
}
// SuspiciousStewEffects.onConsume, a ConsumableListener: each entry is
// added as a `MobEffectInstance(effect, duration)` (amplifier 0).
if let Some(data) = stack.extra.as_deref() {
for (id, duration) in &data.stew_effects {
if let Some(def) = horizon_client::brewing::effect(id) {
self.add_player_effect(horizon_client::effects::EffectInstance::new(
def.id, *duration, 0,
));
}
}
}
for effect in &consumable.effects {
self.apply_consume_effect(effect);
}
@@ -44,13 +44,13 @@ impl Game<'_> {
slot: usize,
stack: Option<ItemStack>,
) {
let key = (chest[0], chest[1], chest[2]);
if let Some(e) = self.world.block_entities.get_mut(&key)
if let Some(e) = self
.world
.block_entities
.get_mut(&(chest[0], chest[1], chest[2]))
&& let Some(s) = e.slots_mut().get_mut(slot)
{
*s = stack;
// TransportItemsBetweenContainers calls Container.setChanged.
self.container_changed(key);
}
}
/// `ContainerOpenersCounter.recheckOpeners` every five ticks: the golems
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -222,7 +222,7 @@ impl Game<'_> {
a.pierce = pierce;
}
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
self.add_projectile(arrow);
self.creatures.projectiles.push(arrow);
}
self.player_projectile_shot();
let pitch = if index == 0 {
+2 -165
View File
@@ -13,7 +13,7 @@ use horizon_client::{
save::VERSION_NAME,
text::Text,
};
use horizon_core::geometry::{Axis, Vec3};
use horizon_core::geometry::Axis;
use winit::keyboard::KeyCode;
/// `ChatFormatting.YELLOW` and `RED`.
@@ -766,8 +766,7 @@ impl Game<'_> {
}
let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a);
let lerpf = |a: f32, b: f32| a + alpha * (b - a);
let guests = self.lan.iter().flat_map(|h| h.models.values());
for m in self.mobs.iter().chain(guests) {
for m in &self.mobs {
if m.removed || m.invisible || m.has_effect("minecraft:invisibility") {
continue;
}
@@ -797,159 +796,6 @@ impl Game<'_> {
// EntityType.ITEM: 0.25 by 0.25, eyes at 0.2125.
g.hitbox(pos, (0.25, 0.25), 0.2125, false, (0.0, 0.0));
}
// The other entities (EntityTypes sizes; eyes at 0.85 of the height
// unless the type sets one). `at` is the interpolated position.
let at = |previous: Vec3, now: Vec3| {
[
lerp(previous.x, now.x),
lerp(previous.y, now.y),
lerp(previous.z, now.z),
]
};
for p in &self.paintings {
let (min, max) = p.bounds();
let centre = std::array::from_fn(|i| (min[i] + max[i]) / 2.0);
g.entity_box(min, max, centre, 0.425, false, step_view(p.step()));
}
for f in self.item_frames.iter().filter(|f| !f.invisible) {
let (min, max) = f.bounds();
let centre = std::array::from_fn(|i| (min[i] + max[i]) / 2.0);
g.entity_box(min, max, centre, 0.0, false, step_view(f.step()));
}
for c in &self.cushions {
let (min, max) = c.bounds();
let p = c.position;
g.entity_box(min, max, [p.x, p.y, p.z], 0.2125, false, (0.0, c.yaw));
}
for a in self.armor_stands.iter().filter(|a| !a.invisible) {
// ArmorStand.getDimensions: a marker has none, a small one is
// half scale with its eyes at 0.9875.
let (size, eye) = match (a.marker, a.small) {
(true, _) => ((0.0, 0.0), 0.0),
(_, true) => ((0.25, 0.9875), 0.9875),
_ => ((0.5, 1.975), 1.7775),
};
let p = a.position;
g.hitbox([p.x, p.y, p.z], size, eye, true, (0.0, a.yaw));
}
for b in &self.boats {
let h = super::boats::HEIGHT as f32;
g.hitbox(
at(b.previous, b.position),
(super::boats::WIDTH as f32, h),
h,
false,
(0.0, b.yaw),
);
}
for c in &self.carts {
let (w, h) = (
super::minecarts::WIDTH as f32,
super::minecarts::HEIGHT as f32,
);
g.hitbox(
at(c.previous, c.position),
(w, h),
h * 0.85,
false,
(0.0, c.yaw),
);
}
for o in &self.orbs {
g.hitbox(
at(o.previous, o.position),
(0.5, 0.5),
0.425,
false,
(0.0, 0.0),
);
}
for f in &self.falling {
g.hitbox(
at(f.previous, f.position),
(0.98, 0.98),
0.833,
false,
(0.0, 0.0),
);
}
for t in &self.tnts {
g.hitbox(
at(t.previous, t.position),
(0.98, 0.98),
0.15,
false,
(0.0, 0.0),
);
}
for c in &self.crystals {
let p = c.position;
g.hitbox([p.x, p.y, p.z], (2.0, 2.0), 1.7, false, (0.0, 0.0));
}
for p in &self.creatures.projectiles {
use horizon_client::projectiles::ProjectileKind as K;
let (size, eye) = match p.kind {
K::Arrow { .. } | K::Trident => (0.5, 0.13),
K::Snowball | K::Egg | K::EnderPearl | K::LlamaSpit | K::Potion { .. } => {
(0.25, 0.2125)
}
K::SmallFireball | K::ShulkerBullet { .. } | K::WitherSkull { .. } => {
(0.3125, 0.265_625)
}
K::WindCharge | K::BreezeWindCharge => (0.3125, 0.0),
K::LargeFireball { .. } => (1.0, 0.85),
};
g.hitbox(
at(p.previous, p.position),
(size, size),
eye,
false,
(p.pitch, p.yaw),
);
}
for t in &self.tridents {
g.hitbox(
at(t.previous, t.position),
(0.5, 0.5),
0.13,
false,
(t.pitch, t.yaw),
);
}
for r in &self.rockets {
// Projectile.updateRotation: the look follows the motion.
let v = r.velocity;
let yaw = v.x.atan2(v.z).to_degrees() as f32;
let pitch = v.y.atan2(v.x.hypot(v.z)).to_degrees() as f32;
g.hitbox(
at(r.previous, r.position),
(0.25, 0.25),
0.2125,
false,
(pitch, yaw),
);
}
for h in self
.hook
.iter()
.map(|h| (h.previous, h.position))
.chain(self.remote_hooks.iter().map(|h| (h.previous, h.position)))
{
g.hitbox(at(h.0, h.1), (0.25, 0.25), 0.2125, false, (0.0, 0.0));
}
for b in &self.bolts {
g.hitbox(b.at, (0.0, 0.0), 0.0, false, (0.0, 0.0));
}
// The dragon: 16 by 8, a living entity; its parts follow.
if let Some(d) = &self.dragon {
g.hitbox(
at(d.previous, d.position),
(16.0, 8.0),
6.8,
true,
(0.0, d.yaw),
);
}
// The dragon's parts.
for (id, b) in self.special_targets() {
if (super::dragon::SPECIAL..super::dragon::SPECIAL + 8).contains(&id) {
@@ -982,12 +828,3 @@ impl Game<'_> {
g
}
}
/// The (x, y) rotations whose view vector is the step of a direction.
fn step_view(step: [i32; 3]) -> (f32, f32) {
let [x, y, z] = step.map(f64::from);
(
(-y).asin().to_degrees() as f32,
(-x.atan2(z)).to_degrees() as f32,
)
}
@@ -120,11 +120,6 @@ impl Game<'_> {
p.guest = guest;
// Not into the one who turned it on its way out.
p.left_owner = false;
// `AbstractHurtingProjectile.onDeflection` by an attack, and the
// `needsSync` that `AIM_DEFLECT` sets: a server's players see it turn
// at once, not at the fireball's next update ten ticks later.
p.acceleration = 0.1;
p.needs_sync = true;
self.reset_attack_strength();
self.swing_arm();
let from = self
+1 -1
View File
@@ -1034,7 +1034,7 @@ impl Game<'_> {
a.pickup = horizon_client::arrows::Pickup::Allowed;
}
shot.shoot(&mut own, dir, power, uncertainty);
self.add_projectile(shot);
self.creatures.projectiles.push(shot);
}
}
}
+21 -182
View File
@@ -27,8 +27,6 @@ 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 {
@@ -50,9 +48,6 @@ 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)]
@@ -267,9 +262,6 @@ 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,
@@ -278,8 +270,6 @@ 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],
@@ -301,11 +291,11 @@ pub(super) enum FightWho {
}
/// A player in the End as the dragon sees it.
#[derive(Clone, Copy, Debug)]
pub(super) struct FightPlayer {
pub(super) who: FightWho,
pub(super) position: Vec3,
pub(super) bounds: Aabb,
pub(super) alive: bool,
struct FightPlayer {
who: FightWho,
position: Vec3,
bounds: Aabb,
alive: bool,
creative: bool,
spectator: bool,
}
@@ -395,7 +385,6 @@ 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,
@@ -408,7 +397,6 @@ impl Dragon {
flame_count: 0,
sitting_damage: 0.0,
growl: 100,
ambient_time: 0,
ticks: 0,
history: [(position.y, yaw); 64],
head: 0,
@@ -438,10 +426,6 @@ 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;
@@ -709,7 +693,7 @@ impl Game<'_> {
}
/// The players in the level the dragon sees (`level.players()`): the
/// window's own, then every guest in the level.
pub(super) fn fight_players(&self) -> Vec<FightPlayer> {
fn fight_players(&self) -> Vec<FightPlayer> {
let mut out = Vec::new();
if let Some(p) = self.player.as_ref() {
out.push(FightPlayer {
@@ -738,7 +722,7 @@ impl Game<'_> {
}
/// Run `f` as the fight's player `who` (a guest's turn, its knockback
/// sent to its client).
pub(super) fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) {
fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) {
match who {
FightWho::Host => f(self),
FightWho::Guest(conn) => {
@@ -844,10 +828,8 @@ impl Game<'_> {
continue;
}
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
// EndCrystal's constructor takes an id from the entity counter.
self.next_entity += 1;
self.crystals.push(Crystal {
id: self.next_entity,
id: 0,
position: at,
show_bottom: true,
time,
@@ -909,12 +891,8 @@ 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();
@@ -925,7 +903,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, by, false);
self.hurt_dragon(0, 10.0, true, false);
} else if let Some(n) = d.nearest_crystal.as_mut()
&& *n > index
{
@@ -997,46 +975,18 @@ impl Game<'_> {
self.destroy_crystal(index, by, CrystalDamage::Direct);
Hurt::Landed { full: true }
} else if id >= SPECIAL {
let by = by_player.then(|| self.acting_player());
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, by, arrow)
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, arrow)
} else {
Hurt::Refused
}
}
/// `EnderDragon.hurt(part, source, damage)` on the dragon in the level.
/// `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 {
fn hurt_dragon(&mut self, part: usize, damage: f32, counts: bool, 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
@@ -1044,47 +994,6 @@ 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)> {
@@ -1657,7 +1566,6 @@ impl Game<'_> {
age: 0,
duration: 200,
from_dragon: true,
potion: None,
});
}
}
@@ -1750,24 +1658,6 @@ 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;
@@ -2188,18 +2078,12 @@ impl Game<'_> {
age: 0,
duration: 600,
from_dragon: false,
potion: None,
});
// DragonFireball.onHit: level event 2006 (the burst of dragon
// breath and the explosion sound) at its block position.
self.level_event_at(
2006,
[
next.x.floor() as i32,
next.y.floor() as i32,
next.z.floor() as i32,
],
1,
self.audio.play(
"minecraft:entity.dragon_fireball.explode",
Some([next.x, next.y, next.z]),
1.0,
1.0,
);
return false;
}
@@ -2226,10 +2110,6 @@ impl Game<'_> {
let mut harmed: Vec<(FightWho, f32)> = Vec::new();
let mut clouds = std::mem::take(&mut self.breath_clouds);
clouds.retain_mut(|c| {
// Lingering potions' clouds tick with the potions.
if c.potion.is_some() {
return true;
}
c.age += 1;
if c.age >= c.duration {
return false;
@@ -2247,12 +2127,10 @@ impl Game<'_> {
c.at[1],
c.at[2] + f64::from(a.sin() * d),
];
self.particles.add_with(
self.particles.add(
&ParticleOptions::simple(ParticleType::DragonBreath),
at,
[0.0, 0.0, 0.0],
false,
true,
);
}
// AreaEffectCloud: after its 20-tick wait, every 20 ticks per entity.
@@ -2379,22 +2257,8 @@ pub(super) struct Breath {
pub(super) at: [f64; 3],
pub(super) radius: f32,
pub(super) age: i32,
pub(super) duration: i32,
pub(super) from_dragon: bool,
/// A lingering potion's cloud (`ThrownLingeringPotion`): what it applies.
pub(super) potion: Option<super::potions::PotionCloud>,
}
impl Breath {
/// `AreaEffectCloud.isWaiting`: before its wait time (20 ticks, 10 for a
/// lingering potion's).
pub(super) fn waiting(&self) -> bool {
self.age
< if self.potion.is_some() {
super::potions::LINGERING_WAIT
} else {
20
}
}
duration: i32,
from_dragon: bool,
}
impl Game<'_> {
/// `EnderDragonRenderer` and `EnderDragonModel.setupAnim`.
@@ -2621,8 +2485,6 @@ impl Game<'_> {
layer: 2,
color: c,
light: 0,
glint_uv: [0.0, 0.0],
});
}
}
@@ -2688,8 +2550,6 @@ impl Game<'_> {
layer: 6,
color: c,
light: wrap_light,
glint_uv: [0.0, 0.0],
});
}
(last_sin, last_cos, last_u) = (sin, cos, uu);
@@ -2735,8 +2595,6 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light: 240 | 240 << 8,
glint_uv: [0.0, 0.0],
});
}
}
@@ -2862,9 +2720,8 @@ 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, by, false);
self.hurt_dragon(part, amount, true, false);
}
}
}
@@ -2978,11 +2835,8 @@ impl Game<'_> {
return false;
}
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
// EndCrystal's constructor takes an id from the entity counter, so
// the crystal is an entity a command can name (`@e[type=end_crystal]`).
self.next_entity += 1;
self.crystals.push(Crystal {
id: self.next_entity,
id: 0,
position: Vec3::new(x + 0.5, y, z + 0.5),
show_bottom: false,
time,
@@ -2999,21 +2853,6 @@ impl Game<'_> {
self.swing_arm();
true
}
/// `EndCrystal.kill` (`/kill`): the crystal is removed, without a blast
/// (`Entity.kill` does not hurt it).
pub(super) fn kill_crystal(&mut self, id: u32) {
if let Some(i) = self.crystals.iter().position(|c| c.id == id) {
self.destroy_crystal(i, None, CrystalDamage::Explosion);
}
}
/// `EnderDragon.kill` (`/kill`): `Entity.kill` removes the entity, it
/// does not hurt it (the dragon flies its death flight when its health
/// runs out).
pub(super) fn kill_dragon(&mut self, id: u32) {
if self.dragon.as_ref().is_some_and(|d| d.id == id) {
self.dragon = None;
}
}
/// Any entity touching `room` (`Level.getEntities(null, box)`).
fn entity_in(&self, room: Aabb) -> bool {
let touches = |lo: Vec3, hi: Vec3| {
@@ -157,10 +157,8 @@ impl Game<'_> {
items: Some(self.res.items()),
..Default::default()
};
// minecraft:post_attack effects of the attacker's weapon on the victim:
// ignite (fire aspect) and apply_mob_effect (bane of arthropods).
// minecraft:post_attack ignite effects of the attacker's weapon (fire aspect).
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;
@@ -170,33 +168,24 @@ impl Game<'_> {
.into_iter()
.flatten()
{
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")
)
if effect["enchanted"] == "attacker"
&& effect["affected"] == "victim"
&& effect["effect"]["type"] == "minecraft:ignite"
{
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));
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,
));
}
}
}
}
@@ -206,41 +195,8 @@ 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`).
@@ -351,10 +307,8 @@ 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 += loss;
s.damage += 2;
if self
.res
.items()
+36 -270
View File
@@ -5,7 +5,6 @@
use super::{Dim, Game};
use horizon_client::{
Result,
containers::{BlockEntity, EndGateway},
environment::Environment,
items::ItemStack,
mesher::Vertex,
@@ -28,64 +27,14 @@ 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, PartialEq, Eq)]
#[derive(Clone, Copy, Debug)]
pub(super) enum GatewayJob {
/// TheEndGatewayBlockEntity.findOrCreateValidTeleportPos from this gateway.
/// `exact` is the gateway's own `ExactTeleport`, read while its chunk is loaded.
Find { gateway: [i32; 3], exact: bool },
Find { gateway: [i32; 3] },
/// 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.
@@ -298,25 +247,16 @@ impl Game<'_> {
layer: 2,
color,
light: 0,
glint_uv: [0.0, 0.0],
});
}
}
out
}
/// LightmapRenderStateExtractor: End flashes add to the sky light factor
/// unless Hide Lightning Flashes is on (the sky sprite is not gated), a
/// third of it under a boss bar's world fog.
/// LightmapRenderStateExtractor: End flashes add to the sky light factor.
pub(super) fn end_flash_sky(&self, sky: &mut horizon_client::environment::Sky, partial: f64) {
if self.dim == Dim::End && !self.settings.file.bool("hideLightningFlashes") {
let intensity = self.end.flash.intensity(partial as f32);
sky.sky_light_factor += if self.boss_overlay.should_create_world_fog() {
intensity / 3.0
} else {
intensity
};
if self.dim == Dim::End {
sky.sky_light_factor += self.end.flash.intensity(partial as f32);
}
}
@@ -659,9 +599,7 @@ 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. Like
/// scanState and spawnExitPortal, only while the fight has no exit portal
/// location: a podium that is later broken stays broken.
/// first gateway (spawnNewGateway) if the portal is not there yet.
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)))
@@ -671,14 +609,8 @@ impl Game<'_> {
}
let won = self.fight.previously_killed;
let gateway = self.first_gateway();
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 (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 won
&& self.world.loaded(gateway[0], gateway[2])
&& &*self.block_name(gateway) != "minecraft:end_gateway"
@@ -709,40 +641,17 @@ 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, found without assuming the centre column is intact.
[0, self.podium_level().unwrap_or(61), 0]
// 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]
}
};
self.place_podium(origin, true)
@@ -845,11 +754,6 @@ impl Game<'_> {
}
}
}
self.world
.block_entities
.get_or_insert_with((origin[0], origin[1], origin[2]), || {
BlockEntity::EndGateway(Default::default())
});
Ok(())
}
@@ -871,7 +775,8 @@ impl Game<'_> {
self.end.exits.as_mut().expect("loaded exits")
}
fn save_gateway_exits(&mut self) -> Result<()> {
fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> {
self.gateway_exits().insert(gateway, (exit, exact));
let list: Vec<_> = self
.gateway_exits()
.iter()
@@ -883,35 +788,6 @@ 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<()> {
@@ -931,39 +807,25 @@ 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 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);
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,
}
}
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,
);
None => GatewayJob::Find { gateway },
};
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;
@@ -1020,10 +882,12 @@ impl Game<'_> {
[p[0], p[1] + 1, p[2]]
};
match job {
GatewayJob::Known { exit, exact } => Ok(bottom_center(exit_target(exit, exact, |e| {
find_exit(self, e)
}))),
GatewayJob::Find { gateway, exact } => {
GatewayJob::Known { exit, exact } => Ok(bottom_center(if exact {
exit
} else {
find_exit(self, exit)
})),
GatewayJob::Find { gateway } => {
// findExitPortalXZPosTentative over the loaded chunks.
let dir = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
.normalize_or_zero();
@@ -1083,11 +947,8 @@ impl Game<'_> {
let exit = [tallest[0], tallest[1] + 10, tallest[2]];
self.place_gateway(exit)?;
self.set_gateway_exit(exit, gateway, false)?;
// 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)
})))
self.set_gateway_exit(gateway, exit, false)?;
Ok(bottom_center(find_exit(self, exit)))
}
}
}
@@ -1379,103 +1240,8 @@ 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
}
);
}
}
}
+1 -43
View File
@@ -307,12 +307,6 @@ 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(
@@ -400,14 +394,7 @@ impl Game<'_> {
// it kills drop its charged-creeper loot (`Creeper.killedEntity`).
let source = horizon_client::mobs::Damage {
kind: horizon_client::mobs::DamageKind::Explosion,
// 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),
attacker: exploding.map(horizon_client::mobs::Who::Mob),
from: Some(center),
};
if hurtable && !m.damage(damage(dist, exposure), source) {
@@ -503,15 +490,6 @@ 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(
@@ -628,22 +606,6 @@ 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);
@@ -676,10 +638,6 @@ 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") {
+40 -58
View File
@@ -23,7 +23,7 @@ pub(super) struct Rocket {
/// The entity id a server tracks it by (0 until it is tracked).
pub id: u32,
pub(super) position: Vec3,
pub(super) previous: Vec3,
previous: Vec3,
pub(super) velocity: Vec3,
life: i32,
lifetime: i32,
@@ -62,56 +62,11 @@ impl Rocket {
item,
}
}
/// 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) {
/// One tick of a server's client: the previous position, then `step`.
pub(super) fn remote_step(&mut self, step: impl FnOnce(Vec3) -> Vec3) {
self.previous = self.position;
if self.attached_to.is_none() {
self.free_flight(scene);
}
self.life += 1;
}
/// `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
self.position = step(self.position);
}
/// Position and motion (the dispenser parity fixture).
pub(super) fn motion(&self) -> (Vec3, Vec3) {
@@ -236,14 +191,46 @@ 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,
};
if r.free_flight(&mut scene) && r.angled {
// hitTargetOrDeflectSelf: explode on the block.
r.life = r.lifetime;
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
};
}
}
let at = [r.position.x, r.position.y, r.position.z];
@@ -396,12 +383,7 @@ 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());
// shouldRender: not while attached to an entity (`isAttachedToEntity`).
for r in self
.rockets
.iter()
.filter(|r| !r.attached && r.attached_to.is_none())
{
for r in self.rockets.iter().filter(|r| !r.attached) {
// FireworkRocketEntity.shouldRenderAtSqrDistance: within 64 blocks;
// EntityRenderer.shouldRender: its 0.25 box in the view.
if !self.standing_entity_in_view(
+6 -31
View File
@@ -2,7 +2,6 @@
//! 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,
@@ -31,8 +30,8 @@ enum State {
pub(super) struct Hook {
/// Its entity id, which a server's players track it under.
id: u32,
pub(super) position: Vec3,
pub(super) previous: Vec3,
position: Vec3,
previous: Vec3,
velocity: Vec3,
state: State,
life: i32,
@@ -78,7 +77,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(stack);
let damage = self.retrieve_hook();
self.damage_held(damage);
self.audio.play(
"minecraft:entity.fishing_bobber.retrieve",
@@ -144,7 +143,7 @@ impl Game<'_> {
true
}
/// `FishingHook.retrieve`: the rod damage it costs.
fn retrieve_hook(&mut self, rod: &ItemStack) -> u32 {
fn retrieve_hook(&mut self) -> u32 {
let Some(hook) = self.hook.take() else {
return 0;
};
@@ -168,15 +167,6 @@ 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 {
@@ -201,7 +191,6 @@ 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
@@ -233,7 +222,6 @@ 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,
@@ -253,15 +241,6 @@ 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 {
@@ -454,8 +433,6 @@ impl Game<'_> {
let options = ParticleOptions::simple(kind);
self.particles
.send_particles(&options, at, spread, speed, count);
// The players a server serves are sent them too.
self.lan_send_particles(&options, at, spread, speed, count);
}
/// `FishingHook.tick`.
pub(super) fn tick_fishing(&mut self) {
@@ -666,8 +643,6 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
glint_uv: [0.0, 0.0],
});
}
}
@@ -795,8 +770,8 @@ impl Game<'_> {
pub(super) struct RemoteHook {
id: u32,
owner: u32,
pub(super) position: Vec3,
pub(super) previous: Vec3,
position: Vec3,
previous: Vec3,
}
impl Hook {
+1 -1
View File
@@ -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: p.health.hurt_dir,
hurt_dir: 0.0,
death_time: self.death_time().map(|t| t as f32 + partial),
}),
player: Some((
+1 -3
View File
@@ -378,8 +378,6 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light,
glint_uv: [0.0, 0.0],
});
}
};
@@ -459,7 +457,7 @@ impl Game<'_> {
}
for (id, m, light) in maps {
let entity_start = out.len();
self.map_vertices(id, true, m, light, out);
self.map_vertices(id, m, light, out);
self.cull_entity_vertices(out, entity_start);
}
}
@@ -1,9 +1,8 @@
//! AbstractGameRulesScreen: the rules by category under a search box, a
//! switch or a number box each. InWorldGameRulesScreen, opened from World
//! Options, sends the changed ones on Done (ServerboundSetGameRulePacket) and
//! asks before leaving with changes (ConfirmScreen); WorldCreationGameRulesScreen,
//! opened from Create New World, hands them back and leaves without asking.
use crate::{
//! InWorldGameRulesScreen (AbstractGameRulesScreen), opened from World
//! Options: the rules by category under a search box, a switch or a number
//! box each, and Done sending the changed ones (ServerboundSetGameRulePacket).
//! Leaving with changes asks first (ConfirmScreen).
use horizon_client::{
confirm_screen::Confirm,
font::Font,
gamerules::{self, GameRules, RULES, Rule, Value},
@@ -33,14 +32,14 @@ enum Row {
/// What a click or Escape asks of World Options.
#[derive(Debug, PartialEq)]
pub enum Exit {
pub(super) enum Exit {
/// Done: the rules that changed.
Apply(Vec<(&'static str, Value)>),
/// Cancel, or discarding the changes.
Discard,
}
pub struct RulesScreen {
pub(super) struct RulesScreen {
search: EditBox,
values: GameRules,
initial: GameRules,
@@ -50,8 +49,6 @@ pub struct RulesScreen {
scroll: ui::Scroll,
/// ConfirmScreen "Game Rule Changes" in its place.
confirm: bool,
/// InWorldGameRulesScreen rather than WorldCreationGameRulesScreen.
in_world: bool,
}
fn name_key(r: &Rule) -> String {
@@ -105,15 +102,7 @@ fn confirm(font: &Font, size: (f32, f32)) -> Confirm {
}
impl RulesScreen {
/// InWorldGameRulesScreen over the world's rules.
pub fn new(rules: &GameRules) -> Self {
Self::build(rules, true)
}
/// WorldCreationGameRulesScreen over the new world's rules.
pub fn for_creation(rules: &GameRules) -> Self {
Self::build(rules, false)
}
fn build(rules: &GameRules, in_world: bool) -> Self {
pub(super) fn new(rules: &GameRules) -> Self {
// setInitialFocus: the search box.
let search = EditBox::new(0.0, 0.0, SEARCH_WIDTH, 15.0);
let numbers = RULES
@@ -134,7 +123,6 @@ impl RulesScreen {
numbers,
scroll: ui::Scroll::default(),
confirm: false,
in_world,
}
}
@@ -209,10 +197,9 @@ impl RulesScreen {
})
.collect()
}
/// InWorldGameRulesScreen.onClose: pending changes ask first;
/// WorldCreationGameRulesScreen.onClose discards them.
/// InWorldGameRulesScreen.onClose: pending changes ask first.
fn close(&mut self) -> Option<Exit> {
if !self.in_world || self.changes().is_empty() {
if self.changes().is_empty() {
Some(Exit::Discard)
} else {
self.confirm = true;
@@ -220,7 +207,7 @@ impl RulesScreen {
}
}
/// Escape: ConfirmScreen answers no; the rules close.
pub fn escape(&mut self) -> Option<Exit> {
pub(super) fn escape(&mut self) -> Option<Exit> {
if self.confirm {
self.confirm = false;
None
@@ -229,7 +216,7 @@ impl RulesScreen {
}
}
/// The mouse wheel (AbstractSelectionList: half an entry a step).
pub fn wheel(&mut self, delta: f32, h: f32) {
pub(super) fn wheel(&mut self, delta: f32, h: f32) {
if !self.confirm {
let content = self.content();
self.scroll
@@ -237,20 +224,20 @@ impl RulesScreen {
}
}
/// A press on the scrollbar holds it (AbstractSelectionList.updateScrolling).
pub fn press_scrollbar(&mut self, cursor: (f32, f32), (w, h): (f32, f32)) -> bool {
pub(super) fn press_scrollbar(&mut self, cursor: (f32, f32), (w, h): (f32, f32)) -> bool {
let content = self.content();
self.scroll
.press(cursor, Self::bar(w), HEADER, h - FOOTER, content)
}
/// The pointer moved while the scrollbar is held.
pub fn drag(&mut self, y: f32, h: f32) {
pub(super) fn drag(&mut self, y: f32, h: f32) {
if self.scroll.held() {
let content = self.content();
self.scroll.drag(y, HEADER, h - FOOTER, content);
}
}
/// The button came up: the scrollbar lets go.
pub fn release(&mut self) {
pub(super) fn release(&mut self) {
self.scroll.release();
}
fn focused(&mut self) -> Option<(Option<&'static str>, &mut EditBox)> {
@@ -285,7 +272,7 @@ impl RulesScreen {
}
}
/// Run an edit on the focused box, then its responder.
pub fn edit(&mut self, f: impl FnOnce(&mut EditBox)) {
pub(super) fn edit(&mut self, f: impl FnOnce(&mut EditBox)) {
let Some((rule, b)) = self.focused() else {
return;
};
@@ -295,12 +282,12 @@ impl RulesScreen {
self.edited(rule);
}
}
pub fn typing(&self) -> bool {
pub(super) fn typing(&self) -> bool {
!self.confirm && (self.search.focused || self.numbers.iter().any(|(_, b)| b.focused))
}
/// A left click; Some when the screen closes.
pub fn click(
pub(super) fn click(
&mut self,
atlas: &HudAtlas,
cursor: (f32, f32),
@@ -368,7 +355,12 @@ impl RulesScreen {
}
}
/// Whether a click lands on a button (for its sound).
pub fn clicks_button(&self, atlas: &HudAtlas, cursor: (f32, f32), size: (f32, f32)) -> bool {
pub(super) fn clicks_button(
&self,
atlas: &HudAtlas,
cursor: (f32, f32),
size: (f32, f32),
) -> bool {
let (w, h) = size;
let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0);
if self.confirm {
@@ -385,9 +377,9 @@ impl RulesScreen {
})
}
pub fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
pub(super) fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
if self.confirm {
let labels = [
Text::translate("gui.yes", &[]),
@@ -401,7 +393,7 @@ impl RulesScreen {
self.scroll.clamp(HEADER, h - FOOTER, content);
// RuleList: the in-world list background, its entries clipped to it,
// then the separators and scrollbar.
ui::list_background(batch, atlas, HEADER, h - FOOTER, self.in_world);
ui::list_background(batch, atlas, HEADER, h - FOOTER, true);
let first = batch.vertices.len();
let hovered = self.hovered(rows.len(), cursor, (w, h));
let (left, x) = (row_left(w), control_x(w));
@@ -458,7 +450,7 @@ impl RulesScreen {
}
}
batch.clip_since(first, 0.0, HEADER, w, h - FOOTER);
ui::separators(batch, atlas, HEADER, h - FOOTER, self.in_world);
ui::separators(batch, atlas, HEADER, h - FOOTER, true);
ui::scrollbar(
batch,
atlas,
@@ -534,8 +526,8 @@ mod tests {
#[test]
fn box_colours_are_vanillas() {
assert_eq!(VALID, crate::hud::rgba(224, 224, 224, 255));
assert_eq!(INVALID, crate::hud::rgba(255, 0, 0, 255));
assert_eq!(VALID, horizon_client::hud::rgba(224, 224, 224, 255));
assert_eq!(INVALID, horizon_client::hud::rgba(255, 0, 0, 255));
}
#[test]
-53
View File
@@ -1,53 +0,0 @@
//! 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
}));
}
}
+1 -59
View File
@@ -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, minecarts::CartKind};
use super::{Game, Mining, Screen};
use horizon_client::{
Result,
attack_strength::HeldStack,
@@ -289,64 +289,6 @@ 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,8 +420,6 @@ 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);
}
+9 -42
View File
@@ -129,7 +129,6 @@ 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> {
@@ -145,14 +144,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.
// 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),
))
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)
}
}
@@ -195,7 +194,6 @@ 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
@@ -298,32 +296,8 @@ 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(
@@ -337,7 +311,6 @@ impl Game<'_> {
out,
light,
lights: Some(diffuse_lights(self.dim == Dim::Nether)),
glint: self.glint(),
};
state.submit(
&self.pack.item_renderer,
@@ -359,7 +332,6 @@ impl Game<'_> {
out,
light,
lights: None,
glint: self.glint(),
};
state.submit(
&self.pack.item_renderer,
@@ -433,12 +405,7 @@ impl Game<'_> {
) {
self.submit_item_world(&state, copy, vertex_light(packed), out);
}
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);
}
self.cull_entity_vertices(out, entity_start);
}
}
}
@@ -79,7 +79,6 @@ impl Game<'_> {
if !self.creative {
self.consume_held();
}
self.stats.custom("play_record", 1);
self.world.block_entities.insert(
(pos[0], pos[1], pos[2]),
BlockEntity::Jukebox(Jukebox {
@@ -91,8 +90,6 @@ 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.
+8 -399
View File
@@ -92,33 +92,6 @@ 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.
@@ -181,136 +154,6 @@ 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.
@@ -319,22 +162,12 @@ 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();
// 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 m.fire_ticks > 0 {
meta.push((0, serializer::BYTE, Value::Byte(1)));
}
if let Some(name) = &m.custom_name {
meta.push((
@@ -388,7 +221,6 @@ 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.
@@ -445,18 +277,6 @@ 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 };
@@ -485,14 +305,6 @@ 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"
@@ -571,29 +383,6 @@ 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" => {
@@ -609,121 +398,6 @@ 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 {
@@ -736,38 +410,6 @@ 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
/// count is `isFullyFrozen`, and Strider.DATA_SUFFOCATING (19, after
/// AgeableMob's two and DATA_BOOST_TIME).
fn mob_freeze_meta(
m: &Mob,
meta: &mut Vec<(u8, i32, Value)>,
defaults: &mut Vec<(u8, i32, Value)>,
) {
if m.ticks_frozen > 0 {
meta.push((7, serializer::INT, Value::Int(m.ticks_frozen)));
}
defaults.push((7, serializer::INT, Value::Int(0)));
if m.kind.id == "minecraft:strider" {
if m.extra.flag {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
}
}
/// The equipment a mob shows (ClientboundSetEquipmentPacket slots: main hand,
@@ -778,16 +420,13 @@ fn mob_equipment(
out: &mut Vec<(u8, Option<horizon_protocol::item::Stack>)>,
) {
let e = &m.extra;
// A horse's armour, a llama's carpet, a harness: Horizon keeps the body
// slot's item (6) in `head`.
let body = horizon_client::mobs::drops::head_is_body(m.species());
let slots = [
(0u8, &e.main_hand),
(1, &e.off_hand),
(2, &e.feet),
(3, &e.legs),
(4, &e.chest),
(if body { 6 } else { 5 }, &e.head),
(5, &e.head),
];
for (slot, item) in slots {
if let Some(item) = item {
@@ -1066,7 +705,6 @@ 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);
}
@@ -1274,7 +912,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), another mob or a fence knot (game/lan_entities.rs).
// host) or another mob. A fence knot isn't an entity here.
let player_entity = mob_players
.get(&m.id)
.and_then(|conn| hub.guest(*conn))
@@ -1283,7 +921,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(p) => self.knot_ids.get(&p).map(|&id| id as i32),
Holder::Knot(_) => None,
});
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];
@@ -1291,15 +929,7 @@ 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,
self.world.game_time,
&mut e.meta,
&mut e.defaults,
);
mob_freeze_meta(m, &mut e.meta, &mut e.defaults);
mob_meta(m, hub, player_entity, &mut e.meta, &mut e.defaults);
mob_equipment(m, hub, &mut e.equipment);
self.mob_attributes(m, &mut e.attributes);
e.leash = leash;
@@ -1310,10 +940,6 @@ 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 {
@@ -1380,9 +1006,6 @@ impl Game<'_> {
for m in &mut self.mobs {
m.needs_sync = false;
}
for p in &mut self.creatures.projectiles {
p.needs_sync = false;
}
// `runBlockEvents`' broadcast precedes the tick's block changes
// (`ChunkHolder.broadcastChanges` in `tick_level` below): the
// guest re-runs a piston's `triggerEvent` while its level still
@@ -1423,8 +1046,6 @@ 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;
@@ -1495,9 +1116,6 @@ 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);
@@ -1548,11 +1166,8 @@ impl Game<'_> {
drop(holding);
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
self.lan = Some(host);
// A blast from a guest's attack, and a mace's wind burst.
self.flush_lan_explosions();
// A mace's wind burst from a guest's attack.
let _ = self.flush_wind_bursts();
// Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes).
self.flush_lan_particles();
}
fn lan_guest_joined(
@@ -1906,7 +1521,6 @@ impl Game<'_> {
.each_ref()
.map(|s| s.as_ref().map(|s| s.item.clone()));
m.extra.crouching = g.sneaking;
m.extra.avatar_pose = g.pose();
m.extra.sprinting = g.sprinting;
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
let visible = || g.effects.list.iter().filter(|e| e.visible);
@@ -1975,12 +1589,7 @@ impl Game<'_> {
out,
);
self.held_item_geometry(m, camera, alpha, packed, true, out);
// 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 {
if !m.glowing {
self.cull_entity_vertices(out, entity_start);
}
}
+12 -74
View File
@@ -74,12 +74,7 @@ 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
@@ -163,7 +158,6 @@ 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,
@@ -273,10 +267,8 @@ impl Game<'_> {
}
let mut play = host.play.remove(&conn).unwrap_or_default();
if fresh {
// A new body (joined, respawned, changed level): nothing on it yet,
// its effects' modifiers and armor protection included.
// A new body (joined, respawned, changed level): nothing on it yet.
play.equipped = None;
play.potions = Default::default();
play.sculk = Default::default();
}
let g = &mut host.hub.guests[i];
@@ -392,7 +384,6 @@ 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),
@@ -414,12 +405,6 @@ impl Game<'_> {
self.apply_equipment();
play.equipped = Some(equipped);
}
// The player's effects are on the new body before anything it does
// (the movement that brought it, a landing): vanilla keeps the same
// entity through a level change, with its attributes and effects.
if fresh {
self.apply_effect_status();
}
self.update_totem_ready();
Some(GuestSwap {
conn,
@@ -464,7 +449,6 @@ impl Game<'_> {
host_ender,
adv_loaded,
last_inventory,
levitation,
death_cause,
acting,
packets,
@@ -524,7 +508,6 @@ impl Game<'_> {
host_ender,
adv_loaded,
last_inventory,
levitation,
death_cause,
combat,
acting,
@@ -647,7 +630,6 @@ 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);
@@ -691,13 +673,6 @@ 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));
@@ -899,17 +874,13 @@ impl Game<'_> {
}
/// The guest's open container is closing (`AbstractContainerMenu.removed`):
/// 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.
/// it no longer counts among the openers of the lid.
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_edge_sound(pos, false);
self.container_openers_changed(pos, false);
}
}
@@ -1003,20 +974,6 @@ 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 => {
@@ -1259,7 +1216,6 @@ impl Game<'_> {
}
S::RenameItem(p) => self.lan_station_rename(host, conn, &p.name),
S::SignUpdate(p) => self.lan_sign_update(host, conn, p),
S::SetCommandBlock(p) => self.lan_set_command_block(host, conn, p),
S::EditBook(p) => self.lan_edit_book(host, conn, p),
S::ContainerSlotStateChanged(p) => {
self.lan_crafter_slot_state(host, conn, p.container, p.slot, p.enabled);
@@ -1584,13 +1540,9 @@ impl Game<'_> {
if let Some(victim) = Self::pvp_victim(host, conn, id) {
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
&& self.pvp_land(host, victim, &strike)
&& strike.stops_sprint()
{
self.as_guest(host, conn, |g| {
g.pvp_exhaustion();
if strike.stops_sprint() {
g.stop_sprint_after_extra_knockback();
}
});
self.as_guest(host, conn, |g| g.stop_sprint_after_extra_knockback());
}
return;
}
@@ -1895,6 +1847,10 @@ impl Game<'_> {
};
xp.respawn(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
(g.xp_level, g.xp_progress, g.xp_total) = (xp.level, xp.progress, xp.total);
// A new body: nothing given to the old one carries over.
if let Some(play) = host.play.get_mut(&conn) {
play.potions = Default::default();
}
let clock_rate = self.network_clock_rate();
let ctx = TickCtx {
world: &mut self.world,
@@ -2253,7 +2209,7 @@ impl Game<'_> {
}
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
let at = g.position;
let (sneaking, sleeping) = (g.sneaking, g.sleeping);
let sneaking = g.sneaking;
let mut soul = None;
let events = self.as_guest(host, conn, |game| {
let (rules, motion) = (game.world.health_rules, game.motion);
@@ -2322,7 +2278,6 @@ impl Game<'_> {
// Entity.applyEffectsFromBlocks along those moves: magma, cactus,
// lit campfires and grown sweet berry bushes hurt (#1305).
w.player.shift_key_down = sneaking;
let _ = w.player.set_sleeping(sleeping);
w.player.freeze_immune = freeze_immune;
let _ = w.server_block_damage(start);
let _ = w.update_soul_speed_block();
@@ -2339,8 +2294,6 @@ impl Game<'_> {
game.cauldron_extinguish(*pos);
}
}
// Entity.baseTick: rain puts out a burning player.
game.rain_extinguish_player();
// WitherRoseBlock.entityInside along those moves.
for event in &events {
if matches!(event, MotionEvent::InsideWitherRose) {
@@ -2379,13 +2332,6 @@ impl Game<'_> {
// ServerEntity.sendChanges sends it as it is after this travel.
let marked = std::mem::take(&mut player.health.sync_velocity);
let velocity = marked.then_some(player.velocity);
// Player.causeFallDamage: a fall of 2 blocks or more counts.
for event in &events {
if let MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) = event
{
game.stats.custom("fall_one_cm", *cm);
}
}
game.record_combat(&events);
game.wear_armor(&events);
(events, velocity)
@@ -2408,15 +2354,6 @@ impl Game<'_> {
speed,
}) => self.landing_dust(Some(host), state, position, count, speed),
MotionEvent::Fall(event) => self.landing_block_updates(&event),
// LivingEntity.baseTick's entity event 67, to every client
// around, the guest's own included; this window draws it too.
MotionEvent::Drown => {
if let Some(g) = host.hub.guest(conn) {
host.hub
.entity_event(g.entity_id, super::combat::DROWN_EVENT, at);
}
self.drown_particles(at, [0.0; 3]);
}
// HoneyBlock.maybeDoSlideEffects: the sound reaches the
// others (Player.playSound excludes the player, whose client
// plays it), the entity event 53 everyone around.
@@ -2752,7 +2689,8 @@ impl Game<'_> {
/// with Mending and gives the rest as points. Everyone near hears it.
fn guest_orbs(&mut self, host: &mut LanHost, gi: usize) {
let g = &host.hub.guests[gi];
let height = f64::from(g.dimensions().height);
// Player's standing and crouching boxes: 1.8 and 1.5 high.
let height = if g.sneaking { 1.5 } else { 1.8 };
let (conn, id, at) = (g.conn, g.entity_id, g.position);
let unable = g.spectator() || g.dead || g.health <= 0.0;
let play = host.play.entry(conn).or_default();
+4 -15
View File
@@ -127,18 +127,9 @@ 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| {
@@ -585,18 +576,16 @@ 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) {
// 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 play = host.play.remove(&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.take() {
if let Some((_, _, menu, _)) = play.crafting.table {
grid.extend((1..10).filter_map(|s| menu.slot(s).cloned()));
}
// A workstation's inputs too.
if let Some(station) = play.station.take() {
grid.extend(super::lan_stations::station_inputs(&station));
if let Some(station) = &play.station {
grid.extend(super::lan_stations::station_inputs(station));
}
}
if grid.is_empty() {
+40 -65
View File
@@ -11,6 +11,18 @@ 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,
@@ -49,21 +61,28 @@ fn vehicle_meta(hurt_time: i32, hurt_dir: i32, damage: f32) -> Vec<(u8, i32, Val
meta
}
/// 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)> {
/// 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();
};
let mut meta = Vec::new();
for (i, (rotation, default)) in a
.pose
.iter()
.zip(horizon_client::entities::ArmorStand::DEFAULT_POSE)
.enumerate()
{
if *rotation != default {
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 {
meta.push((
16 + i as u8,
serializer::ROTATIONS,
Value::Floats(rotation.to_vec()),
Value::Floats(r.to_vec()),
));
}
}
@@ -166,19 +185,7 @@ impl Game<'_> {
if flags != 0 {
e.meta.push((15, serializer::BYTE, Value::Byte(flags)));
}
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()),
));
}
e.meta.extend(stand_poses(self.persist.raw.get(&a.id)));
// 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() {
@@ -218,16 +225,6 @@ 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
@@ -273,24 +270,6 @@ 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 {
@@ -363,7 +342,6 @@ impl Game<'_> {
let mut e = state(p.id, kind, at(p.position), p.yaw, false);
e.pitch = p.pitch;
e.velocity = at(p.velocity);
e.needs_sync = p.needs_sync;
// Projectile.getAddEntityPacket: the owner's id.
e.data = match p.guest.and_then(|conn| hub.guest(conn)) {
Some(g) => g.entity_id,
@@ -488,7 +466,7 @@ impl Game<'_> {
}
out.push(e);
}
// The dragon's breath and lingering potion clouds (AreaEffectCloud's DATA_RADIUS,
// The dragon's breath clouds (AreaEffectCloud's DATA_RADIUS,
// DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the
// client draws them from these (`tick_remote_clouds`).
let breath = self.network_particle(
@@ -506,14 +484,10 @@ impl Game<'_> {
for b in self.breath_clouds.iter().filter(|b| b.id != 0) {
let mut e = state(b.id, "minecraft:area_effect_cloud", b.at, 0.0, false);
e.meta.push((8, serializer::FLOAT, Value::Float(b.radius)));
if b.waiting() {
if b.age < 20 {
e.meta.push((9, serializer::BOOLEAN, Value::Bool(true)));
}
let particle = match &b.potion {
Some(p) => self.network_particle(&p.particle(), None),
None => breath.clone(),
};
if let Some(p) = &particle {
if let Some(p) = &breath {
e.meta
.push((10, serializer::PARTICLE, Value::Particle(p.clone())));
}
@@ -553,9 +527,11 @@ mod tests {
#[test]
fn armor_stand_poses_send_only_what_differs() {
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);
let tag =
horizon_world::nbt::parse_snbt("{Pose:{Head:[10f,20f,0f],LeftArm:[-10f,0f,-10f]}}")
.unwrap()
.0;
let meta = stand_poses(Some(&tag));
assert_eq!(
meta,
vec![(
@@ -564,7 +540,6 @@ mod tests {
Value::Floats(vec![10.0, 20.0, 0.0])
)]
);
let plain = horizon_client::entities::ArmorStand::new(1, Default::default(), 0.0);
assert!(stand_poses(&plain).is_empty());
assert!(stand_poses(None).is_empty());
}
}
+12 -31
View File
@@ -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, Species, Who},
mobs::{Mob, MobEvent, PlayerView, Who},
projectiles::{Hit, Projectile},
};
use horizon_core::geometry::{Aabb, Vec3};
@@ -28,22 +28,6 @@ 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)
}
@@ -86,10 +70,6 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
target: Who::Player,
..
}
| MobEvent::Knockback {
target: Who::Player,
..
}
| MobEvent::Ignite {
target: Who::Player,
..
@@ -195,9 +175,9 @@ impl Game<'_> {
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
.map(|g| {
let b = g.bounds();
let (lo, hi) = (b.min_position(), b.max_position());
([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
let [x, y, z] = g.position;
let height = if g.sneaking { 1.5 } else { 1.8 };
([x - 0.3, y, z - 0.3], [x + 0.3, y + height, z + 0.3])
})
.collect()
}
@@ -339,7 +319,7 @@ impl Game<'_> {
uuid: horizon_client::lan::playerdata::uuid_ints(g.uuid),
position: g.position,
eye: [x, y + g.eye_height(), z],
height: g.dimensions().height,
height: if g.sneaking { 1.5 } else { 1.8 },
creative: g.creative(),
spectator: g.spectator(),
alive: !g.dead,
@@ -356,9 +336,14 @@ impl Game<'_> {
}
}
/// A guest's bounding box, by its pose (`Avatar.POSES`).
/// A guest's bounding box (0.6 wide, 1.8 tall, 1.5 crouching).
pub(super) fn guest_bounds(g: &horizon_client::lan::guest::Guest) -> Aabb {
g.bounds()
let [x, y, z] = g.position;
let height = if g.sneaking { 1.5 } else { 1.8 };
Aabb::new(
Vec3::new(x - 0.3, y, z - 0.3),
Vec3::new(x + 0.3, y + height, z + 0.3),
)
}
#[cfg(test)]
@@ -378,21 +363,17 @@ 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],
allies: Default::default(),
remote: false,
passenger: false,
}
}
@@ -247,10 +247,14 @@ impl Game<'_> {
return;
};
let (d, s) = (p.dist.map(f64::from), p.max_speed.map(f64::from));
// ClientLevel.addParticle: the camera's 32 blocks and the Particles
// option, unless the packet or the type overrides the limiter.
// ClientLevel.addParticle: the camera's 32 blocks unless the packet
// (`force`) or the type overrides the limiter.
let add = |particles: &mut horizon_client::particles::ParticleEngine, at, v| {
particles.add_with(&options, at, v, p.override_limiter, p.always_show);
if p.override_limiter {
particles.create(&options, at, v);
} else {
particles.add(&options, at, v);
}
};
if p.count == 0 {
add(
@@ -314,86 +318,16 @@ impl Game<'_> {
}
}
/// `ServerLevel.sendParticles` for the players a server serves: one
/// `ClientboundLevelParticlesPacket` to each player in the level whose
/// block is within 32 blocks of `at`. The window's own player is not
/// sent it (the caller draws it in this window). While the host holds
/// the players (a guest's turn through `as_guest`, a host's hit on a
/// guest) the packet waits for the turn to end so that it reaches every
/// player, the guest too.
pub(super) fn lan_send_particles(
&mut self,
options: &ParticleOptions,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
) {
if self.lan.is_none() {
// The server is running (its sounds are captured for the guests).
if self.audio.capture.is_some() {
self.creatures
.lan_particles
.push((options.clone(), at, spread, speed, count));
}
return;
}
let Some(particle) = self.network_particle(options, None) else {
return;
};
let Some(host) = self.lan.as_ref() else {
return;
};
let body = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
always_show: false,
pos: at,
dist: spread.map(|d| d as f32),
max_speed: [speed as f32; 3],
count: count as i32,
randomization: 0,
})
.encode();
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
// BlockPos.closerToCenterThan(at, 32): from the centre of the
// player's block.
let d: f64 = (0..3)
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
.sum();
if d < 32.0 * 32.0 {
g.sender.send(body.clone());
}
}
}
/// The particles guests' turns put off.
pub(super) fn flush_lan_particles(&mut self) {
for (options, at, spread, speed, count) in std::mem::take(&mut self.creatures.lan_particles)
{
self.lan_send_particles(&options, at, spread, speed, count);
}
}
/// `ServerLevel.explode`'s packet to each player within 64 blocks. The
/// sound and the push reach them on their own (the level's sounds, the
/// motion a hurt player is sent), so the packet carries neither.
pub(super) fn lan_explode(
&mut self,
&self,
center: [f64; 3],
small: bool,
radius: f32,
block_count: usize,
) {
// In a guest's turn (their hit on an end crystal or a minecart) the
// host holds the players (`as_guest`): the packet waits for the turn
// to end so that it reaches every player, the guest too.
if self.lan_acting.is_some() && self.lan.is_none() {
self.creatures
.lan_explosions
.push((center, small, radius, block_count));
return;
}
let emitter = if small {
"minecraft:explosion"
} else {
@@ -407,15 +341,6 @@ impl Game<'_> {
}
}
/// The explosions guests' turns put off.
pub(super) fn flush_lan_explosions(&mut self) {
for (center, small, radius, block_count) in
std::mem::take(&mut self.creatures.lan_explosions)
{
self.lan_explode(center, small, radius, block_count);
}
}
/// A wind charge's burst (`AbstractWindCharge.explode`): gust emitters,
/// no block particles, and for each player it pushed the push
/// (`ServerExplosion.hitPlayers`), which only that client applies, as
+8 -39
View File
@@ -86,11 +86,9 @@ impl Game<'_> {
};
if let Some(strike) = self.pvp_strike()
&& self.pvp_land(&mut host, Victim::Guest(conn), &strike)
&& strike.stops_sprint()
{
self.pvp_exhaustion();
if strike.stops_sprint() {
self.stop_sprint_after_extra_knockback();
}
self.stop_sprint_after_extra_knockback();
}
self.lan = Some(host);
true
@@ -108,18 +106,8 @@ impl Game<'_> {
.map(|g| Victim::Guest(g.conn))
}
/// `Player.attack`'s `causeFoodExhaustion(0.1F)`, charged to the attacker
/// (the host, or a guest swapped in) only once the hit hurt the victim.
pub(super) fn pvp_exhaustion(&mut self) {
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
}
/// The attacker's side of `Player.attack` (the host, or a guest swapped
/// in): the damage, the charge reset and the swing.
/// in): the damage, the charge reset, the exhaustion and the swing.
pub(super) fn pvp_strike(&mut self) -> Option<Strike> {
let p = self.player.as_ref()?;
let from = [p.position.x, p.position.y, p.position.z];
@@ -128,6 +116,11 @@ impl Game<'_> {
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
let (xd, zd) = self.extra_knockback_direction();
self.attack_ticks = 0;
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
self.swing_arm();
Some(Strike {
attacker: self.lan_acting,
@@ -267,30 +260,6 @@ 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)),
+6 -2
View File
@@ -5,10 +5,14 @@
//! forcing the game mode. Its difficulty lock asks first (PopupScreen), and
//! Restrictions opens RestrictionsScreen.
//! Parity reference: https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/WorldOptionsScreen
use super::{Game, Screen, integrated::ServerOptions, lan::LanSettings};
use super::{
Game, Screen,
game_rules_screen::{Exit, RulesScreen},
integrated::ServerOptions,
lan::LanSettings,
};
use horizon_client::{
font::Font,
game_rules_screen::{Exit, RulesScreen},
hud::{HudBatch, rgba},
text::{Text, named_color},
ui::{self, EditBox},
@@ -277,12 +277,7 @@ impl Game<'_> {
g.carried = menu.carried.take();
}
self.register_exploration_maps(made);
let (session, traded) = self.merchant_apply(session, gm.merchant.mob);
gm.merchant.session = session;
if !traded.is_empty() {
// The guest's own statistic and advancements.
self.as_guest(host, conn, |g| g.merchant_traded(gm.merchant.mob, &traded));
}
gm.merchant.session = self.merchant_apply(session, gm.merchant.mob);
self.drop_guest_items(host, gi, dropped);
self.lan_send_offers(host, conn, &mut gm, false);
self.lan_send_merchant_content(host, conn, &mut gm);
-2
View File
@@ -386,8 +386,6 @@ impl Game<'_> {
layer: 0,
color,
light: packed,
glint_uv: [0.0, 0.0],
});
}
}
+21 -200
View File
@@ -1,34 +1,28 @@
//! 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), 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.
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
//! off (3004) and scraping (3005).
use super::Game;
use horizon_client::items::ItemStack;
use horizon_client::particles::{self, ParticleOptions, ParticleType};
use horizon_core::{geometry::trig, position::BlockPos, random::RandomSource};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_world::level::LevelEvent;
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.particles
.block_teleportation(pos, data, [16, 8, 16], self.settings.particles);
}
/// 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.particles
.block_teleportation(pos, data, [8, 8, 8], self.settings.particles);
}
/// 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]);
}
/// 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);
.block_teleportation(pos, data, [127, 127, 127], self.settings.particles);
}
/// `LevelEventHandler.levelEvent` 3002 to 3005, drawing from the client
/// level's random.
@@ -78,42 +72,24 @@ impl Game<'_> {
} else {
// ParticleUtils.spawnParticleInBlock: up to the
// block's shape (a full block when air).
(at, count, 0.5, self.block_shape_top(at), true)
let inside = self.world.block(at[0], at[1], at[2]);
let height = if facts.is_air(inside) {
1.0
} else {
facts
.get(inside)
.outline
.iter()
.map(|b| b[4])
.fold(0.0, f64::max)
};
(at, count, 0.5, height, true)
}
}
None if &*facts.get(state).name == "minecraft:water" => (p, count * 3, 3.0, 1.0, false),
None => return,
};
self.spawn_particles(at, count, xz, height, floating, ParticleType::HappyVillager);
}
/// `BlockState.getShape(..).max(Y)` of the block at `at`, 1.0 for air.
fn block_shape_top(&self, at: [i32; 3]) -> f64 {
let facts = self.res.facts();
let inside = self.world.block(at[0], at[1], at[2]);
if facts.is_air(inside) {
1.0
} else {
facts
.get(inside)
.outline
.iter()
.map(|b| b[4])
.fold(0.0, f64::max)
}
}
/// `ParticleUtils.spawnParticles`: `count` slow particles spread over
/// `xz` blocks around `at` and `height` up, only above solid ground
/// unless floating ones are allowed.
fn spawn_particles(
&mut self,
at: [i32; 3],
count: i32,
xz: f64,
height: f64,
floating: bool,
ty: ParticleType,
) {
let options = ParticleOptions::simple(ty);
let options = ParticleOptions::simple(ParticleType::HappyVillager);
for _ in 0..count.max(0) {
let r = self.particles.level_random();
let v = [0.0; 3].map(|_: f64| r.next_gaussian() * 0.02);
@@ -132,161 +108,6 @@ impl Game<'_> {
}
}
}
/// `LevelEventHandler` 2012 (a turtle egg laid on sand):
/// `ParticleUtils.spawnParticleInBlock` with happy villager sparks.
pub(super) fn turtle_egg_particles(&mut self, pos: BlockPos, count: i32) {
let at = [pos.x, pos.y, pos.z];
let height = self.block_shape_top(at);
self.spawn_particles(at, count, 0.5, height, true, ParticleType::HappyVillager);
}
/// `LevelEventHandler` 3009 (a sniffer egg cracking on moss):
/// `spawnParticlesOnBlockFaces(.., EGG_CRACK, UniformInt.of(3, 6))`.
pub(super) fn sniffer_egg_crack_particles(&mut self, pos: BlockPos) {
let mut spawned = Vec::new();
particles::on_block_faces_between(
self.particles.level_random(),
[pos.x, pos.y, pos.z],
3,
6,
|p, v| spawned.push((p, v)),
);
let options = ParticleOptions::simple(ParticleType::EggCrack);
for (p, v) in spawned {
self.particles.add(&options, p, v);
}
}
/// `LevelEventHandler` 1501 (lava put out) and 2009 (a wet sponge dried):
/// eight puffs just above the block, drawn after the sound.
pub(super) fn puffs_above_block(&mut self, pos: BlockPos, ty: ParticleType) {
let options = ParticleOptions::simple(ty);
for _ in 0..8 {
let r = self.particles.level_random();
let x = f64::from(pos.x) + r.next_double();
let z = f64::from(pos.z) + r.next_double();
self.particles
.add(&options, [x, f64::from(pos.y) + 1.2, z], [0.0; 3]);
}
}
/// `LevelEventHandler` 1502 (a redstone torch burning out): five smoke
/// puffs inside the block, drawn after the sound.
pub(super) fn torch_burnout_smoke(&mut self, pos: BlockPos) {
let options = ParticleOptions::simple(ParticleType::Smoke);
for _ in 0..5 {
let r = self.particles.level_random();
let x = f64::from(pos.x) + r.next_double() * 0.6 + 0.2;
let y = f64::from(pos.y) + r.next_double() * 0.6 + 0.2;
let z = f64::from(pos.z) + r.next_double() * 0.6 + 0.2;
self.particles.add(&options, [x, y, z], [0.0; 3]);
}
}
/// `LevelEventHandler` 2006 (`DragonFireball.onHit`): 200 dragon breath
/// particles thrown outwards, then the explosion sound when `data` is 1.
pub(super) fn dragon_fireball_burst(&mut self, pos: BlockPos, data: i32) {
for _ in 0..200 {
let r = self.particles.level_random();
let dist = r.next_float() * 4.0;
let angle = r.next_float() * std::f32::consts::TAU;
let vx = f64::from(trig::cos(f64::from(angle)) * dist);
let vy = 0.01 + r.next_double() * 0.5;
let vz = f64::from(trig::sin(f64::from(angle)) * dist);
let options = ParticleOptions {
scale: dist,
..ParticleOptions::simple(ParticleType::DragonBreath)
};
self.particles.add(
&options,
[
f64::from(pos.x) + vx * 0.1,
f64::from(pos.y) + 0.3,
f64::from(pos.z) + vz * 0.1,
],
[vx, vy, vz],
);
}
if data == 1 {
let at = [pos.x, pos.y, pos.z].map(|v| f64::from(v) + 0.5);
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio.play(
"minecraft:entity.dragon_fireball.explode",
Some(at),
1.0,
pitch,
);
}
}
/// `SpeleothemBlock.isStalactite`: a speleothem whose tip points down.
fn is_stalactite(&self, state: BlockStateId) -> bool {
self.res.facts().get(state).has_class("SpeleothemBlock")
&& self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("vertical_direction"))
.is_some_and(|d| d == "down")
}
/// `LevelEventHandler` 1504: `PointedDripstoneBlock.spawnDripParticle` at
/// the tip `pos`. The fluid above the stalactite's root picks the drip;
/// with none, the dimension's default (lava in the Nether).
pub(super) fn dripstone_drip_particle(&mut self, pos: BlockPos) {
let tip_state = self.world.block(pos.x, pos.y, pos.z);
if !self.is_stalactite(tip_state) {
return;
}
// findRootBlock: up through the same stalactite, at most 11 steps.
let tip_name = self.res.facts().get(tip_state).name.clone();
let mut root = None;
for step in 1..11 {
let y = pos.y + step;
if !(horizon_client::chunk::MIN_Y
..horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT)
.contains(&y)
{
break;
}
let state = self.world.block(pos.x, y, pos.z);
if self.res.facts().get(state).name != tip_name {
root = Some(y);
break;
}
if !self.is_stalactite(state) {
break;
}
}
let Some(root_y) = root else {
return;
};
let above = self.world.block(pos.x, root_y + 1, pos.z);
let above_facts = self.res.facts().get(above);
let nether = self.dim == super::Dim::Nether;
let fluid = if &*above_facts.name == "minecraft:mud" && !nether {
horizon_client::blocks::Fluid::Water
} else {
above_facts.fluid
};
let ty = match fluid {
horizon_client::blocks::Fluid::Water => ParticleType::DrippingDripstoneWater,
horizon_client::blocks::Fluid::Lava => ParticleType::DrippingDripstoneLava,
horizon_client::blocks::Fluid::None if nether => ParticleType::DrippingDripstoneLava,
horizon_client::blocks::Fluid::None => ParticleType::DrippingDripstoneWater,
};
let offset = self
.pack
.context
.tables
.offset(tip_state, pos.x, pos.z)
.unwrap_or([0.0; 3]);
// The tip shape starts 5/16 up; the particle sits a pixel below that.
self.particles.add(
&ParticleOptions::simple(ty),
[
f64::from(pos.x) + 0.5 + offset[0],
f64::from(pos.y) + 5.0 / 16.0 - 0.0625,
f64::from(pos.z) + 0.5 + offset[2],
],
[0.0; 3],
);
}
/// `ServerLevel.globalLevelEvent`: heard by every player on the server
/// (an end portal opening, the dragon's death roar): here, and sent to
/// every guest whatever their dimension.
-8
View File
@@ -66,8 +66,6 @@ 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>,
@@ -122,10 +120,6 @@ impl Levels {
.iter_mut()
.map(|(d, p)| (*d, &mut p.world.chunks))
}
/// The levels that are not in place, by dimension.
pub(super) fn parked_dims(&self) -> Vec<Dim> {
self.parked.keys().copied().collect()
}
}
impl Game<'_> {
@@ -172,7 +166,6 @@ impl Game<'_> {
tnts: Vec::new(),
hoppers: HashMap::new(),
mobs: Vec::new(),
knot_ids: HashMap::new(),
projectiles: Vec::new(),
dropped: Vec::new(),
orbs: Vec::new(),
@@ -226,7 +219,6 @@ 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);
+1 -29
View File
@@ -16,7 +16,7 @@ pub(super) struct Bolt {
flashes: i32,
seed: i64,
visual_only: bool,
/// Mooshrooms and copper golems change once per bolt.
/// Mooshrooms switch colour once per bolt.
converted: Vec<u32>,
}
impl Game<'_> {
@@ -278,13 +278,6 @@ 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 {
@@ -341,16 +334,6 @@ 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;
@@ -410,17 +393,6 @@ 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.
-6
View File
@@ -25,13 +25,11 @@ 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);
@@ -59,10 +57,6 @@ 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
+29 -95
View File
@@ -37,41 +37,6 @@ 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]
@@ -255,75 +220,44 @@ 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))
{
smash_push(
center,
heavy,
m.position,
&mut m.velocity,
m.attrs.knockback_resistance,
);
push(m.position, &mut m.velocity, m.attrs.knockback_resistance);
}
for s in self.armor_stands.iter_mut().filter(|s| !s.marker) {
smash_push(center, heavy, s.position, &mut s.velocity, 0.0);
push(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).
Loaded 100 of 297 files, more files were not shown because too many files have changed in this diff. Show more