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
425 changed files with 6212 additions and 35889 deletions

No files matched your search

-7
View File
@@ -1,7 +0,0 @@
# Trusted reviewers: they pick the pull requests the Nightly holds with `/nightly 2640 2642` in any issue or pull
# request (docs/release-flow.md "Nightly channel", tools/ci/nightly_set.py). Ludovic is always allowed and isn't
# listed. One GitHub login per line, with who added it and when. Removing a line revokes that login at once.
# A line with only `paused` stops every reviewer's `/nightly` (Ludovic's still work).
kacpherek # added by Ludovic, 2026-10-27
rdbtCVS # added by Ludovic, 2026-10-27
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
-291
View File
@@ -1,291 +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: ''
trusted:
# Only release.yml says true: it builds main. A nightly can hold pull requests nobody reviewed yet, whose
# build scripts and tests run here and could rewrite target/, the cargo home or a compiler cache. So an
# untrusted build never runs on the release runner, never saves the Rust cache (its key is its own anyway),
# and a trusted one never reads the machine's shared sccache, which pull requests' jobs write
# (tests/test_nightly.py pins all of it).
description: The source is main (a release); false for anything else (a nightly)
type: boolean
default: false
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. An untrusted build
# goes to the Mac mini runner that already runs pull requests' parity jobs (macOS + horizon-game:
# ludovics-mac-mini), never the release runner (MAC_RUNNER: ludovics-mac-mini-release) nor parity-main's.
runs-on: ${{ inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-game"]') }}
# 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
env:
HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}
- 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 && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-game"]')) || 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:
# A release restores only what a release saved: an untrusted build has its own key and saves nothing.
shared-key: ${{ inputs.trusted && 'release' || 'nightly' }}-${{ matrix.name }}
save-if: ${{ inputs.trusted }}
- 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'
env:
HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}
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
-237
View File
@@ -1,237 +0,0 @@
name: nightly build
run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.request) || '' }}
# The opt-in Nightly channel (docs/release-flow.md "Nightly"; Ludovic, 2026-10-27). Once a night, and when a trusted
# reviewer asks (`/nightly 2640 2642`, nightly-command.yml), it builds main plus the pull requests the reviewers
# picked, merged in order (tools/ci/nightly_set.py). With none picked, it builds the newest union branch on origin
# (union/<agent>-<name>: main plus pull requests the contribution gate proved together), or main when no union is
# newer. It publishes the result as the `nightly` pre-release. Launchers install it only for players who turn Nightly
# on. The previous nightly stays a week as nightly-<date>.
#
# Why the signing key never meets the pull requests' code:
# - `pick` merges the picked heads onto main with `git merge-tree --write-tree`: git objects only, no working tree,
# so none of their files reaches the disk and no hook, filter or script of theirs runs. It refuses any pull
# request that changes a path CODEOWNERS gives to Ludovic (this folder, tools/ci/, the signing and update code),
# pushes the merge as nightly/build-<date>-<n> and saves the set on the nightly-state branch.
# - This file, game-build.yml and every script below run from main's copy: a schedule runs the default branch, and
# `uses: ./…` takes the caller's commit. The union's own .github/ is never read.
# - `build` (game-build.yml, `trusted: false`) never reaches a release's tools: its macOS jobs run on the runner
# that already runs pull requests' parity jobs, never the release runner; its Rust cache has its own key and is
# never saved; and releases don't read the machine's shared sccache.
# - `build` (game-build.yml) checks out and compiles the merge (or the union) with no secrets: its token reads contents only and
# 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
issue:
description: The issue or pull request whose `/nightly` comment asked for this build (answered there)
type: string
default: ''
request:
description: The command's id, in the run's name so it can link the run
type: string
default: ''
prs:
description: A dry run's own set, `2640@<head> 2642@<head>` (the saved set stays)
type: string
default: ''
permissions:
contents: read
concurrency:
# One build at a time. A newer request replaces one still waiting: it builds the set as it is by then, so no pick
# is lost.
group: nightly-build
cancel-in-progress: false
jobs:
pick:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write # nightly/build-<date>-<n> and the nightly-state branch (main is protected)
issues: write # says in the thread when the picked pull requests conflict
pull-requests: write
outputs:
build: ${{ steps.pick.outputs.build }}
sha: ${{ steps.pick.outputs.sha }}
short: ${{ steps.pick.outputs.short }}
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' || '' }}
DRY_RUN: ${{ steps.mode.outputs.dry_run }}
NIGHTLY_ISSUE: ${{ inputs.issue }}
NIGHTLY_PRS: ${{ inputs.prs }}
run: |
python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY"
{ echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY"
- name: Old nightly/build-* branches deleted (scheduled runs only)
# Ludovic, 2026-10-27: older than 7 days, but never the newest build or one a published nightly points to;
# nothing but nightly/build-* (nightly.py stale_builds, tests/test_nightly.py).
if: github.event_name == 'schedule'
env:
GH_TOKEN: ${{ github.token }}
run: |
{ echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$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
trusted: false # it can hold unreviewed pull requests: their own cache key, never saved, not the release runner
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
answer:
# Tells the thread that asked for this build how it ended. A conflict `pick` already explained there.
needs: [pick, build, sign, publish]
if: always() && inputs.issue != '' && needs.pick.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
issues: write
pull-requests: write
steps:
- env:
GH_TOKEN: ${{ github.token }}
ISSUE: ${{ inputs.issue }}
BUILD: ${{ needs.pick.outputs.build }}
PUBLISHED: ${{ needs.publish.result }}
DRY_RUN: ${{ needs.pick.outputs.dry_run }}
NAME: Nightly ${{ needs.pick.outputs.date }} · ${{ needs.pick.outputs.short }}
RUN: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if [[ "$BUILD" != true ]]; then
text="nothing new: the published Nightly is already this build."
elif [[ "$PUBLISHED" == success && "$DRY_RUN" == true ]]; then
text="the dry run built and signed $NAME; nothing was published. $RUN"
elif [[ "$PUBLISHED" == success ]]; then
text="published $NAME: https://github.com/$GITHUB_REPOSITORY/releases/tag/nightly. Launchers with Nightly on install it at their next start."
else
text="the build failed, nothing was published: $RUN"
fi
gh api "repos/$GITHUB_REPOSITORY/issues/$ISSUE/comments" -f body="🤖 Nightly: $text"
-46
View File
@@ -1,46 +0,0 @@
name: nightly command
# `/nightly …` comments: the trusted reviewers in .github/nightly-reviewers.txt (and Ludovic) pick the pull requests
# the Nightly holds (docs/release-flow.md "Nightly channel", tools/ci/nightly_set.py). This job only checks who asked,
# saves the set on the nightly-state branch, starts `nightly build` and answers in the thread.
#
# Why no pull request's code runs here, though the token can write:
# - `issue_comment` always runs this file from main, whoever comments and wherever; a pull request's own copy of it
# is never read.
# - It checks out main and runs main's nightly_set.py. The comment's text and author reach it as env vars, never in
# a shell line.
# - It never checks out, fetches or merges a pull request: it reads their file lists through the API. The merge
# happens in nightly-build.yml's `pick`, with git objects only; their code runs in its `build`, without secrets.
on:
issue_comment:
types: [created]
permissions:
contents: read
jobs:
command:
# Every other comment skips here, before a runner starts.
if: startsWith(github.event.comment.body, '/nightly') && github.event.comment.user.type != 'Bot'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write # the nightly-state branch (main is protected)
actions: write # starts `nightly build`
issues: write # answers in the thread
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
sparse-checkout: |
.github/CODEOWNERS
.github/nightly-reviewers.txt
tools/ci/nightly_set.py
sparse-checkout-cone-mode: false
- name: Run the command
env:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
ISSUE: ${{ github.event.issue.number }}
COMMENT_URL: ${{ github.event.comment.html_url }}
run: python3 tools/ci/nightly_set.py command
+245 -11
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,15 +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 }}
trusted: true # main: the release runner, the release cache, no shared sccache
# 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]
+2 -3
View File
@@ -35,8 +35,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~170 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-client` | The game: a lib (`src/lib.rs`, ~180 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
@@ -121,7 +120,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
Generated
+110 -16
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"
@@ -1385,7 +1426,6 @@ dependencies = [
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mods",
"horizon-protocol",
"horizon-server-files",
@@ -1406,7 +1446,6 @@ dependencies = [
"sha1",
"sha2",
"symphonia",
"unicode-bidi",
"ureq",
"wgpu",
"winit",
@@ -1471,14 +1510,13 @@ dependencies = [
]
[[package]]
name = "horizon-items"
name = "horizon-mod-lua"
version = "0.1.0"
dependencies = [
"horizon-client-core",
"horizon-core",
"horizon-world",
"serde",
"serde_json",
"getrandom 0.2.17",
"getrandom 0.3.4",
"horizon-mod-sdk",
"piccolo",
]
[[package]]
@@ -2757,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"
@@ -2884,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"
@@ -2984,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"
@@ -3463,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"
@@ -3580,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"
@@ -3787,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"
@@ -5247,7 +5341,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
@@ -5288,7 +5382,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
+2 -1
View File
@@ -9,7 +9,6 @@ members = [
"crates/horizon-client",
"crates/horizon-client-core",
"crates/horizon-command",
"crates/horizon-items",
"crates/horizon-server-files",
"crates/horizon-server",
"crates/horizon-bots",
@@ -31,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",
@@ -41,6 +41,7 @@ members = [
"mods/official/zoom",
"xtask",
"mods/official/recipes",
"crates/horizon-mod-lua",
]
resolver = "2"
+3 -3
View File
@@ -31,7 +31,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)); Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
<table>
<tr>
@@ -47,7 +47,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
@@ -158,7 +158,7 @@ them too (Rust uses Java's own float maths on each platform).
### Known issues
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
Horizon can't sign in to a Microsoft account yet, so it can't join online-mode servers (it joins offline-mode ones); skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
## Licence
+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));
}
+6 -44
View File
@@ -1,7 +1,6 @@
//! Text components from commands: `ComponentArgument` reads an SNBT (or JSON)
//! value, `ComponentSerialization` turns it into styled text.
use crate::text::{Style, Text, named_color};
use horizon_world::nbt::Tag;
use serde_json::{Map, Value};
/// `SnbtGrammar`, enough for components: compounds, lists, quoted and
@@ -174,48 +173,20 @@ 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("run_command") => text("command").map(crate::text::ClickEvent::RunCommand),
Some("copy_to_clipboard") => {
text("value").map(crate::text::ClickEvent::CopyToClipboard)
}
_ => None,
};
}
// HoverEvent.ShowText and ShowItem.
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
match e.get("action").and_then(Value::as_str) {
Some("show_text") => {
if let Some(value) = e.get("value") {
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
}
}
Some("show_item") if e.get("id").is_some_and(Value::is_string) => {
style.hover_item = Some(Box::new(Value::Object(e.clone())));
}
_ => {}
}
// HoverEvent.ShowText.
if let Some(e) = o.get("hover_event").and_then(Value::as_object)
&& e.get("action").and_then(Value::as_str) == Some("show_text")
&& let Some(value) = e.get("value")
{
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
}
style.insertion = o
.get("insertion")
.and_then(Value::as_str)
.map(str::to_owned);
style
}
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
@@ -300,15 +271,6 @@ pub fn to_text_with(
}
}
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
/// else is a component (`ComponentSerialization`).
pub fn name_text(tag: &Tag) -> Text {
match tag {
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
}
}
#[cfg(test)]
mod tests {
use super::*;
+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 -7
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;
@@ -14,15 +13,11 @@ pub mod identifier;
pub mod key_display;
pub mod lang;
pub mod nbt_path;
pub mod nbt_value;
pub mod particle_type;
pub mod pattern;
pub mod potions;
pub mod registries;
pub mod scoreboard;
pub mod snbt;
pub mod sound_source;
pub mod text;
pub mod uuid;
pub mod who;
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
@@ -1,24 +0,0 @@
//! Reading and writing single NBT values the way vanilla's codecs accept them.
use horizon_world::nbt::Tag;
pub fn string(s: &str) -> Tag {
Tag::String(s.into())
}
pub fn text(tag: Option<&Tag>) -> Option<String> {
match tag {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
}
}
/// Any numeric tag as i64 (vanilla's `getIntOr` etc. accept every number type).
pub fn number(tag: Option<&Tag>) -> Option<i64> {
Some(match tag? {
Tag::Byte(v) => i64::from(*v),
Tag::Short(v) => i64::from(*v),
Tag::Int(v) => i64::from(*v),
Tag::Long(v) => *v,
Tag::Float(b) => f32::from_bits(*b) as i64,
Tag::Double(b) => f64::from_bits(*b) as i64,
_ => return None,
})
}
-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,
}
}
+1 -30
View File
@@ -42,13 +42,7 @@ 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),
/// `ClickEvent.RunCommand`: the command, with or without its `/`.
RunCommand(String),
CopyToClipboard(String),
/// A local file to open with the system handler.
/// Only allow this in locally constructed messages, never server/LAN text.
@@ -67,10 +61,6 @@ pub struct Style {
pub click: Option<ClickEvent>,
/// Hover text.
pub hover: Option<Box<Text>>,
/// `HoverEvent.ShowItem`: the event's `{id, count?, components?}` object.
pub hover_item: Option<Box<Value>>,
/// `Style.insertion`: the text a Shift-click puts in the chat box.
pub insertion: Option<String>,
/// The font (`FontDescription.Resource`), a full id like
/// `minecraft:alt`; the default font when unset.
pub font: Option<String>,
@@ -100,12 +90,6 @@ impl Style {
if self.hover.is_none() {
self.hover.clone_from(&base.hover);
}
if self.hover_item.is_none() {
self.hover_item.clone_from(&base.hover_item);
}
if self.insertion.is_none() {
self.insertion.clone_from(&base.insertion);
}
if self.font.is_none() {
self.font.clone_from(&base.font);
}
@@ -511,7 +495,7 @@ impl Text {
}
/// The style fields of a component's JSON.
const STYLE_KEYS: [&str; 10] = [
const STYLE_KEYS: [&str; 9] = [
"color",
"bold",
"italic",
@@ -520,7 +504,6 @@ const STYLE_KEYS: [&str; 10] = [
"obfuscated",
"click_event",
"hover_event",
"insertion",
"font",
];
/// Style.Serializer: the fields a style sets.
@@ -546,17 +529,12 @@ 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 })
}
ClickEvent::CopyToClipboard(value) => {
json!({ "action": "copy_to_clipboard", "value": value })
}
ClickEvent::RunCommand(command) => {
json!({ "action": "run_command", "command": command })
}
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
};
out.push(("click_event".to_owned(), event));
@@ -566,13 +544,6 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
"hover_event".to_owned(),
json!({ "action": "show_text", "value": hover.component() }),
));
} else if let Some(Value::Object(item)) = style.hover_item.as_deref() {
let mut event = item.clone();
event.insert("action".into(), "show_item".into());
out.push(("hover_event".to_owned(), Value::Object(event)));
}
if let Some(insertion) = &style.insertion {
out.push(("insertion".to_owned(), insertion.clone().into()));
}
if let Some(font) = &style.font {
out.push(("font".to_owned(), font.clone().into()));
-8
View File
@@ -1,8 +0,0 @@
//! Who an action came from or is aimed at: the player or a mob by id.
/// Who caused damage or is being targeted.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Who {
Player,
Mob(u32),
}
-3
View File
@@ -11,7 +11,6 @@ description = "Horizon native development client"
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
@@ -63,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).
@@ -106,6 +104,5 @@ path = "examples/probe/main.rs"
workspace = true
[dev-dependencies]
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
@@ -2,7 +2,7 @@
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
#[allow(dead_code)]
#[path = "../../horizon-items/src/blocks/decode.rs"]
#[path = "../src/blocks/decode.rs"]
mod decode;
use std::{hint::black_box, path::Path, time::Instant};
@@ -54,7 +54,7 @@ pub fn main() -> Result<()> {
match c["axis"].as_i64() {
Some(axis @ 0..=2) => {
let line = record("electric_spark");
particles::along_axis(&mut random, axis as usize, p, (10, 19), |p, v| {
particles::along_axis(&mut random, axis as usize, p, |p, v| {
lines.push(line(p, v))
});
}
@@ -24,7 +24,6 @@ fn stack(spec: &str) -> LootStack {
exploration: None,
ominous_amplifier: None,
stew_effects: Vec::new(),
names: Vec::new(),
};
for e in parts {
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
@@ -223,7 +223,6 @@ fn decorations(c: &Value, atlas: &HudAtlas) -> Vec<GuiDraw> {
display_health: int("displayHealth", 0),
last_health_time: millis - i64::from(int("lastHealthAgo", 0)),
health_blink_time: i64::from(int("healthBlinkTime", 0)),
..HealthHud::default()
};
let kind = if has("minecraft:poison") {
HeartKind::Poisoned
@@ -101,7 +101,7 @@ fn title(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
}
let partial = float(c, "partial", 0.0);
let bg = background(c);
let mut out = t.overlay_message(w, h, 39, partial, &atlas.font, bg);
let mut out = t.overlay_message(w, h, partial, &atlas.font, bg);
out.extend(t.title(w, h, partial, &atlas.font, bg));
out
}
@@ -129,7 +129,7 @@ fn selected(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
}
let mode = c["gameMode"].as_str().unwrap_or("survival");
let can_hurt = mode == "survival" || mode == "adventure";
highlight.draw(w, h, 39, can_hurt, &atlas.font, background(c))
highlight.draw(w, h, can_hurt, &atlas.font, background(c))
}
fn boss(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
@@ -74,14 +74,7 @@ pub fn main() -> Result<()> {
biomes: &biomes,
registry: assets.registry,
raining_at: &|_| false,
height_no_leaves: &|_| 0,
firefly_bush_sounds: false,
sky_darken: 0,
marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)),
model_offset: &|_, _, _| None,
gateway_particles: &|_, _| 0,
thundering: false,
ultrawarm: false,
};
engine.seed_randoms(1057, 0, 0);
engine.requested = Some(Vec::new());
@@ -567,13 +567,7 @@ fn print_heart(
/// The damage of a mob's direct hit on the player (others count as none).
fn player_damage(e: &MobEvent) -> f32 {
match e {
MobEvent::AttackPlayer(amount, _)
| MobEvent::AttackPlayerWithEffect { amount, .. }
| MobEvent::DamagePlayer { amount, .. } => *amount,
MobEvent::SonicBoom {
target: Who::Player,
..
} => 10.0,
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount,
_ => 0.0,
}
}
@@ -584,15 +578,9 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
return false;
};
match e {
MobEvent::AttackPlayer(amount, _)
| MobEvent::AttackPlayerWithEffect { amount, .. }
| MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
MobEvent::SonicBoom {
target: Who::Player,
..
} => true,
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();
@@ -1057,29 +1045,22 @@ pub fn main() -> horizon_client::Result<()> {
alive: true,
creative: s.creative,
sneaking: false,
swimming: false,
look: [0.0, 0.0, 1.0],
main_hand: s.hand.as_deref().map(Into::into),
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,
sleeping: false,
sleep_timer: 0,
passenger: false,
}
});
let mut scratch = LegacyRandom::new(0);
@@ -1089,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) => {
@@ -1102,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,
@@ -1120,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,
}
};
}
@@ -208,7 +208,7 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> {
)),
MotionEvent::Emission {
state,
muffled: true,
in_water: true,
..
} => Some(format!("muffled-step {}", state.0)),
MotionEvent::BubbleColumnParticle { bubble, position } => Some(format!(
@@ -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" => {
+22 -153
View File
@@ -4,7 +4,7 @@
//! conditions advancements use).
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
collections::{BTreeMap, HashMap},
path::Path,
};
@@ -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,87 +703,6 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
}
true
}
/// `KilledByArrowTrigger.TriggerInstance.matches`: the weapon, each listed
/// victim condition matched by a distinct victim, and the count of entity types.
pub fn killed_by_arrow(
c: &Value,
me: &EntityView,
victims: &[EntityView],
weapon: Option<&ItemView>,
w: &dyn World,
) -> bool {
fn ctx<'a>(e: &'a EntityView, me: &EntityView) -> Entities<'a> {
Entities {
this: Some(e),
origin: me.position,
tool: None,
block_pos: None,
}
}
if !conditions(&c["player"], &ctx(me, me), w) {
return false;
}
if !c["fired_from_weapon"].is_null()
&& !weapon.is_some_and(|it| item(&c["fired_from_weapon"], it, w))
{
return false;
}
let mut left: Vec<&EntityView> = victims.iter().collect();
for want in c["victims"].as_array().into_iter().flatten() {
let Some(i) = left.iter().position(|e| conditions(want, &ctx(e, me), w)) else {
return false;
};
left.remove(i);
}
let kinds: BTreeSet<&str> = victims.iter().map(|e| e.kind.as_str()).collect();
range_i(&c["unique_entity_types"], kinds.len() as i64)
}
/// 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>,
+28 -551
View File
@@ -8,7 +8,7 @@ use crate::{
blocks::{BlockFacts, Fluid},
command::args::GameMode,
items::ItemStack,
particles::{ParticleEngine, ParticleOptions, ParticleType as T, along_axis},
particles::{ParticleEngine, ParticleOptions, ParticleType as T},
world::World,
};
use horizon_core::{
@@ -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,32 +73,6 @@ enum Animate {
waterlogged: bool,
},
EndPortal,
/// A powered lever: its facing and the direction it is attached toward.
Lever {
facing: [i32; 3],
connected: [i32; 3],
},
/// A powered repeater.
Repeater {
facing: [i32; 3],
delay: i32,
},
/// A free-hanging stalactite tip that is not waterlogged.
PointedDripstone,
WitherRose,
EndGateway,
/// A lightning rod, by the axis it points along (0, 1, 2 for X, Y, Z).
LightningRod(usize),
/// `WetSpongeBlock.animateTick`.
WetSponge,
FireflyBush,
/// An open eyeblossom (`Type.OPEN.emitSounds()`).
OpenEyeblossom,
PaleHangingMoss,
/// `DryVegetationBlock`: a dead bush.
DeadBush,
/// `ShortDryGrassBlock` and `TallDryGrassBlock`.
DryGrass,
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -115,7 +87,6 @@ fn step(facing: &str) -> [i32; 3] {
fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
let lit = prop("lit").as_deref() == Some("true");
let powered = prop("powered").as_deref() == Some("true");
let facing = || step(&prop("facing").unwrap_or_default());
match name {
"torch" => Animate::Torch(T::Flame),
@@ -136,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()),
@@ -155,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" => {
@@ -197,47 +167,6 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
waterlogged: prop("waterlogged").as_deref() == Some("true"),
},
"end_portal" => Animate::EndPortal,
"lever" if powered => Animate::Lever {
facing: facing(),
// FaceAttachedHorizontalDirectionalBlock.getConnectedDirection.
connected: match prop("face").as_deref() {
Some("ceiling") => [0, -1, 0],
Some("floor") => [0, 1, 0],
_ => facing(),
},
},
"repeater" if powered => Animate::Repeater {
facing: facing(),
delay: prop("delay").and_then(|d| d.parse().ok()).unwrap_or(1),
},
"pointed_dripstone"
if prop("vertical_direction").as_deref() == Some("down")
&& prop("thickness").as_deref() == Some("tip")
&& prop("waterlogged").as_deref() != Some("true") =>
{
Animate::PointedDripstone
}
"wither_rose" => Animate::WitherRose,
"end_gateway" => Animate::EndGateway,
_ if name == "lightning_rod" || name.ends_with("_lightning_rod") => {
let [dx, dy, _] = facing();
Animate::LightningRod(if dx != 0 {
0
} else if dy != 0 {
1
} else {
2
})
}
"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),
"firefly_bush" => Animate::FireflyBush,
"open_eyeblossom" => Animate::OpenEyeblossom,
"pale_hanging_moss" => Animate::PaleHangingMoss,
"dead_bush" => Animate::DeadBush,
"short_dry_grass" | "tall_dry_grass" => Animate::DryGrass,
"sculk_sensor" | "calibrated_sculk_sensor"
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
{
@@ -304,20 +233,10 @@ pub struct AnimateTables {
power_transmitter: Vec<bool>,
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
dried_ghast_sounds: Vec<bool>,
/// `#triggers_ambient_desert_sand_block_sounds` (sand, red sand).
desert_sand_sounds: Vec<bool>,
/// `#required_for_poplar_leaf_ambience` (overworld natural logs).
poplar_leaf_logs: Vec<bool>,
/// `#triggers_ambient_desert_dry_vegetation_block_sounds`.
desert_dry_vegetation_sounds: Vec<bool>,
terracotta: Vec<bool>,
pale_oak_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`).
ambient: Vec<Vec<(T, f32)>>,
/// Per state: 1 for a pointed dripstone, 2 for one whose tip points down.
dripstone: Vec<u8>,
}
impl AnimateTables {
pub fn load(root: &Path, registry: &BlockRegistry, biomes: &Biomes) -> crate::Result<Self> {
@@ -383,36 +302,12 @@ impl AnimateTables {
}
}
}
let dripstone = (0..registry.len() as u32)
.map(|id| {
registry.state(BlockStateId(id)).map_or(0, |s| {
let name = s.name.strip_prefix("minecraft:").unwrap_or(&s.name);
if name != "pointed_dripstone" {
0
} else if s.properties.get("vertical_direction").map(String::as_str)
== Some("down")
{
2
} else {
1
}
})
})
.collect();
Ok(Self {
kinds,
dripstone,
impermeable: by_state("minecraft:impermeable"),
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"),
desert_dry_vegetation_sounds: by_state(
"minecraft:triggers_ambient_desert_dry_vegetation_block_sounds",
),
terracotta: by_state("minecraft:terracotta"),
pale_oak_logs: by_state("minecraft:pale_oak_logs"),
burnable,
ambient,
})
@@ -438,22 +333,8 @@ pub struct AnimateContext<'a> {
pub registry: &'a BlockRegistry,
/// `Level.isRainingAt`.
pub raining_at: &'a dyn Fn([i32; 3]) -> bool,
/// `getHeight(MOTION_BLOCKING_NO_LEAVES, x, z)` at a position's column.
pub height_no_leaves: &'a dyn Fn([i32; 3]) -> i32,
/// `EnvironmentAttributes.FIREFLY_BUSH_SOUNDS` (overworld day timeline).
pub firefly_bush_sounds: bool,
/// `Level.getSkyDarken`, for `getMaxLocalRawBrightness`.
pub sky_darken: i32,
/// Creative main-hand marker block and a resolver for each placed state's sprite.
pub marker: Option<(&'a str, &'a dyn Fn(BlockStateId) -> Option<ParticleOptions>)>,
/// `BlockState.getOffset` of a state at (x, z), for blocks with a random model offset.
pub model_offset: &'a dyn Fn(BlockStateId, i32, i32) -> Option<[f64; 3]>,
/// `TheEndGatewayBlockEntity.getParticleAmount` of the gateway state at a position.
pub gateway_particles: &'a dyn Fn(BlockStateId, [i32; 3]) -> usize,
/// `Level.isThundering`.
pub thundering: bool,
/// The dimension has `water_evaporates` and drips lava from dripstone (the Nether).
pub ultrawarm: bool,
}
/// `ClientLevel.getMarkerParticleTarget`: only the selected main-hand item counts.
@@ -502,15 +383,6 @@ impl AnimateContext<'_> {
],
))
}
/// `Level.getMaxLocalRawBrightness`: the brighter of block light and
/// sky light less the sky darkening.
fn max_local_raw_brightness(&self, p: [i32; 3]) -> i32 {
let light = self.world.light(p[0], p[1], p[2]);
i32::from(light & 15).max(i32::from(light >> 4) - self.sky_darken)
}
fn offset_of(&self, state: BlockStateId, p: [i32; 3]) -> [f64; 3] {
(self.model_offset)(state, p[0], p[2]).unwrap_or([0.0; 3])
}
fn foliage(&self, p: [i32; 3]) -> u32 {
let biome = self
.world
@@ -524,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));
}
@@ -566,7 +434,7 @@ fn do_animate_tick(
return;
}
let state = ctx.state(pos);
block_animate_tick(tables, engine, ctx, state, pos, center);
block_animate_tick(tables, engine, ctx, state, pos);
let facts = ctx.facts.get(state);
if facts.fluid != Fluid::None {
fluid_animate_tick(
@@ -722,22 +590,6 @@ fn beehive_drip(
];
add(engine, T::DrippingHoney, p, [0.0; 3]);
}
/// `AmbientDesertBlockSoundsPlayer.shouldPlayDesertDryVegetationBlockSounds`:
/// sand or terracotta two blocks deep.
fn desert_dry_vegetation_sounds(
tables: &AnimateTables,
ctx: &AnimateContext,
below: [i32; 3],
) -> bool {
let triggers = |p: [i32; 3]| {
tables
.desert_dry_vegetation_sounds
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
};
triggers(below) && triggers([below[0], below[1] - 1, below[2]])
}
/// `WaterFluid.animateTick` / `LavaFluid.animateTick`.
fn fluid_animate_tick(
engine: &mut ParticleEngine,
@@ -813,7 +665,6 @@ fn block_animate_tick(
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
player: [i32; 3],
) {
let (fx, fy, fz) = (f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
match tables.kind(state) {
@@ -920,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
@@ -932,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
@@ -1107,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";
@@ -1115,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 {
@@ -1203,198 +1021,12 @@ fn block_animate_tick(
}
}
}
Animate::FireflyBush => {
// FireflyBushBlock.animateTick: the idle sound on a quiet night
// under open sky, and fireflies in the dark within 5 blocks.
if next_int(engine.random(), 30) == 0
&& ctx.firefly_bush_sounds
&& (ctx.height_no_leaves)(pos) <= pos[1]
{
let name = "minecraft:block.firefly_bush.idle";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
if ctx.max_local_raw_brightness(pos) <= 13 {
let r = engine.random();
if r.next_double() <= 0.7 {
let p = [
fx + r.next_double() * 10.0 - 5.0,
fy + r.next_double() * 5.0,
fz + r.next_double() * 10.0 - 5.0,
];
add(engine, T::Firefly, p, [0.0; 3]);
}
}
}
Animate::OpenEyeblossom => {
// EyeblossomBlock.animateTick: pale moss below.
if next_int(engine.random(), 700) == 0 {
let below = ctx.state([pos[0], pos[1] - 1, pos[2]]);
if ctx
.registry
.state(below)
.is_some_and(|s| s.name == "minecraft:pale_moss_block")
{
let name = "minecraft:block.eyeblossom.idle";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
}
}
Animate::PaleHangingMoss => {
// HangingMossBlock.animateTick: under pale oak logs or leaves.
if next_int(engine.random(), 500) == 0 {
let above = ctx.state([pos[0], pos[1] + 1, pos[2]]);
let pale_leaves = ctx
.registry
.state(above)
.is_some_and(|s| s.name == "minecraft:pale_oak_leaves");
if pale_leaves
|| tables
.pale_oak_logs
.get(above.0 as usize)
.copied()
.unwrap_or(false)
{
let name = "minecraft:block.pale_hanging_moss.idle";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
}
}
Animate::DeadBush => {
// AmbientDesertBlockSoundsPlayer.playAmbientDeadBushSounds.
if next_int(engine.random(), 130) == 0 {
let below = [pos[0], pos[1] - 1, pos[2]];
let state = ctx.state(below);
let red_sand = ctx
.registry
.state(state)
.is_some_and(|s| s.name == "minecraft:red_sand");
let terracotta = tables
.terracotta
.get(state.0 as usize)
.copied()
.unwrap_or(false);
if (red_sand || terracotta) && next_int(engine.random(), 3) != 0 {
return;
}
if desert_dry_vegetation_sounds(tables, ctx, below) {
let name = "minecraft:block.deadbush.idle";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
}
}
Animate::DryGrass => {
// AmbientDesertBlockSoundsPlayer.playAmbientDryGrassSounds:
// played at the player.
if next_int(engine.random(), 200) == 0
&& desert_dry_vegetation_sounds(tables, ctx, [pos[0], pos[1] - 1, pos[2]])
{
let at = [
f64::from(player[0]) + 0.5,
f64::from(player[1]),
f64::from(player[2]) + 0.5,
];
sound(engine, "minecraft:block.dry_grass.ambient", at, 1.0, 1.0);
}
}
Animate::EndPortal => {
// EndPortalBlock.animateTick: one smoke particle above the surface.
let r = engine.random();
let p = [fx + r.next_double(), fy + 0.8, fz + r.next_double()];
add(engine, T::Smoke, p, [0.0; 3]);
}
Animate::Lever { facing, connected } => {
// LeverBlock.animateTick: a powered lever puffs red dust a quarter of the time.
if engine.random().next_float() < 0.25 {
// LeverBlock.makeParticle with scale 0.5.
let at = |base: f64, i: usize| {
base + 0.5 + 0.1 * f64::from(-facing[i]) + 0.2 * f64::from(-connected[i])
};
let p = [at(fx, 0), at(fy, 1), at(fz, 2)];
engine.add(&ParticleOptions::dust([1.0, 0.0, 0.0], 0.5), p, [0.0; 3]);
}
}
Animate::Repeater { facing, delay } => {
// RepeaterBlock.animateTick: dust at one of the torches.
let r = engine.random();
let x = fx + 0.5 + (r.next_double() - 0.5) * 0.2;
let y = fy + 0.4 + (r.next_double() - 0.5) * 0.2;
let z = fz + 0.5 + (r.next_double() - 0.5) * 0.2;
let mut offset = -5.0f32;
if r.next_boolean() {
offset = (delay * 2 - 1) as f32;
}
offset /= 16.0;
let xo = f64::from(offset * facing[0] as f32);
let zo = f64::from(offset * facing[2] as f32);
redstone(engine, [x + xo, y, z + zo]);
}
Animate::PointedDripstone => dripstone_drip(tables, engine, ctx, state, pos),
Animate::WitherRose => {
// WitherRoseBlock.animateTick: smoke puffs at the centre of the shape.
let center = ctx.shape_y(pos).map_or([0.5; 2], |s| {
[(s.2[0] + s.2[1]) / 2.0, (s.2[2] + s.2[3]) / 2.0]
});
let offset = ctx.offset_of(state, pos);
let x = fx + center[0] + offset[0];
let z = fz + center[1] + offset[2];
for _ in 0..3 {
let r = engine.random();
if r.next_boolean() {
let p = [
x + r.next_double() / 5.0,
fy + (0.5 - r.next_double()),
z + r.next_double() / 5.0,
];
add(engine, T::Smoke, p, [0.0; 3]);
}
}
}
Animate::EndGateway => {
// EndGatewayBlock.animateTick: portal particles, one for each visible face.
for _ in 0..(ctx.gateway_particles)(state, pos) {
let r = engine.random();
let mut p = [
fx + r.next_double(),
fy + r.next_double(),
fz + r.next_double(),
];
let mut v = [
(r.next_double() - 0.5) * 0.5,
(r.next_double() - 0.5) * 0.5,
(r.next_double() - 0.5) * 0.5,
];
let flip = next_int(r, 2) * 2 - 1;
if r.next_boolean() {
p[2] = fz + 0.5 + 0.25 * f64::from(flip);
v[2] = f64::from(r.next_float() * 2.0 * flip as f32);
} else {
p[0] = fx + 0.5 + 0.25 * f64::from(flip);
v[0] = f64::from(r.next_float() * 2.0 * flip as f32);
}
add(engine, T::Portal, p, v);
}
}
Animate::LightningRod(axis) => {
// LightningRodBlock.animateTick: in a thunderstorm, sparks along the
// topmost rod, more often as the game time within 200 ticks grows.
if ctx.thundering
&& i64::from(next_int(engine.level_random(), 200)) <= ctx.world.game_time % 200
&& pos[1]
== ctx
.world
.world_surface(pos[0], pos[2], |id| ctx.facts.is_air(id))
- 1
{
let mut sparks = Vec::new();
along_axis(engine.level_random(), axis, pos, (1, 2), |p, v| {
sparks.push((p, v));
});
let options = ParticleOptions::simple(T::ElectricSpark);
for (p, v) in sparks {
engine.add(&options, p, v);
}
}
}
Animate::EnderChest => {
for _ in 0..3 {
let r = engine.random();
@@ -1413,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.
@@ -1430,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.
@@ -1555,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);
@@ -1568,7 +1192,7 @@ fn block_animate_tick(
);
}
} else {
add_visible(
add(
engine,
T::BubbleColumnUp,
[fx + 0.5, fy, fz + 0.5],
@@ -1580,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);
@@ -1658,153 +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;
}
}
}
/// `PointedDripstoneBlock.animateTick`: a free-hanging stalactite tip drips the
/// fluid above the root of its stalactite.
fn dripstone_drip(
tables: &AnimateTables,
engine: &mut ParticleEngine,
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
) {
let roll = engine.random().next_float();
if roll > 0.12 {
return;
}
// getFluidAboveStalactite: findRootBlock climbs at most ten blocks of
// stalactite and stops at the first block that is not pointed dripstone.
let mut root = None;
for step in 1..11 {
let p = [pos[0], pos[1] + step, pos[2]];
match tables
.dripstone
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(0)
{
0 => {
root = Some(p);
break;
}
2 => {}
_ => return,
}
}
let Some(root) = root else {
return;
};
let above = ctx.facts.get(ctx.state([root[0], root[1] + 1, root[2]]));
// Mud above the root counts as a water source unless water evaporates here.
let (fluid, source) = if &*above.name == "minecraft:mud" && !ctx.ultrawarm {
(Fluid::Water, true)
} else {
(above.fluid, above.fluid_level >= 8 && !above.fluid_falling)
};
// canFillCauldron: only a water or lava source passes below a 2% roll.
if roll >= 0.02 && !(fluid != Fluid::None && source) {
return;
}
// getDripParticle: no fluid drips what the dimension's default says.
let lava = fluid == Fluid::Lava || (fluid == Fluid::None && ctx.ultrawarm);
let ty = if lava {
T::DrippingDripstoneLava
} else {
T::DrippingDripstoneWater
};
let offset = ctx.offset_of(state, pos);
// STALACTITE_DRIP_START_PIXEL is the tip's lowest point, 5/16.
let p = [
f64::from(pos[0]) + 0.5 + offset[0],
f64::from(pos[1]) + 0.3125 - 0.0625,
f64::from(pos[2]) + 0.5 + offset[2],
];
add(engine, ty, p, [0.0; 3]);
}
/// `AmbientDesertBlockSoundsPlayer.shouldPlayAmbientSandSound`: sand in the
/// surface column eight blocks away on all four sides.
fn sand_sound_nearby(tables: &AnimateTables, ctx: &AnimateContext, pos: [i32; 3]) -> bool {
let is_sand = |p: [i32; 3]| {
tables
.desert_sand_sounds
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
};
let (mut found, mut checked) = (0, 0);
for d in [[0, -8], [8, 0], [0, 8], [-8, 0]] {
let (x, z) = (pos[0] + d[0], pos[2] + d[1]);
// columnContainsTriggeringBlock.
let surface = ctx.world.world_surface(x, z, |s| ctx.facts.is_air(s)) - 1;
let matches = if (surface - pos[1]).abs() > 5 {
let mut y = pos[1] + 6;
let mut above_air = ctx.is_air([x, y, z]);
y -= 1;
let mut hit = false;
for _ in 0..10 {
if above_air && is_sand([x, y, z]) {
hit = true;
break;
}
above_air = ctx.is_air([x, y, z]);
y -= 1;
}
hit
} else {
ctx.is_air([x, surface + 1, z]) && is_sand([x, surface, z])
};
if matches {
found += 1;
if found > 3 {
return true;
}
}
checked += 1;
if 4 - checked + found < 3 {
return false;
}
}
false
}
/// `AbstractCandleBlock.addParticlesAndSound`.
fn candle(engine: &mut ParticleEngine, p: [f64; 3]) {
let r = engine.random();
-3
View File
@@ -159,8 +159,6 @@ pub struct ArrowState {
pub pickup: Pickup,
/// `pickupItemStack`: what picking the arrow up gives.
pub pickup_item: crate::items::ItemStack,
/// `AbstractArrow.firedFromWeapon`: the crossbow or bow it was shot from.
pub fired_from_weapon: Option<crate::items::ItemStack>,
pub no_physics: bool,
/// `AbstractArrow.getPierceLevel`.
pub pierce: u8,
@@ -210,7 +208,6 @@ impl ArrowState {
last_state: None,
pickup: Pickup::Disallowed,
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
fired_from_weapon: None,
no_physics: false,
pierce: 0,
pierced: Vec::new(),
-78
View File
@@ -234,81 +234,3 @@ impl RenderAssets {
})
}
}
#[cfg(test)]
mod tests {
use crate::items::ConsumeEffect;
#[test]
fn always_edible_and_consume_effects_loaded() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("items.json").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
let apple = items.get("minecraft:golden_apple").expect("golden apple");
assert!(apple.can_always_eat);
assert!(apple.food.is_some());
assert!(!apple.consumable.as_ref().unwrap().effects.is_empty());
let enchanted = items
.get("minecraft:enchanted_golden_apple")
.expect("enchanted golden apple");
assert!(enchanted.can_always_eat);
assert!(enchanted.food.is_some());
assert!(!enchanted.consumable.as_ref().unwrap().effects.is_empty());
let beef = items.get("minecraft:cooked_beef").expect("cooked beef");
assert!(!beef.can_always_eat);
assert!(beef.food.is_some());
// Consumables without food, their style and their remainders.
use horizon_world::consumable::UseAnimation;
let milk = items.get("minecraft:milk_bucket").expect("milk bucket");
let drink = milk.consumable.as_ref().unwrap();
assert!(milk.food.is_none());
assert_eq!(drink.effects, [ConsumeEffect::ClearAllEffects]);
assert_eq!((drink.ticks(), drink.animation), (32, UseAnimation::Drink));
assert_eq!(drink.sound, "minecraft:entity.generic.drink");
assert!(!drink.particles);
assert_eq!(milk.use_remainder.as_deref(), Some("minecraft:bucket"));
let honey = items.get("minecraft:honey_bottle").expect("honey bottle");
let sip = honey.consumable.as_ref().unwrap();
assert_eq!(sip.ticks(), 40);
assert_eq!(sip.sound, "minecraft:item.honey_bottle.drink");
assert_eq!(
sip.effects,
[ConsumeEffect::RemoveEffects(vec![
"minecraft:poison".into()
])]
);
assert_eq!(
honey.use_remainder.as_deref(),
Some("minecraft:glass_bottle")
);
let kelp = items.get("minecraft:dried_kelp").unwrap();
let bite = kelp.consumable.as_ref().unwrap();
assert_eq!((bite.ticks(), bite.animation), (16, UseAnimation::Eat));
assert_eq!(bite.sound, "minecraft:entity.generic.eat");
assert!(bite.particles && kelp.use_remainder.is_none());
let chorus = items.get("minecraft:chorus_fruit").unwrap();
assert_eq!(
chorus.consumable.as_ref().unwrap().effects,
[ConsumeEffect::TeleportRandomly(
horizon_world::teleport_consume::TeleportConsume {
diameter: 16.0,
directional_particles: true,
}
)]
);
let chicken = items.get("minecraft:chicken").unwrap();
assert!(matches!(
chicken.consumable.as_ref().unwrap().effects[..],
[ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3
));
}
}
+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);
+53 -1
View File
@@ -3,10 +3,24 @@
//! banners, and the loom rules of `LoomMenu` (selectable patterns, the
//! six-layer limit and the result stack).
use crate::{block_entity_render::DYES, items::ItemStack, items::Items};
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path, sync::Arc};
pub use horizon_items::banner_layer::{BannerLayer, layers_from_nbt, layers_to_nbt};
/// `BannerPatternLayers.Layer`: a pattern id and the dye name it is painted in.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BannerLayer {
pub pattern: Arc<str>,
pub color: Arc<str>,
}
impl BannerLayer {
pub fn new(pattern: &str, color: &str) -> Self {
Self {
pattern: Arc::from(pattern),
color: Arc::from(color),
}
}
}
/// Layers a loom may stack on a banner (`LoomMenu`'s `hasMaxPatterns`).
pub const MAX_LAYERS: usize = 6;
@@ -210,6 +224,44 @@ pub fn has_max_layers(banner: &ItemStack) -> bool {
layers(banner).len() >= MAX_LAYERS
}
/// The `banner_patterns` NBT list of layers.
pub fn layers_to_nbt(layers: &[BannerLayer]) -> Tag {
Tag::List(
layers
.iter()
.map(|l| {
Tag::compound([
("pattern", Tag::String((&*l.pattern).into())),
("color", Tag::String((&*l.color).into())),
])
})
.collect(),
)
}
pub fn layers_from_nbt(tag: Option<&Tag>) -> Vec<BannerLayer> {
let Some(Tag::List(list)) = tag else {
return Vec::new();
};
list.iter()
.filter_map(|e| {
let text = |k: &str| match e.get(k) {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
};
let pattern = text("pattern")?;
let color = text("color")?;
Some(BannerLayer::new(
&if pattern.contains(':') {
pattern
} else {
format!("minecraft:{pattern}")
},
&color,
))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
+14 -1
View File
@@ -3,7 +3,6 @@
//! the rain, nor through a blocked front, unless the hive is emptied in an
//! emergency), honey for every bee coming home with nectar. The host (the game
//! or the mob AI probe) loads and adds the bees through [`HiveHost`].
pub use crate::chunk::block_entity::{Beehive, Occupant};
use crate::mobs::{Mob, Species, bee};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::nbt::{Compound, Tag};
@@ -39,6 +38,20 @@ const IGNORED_BEE_TAGS: [&str; 25] = [
"UUID",
];
/// `BeehiveBlockEntity`: the bees inside and the flower one of them knew.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Beehive {
pub bees: Vec<Occupant>,
pub flower: Option<[i32; 3]>,
}
/// `BeehiveBlockEntity.Occupant`: a bee's tag (with its `id`), its time in
/// the hive and the time it stays at least.
#[derive(Clone, Debug, PartialEq)]
pub struct Occupant {
pub entity_data: Tag,
pub ticks_in_hive: i32,
pub min_ticks_in_hive: i32,
}
/// `BeehiveBlockEntity.BeeReleaseStatus`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Release {
-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(),
)
}
-7
View File
@@ -66,13 +66,6 @@ impl BiomeInfo {
}
.height_adjusted(pos, sea_level)
}
/// `Biome.getPrecipitationAt` in the Overworld (sea level 63): None
/// without precipitation, else whether it falls as snow
/// (`getHeightAdjustedTemperature` below 0.15).
pub fn snows_at(&self, x: i32, y: i32, z: i32) -> Option<bool> {
self.precipitation
.then(|| self.temperature_at(BlockPos::new(x, y, z), 63) < 0.15)
}
}
pub struct Biomes {
pub list: Vec<BiomeInfo>,
+1 -1
View File
@@ -23,7 +23,7 @@
//! Which states tick is a table by state id, built by the game from the block
//! registry; whether a slot is made is decided when the slot is promoted at
//! the start of the next tick, from the block state then.
use crate::chunk::block_entity::BlockEntity;
use crate::containers::BlockEntity;
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
+21 -117
View File
@@ -5,11 +5,7 @@
//! (`entity-models.json`); transforms, animation and textures follow each
//! vanilla renderer. Faces are lit like the `entity` shader: the lightmap at
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
use crate::{
hud::Rect,
mesher::{CULL_BACK, Vertex},
world::World,
};
use crate::{hud::Rect, mesher::Vertex, world::World};
use glam::{DVec3, Mat4, Quat, Vec3};
use horizon_core::geometry::{
Direction,
@@ -153,8 +149,6 @@ pub fn draw_part(
layer: 0,
color,
light,
glint_uv: [0.0, 0.0],
});
}
}
@@ -616,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,
@@ -656,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],
});
}
}
@@ -744,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),
}
@@ -942,30 +932,6 @@ impl BlockEntityRenderer {
let lights = diffuse_lights(scene.nether);
self.render_one(scene, pos, kind, base, packed, lights, false, false, out);
}
/// `TheEndGatewayBlockEntity.shouldRenderFace` for each face of the gateway
/// at `pos`: the faces its portal is drawn on.
pub fn gateway_faces(
&self,
world: &World,
state: BlockStateId,
pos: [i32; 3],
) -> Vec<Direction> {
Direction::ALL
.into_iter()
.filter(|&d| {
let [dx, dy, dz] = d.normal();
let neighbor = world.block(pos[0] + dx, pos[1] + dy, pos[2] + dz);
self.visibility.as_ref().is_none_or(|v| {
v.lock()
.map(|mut v| {
v.should_render_face(state, neighbor, d, true)
.unwrap_or(true)
})
.unwrap_or(true)
})
})
.collect()
}
/// Whether this block state has a block-entity renderer here.
pub fn renders(&self, state: u32) -> bool {
!matches!(self.kind(state), Kind::None)
@@ -1320,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 {
@@ -1420,7 +1385,6 @@ impl BlockEntityRenderer {
m *= around(Mat4::from_rotation_y(turn * decay), pivot);
}
}
let first = out.len();
if let Some(paint) =
paint("entity/decorated_pot/decorated_pot_base", 0xFFFFFF, packed)
{
@@ -1438,11 +1402,6 @@ impl BlockEntityRenderer {
draw_part(part, side, m, &none, paint, out);
}
}
// `DecoratedPotRenderer` submits every part with `entitySolid`, which culls
// back faces: the flat `top` and `bottom` planes show one side each.
for vertex in &mut out[first..] {
vertex.normal = CULL_BACK;
}
}
Kind::EndPortal => {
// TheEndPortalRenderer.TRANSFORMATION: y from 6/16 to 12/16; only the
@@ -1473,8 +1432,22 @@ impl BlockEntityRenderer {
out,
);
}
// AbstractEndPortalRenderer.extractRenderState.
let faces = self.gateway_faces(scene.world, BlockStateId(*state), pos);
// AbstractEndPortalRenderer.extractRenderState: TheEndGatewayBlockEntity.shouldRenderFace.
let faces: Vec<Direction> = Direction::ALL
.into_iter()
.filter(|&d| {
let [dx, dy, dz] = d.normal();
let neighbor = scene.world.block(pos[0] + dx, pos[1] + dy, pos[2] + dz);
self.visibility.as_ref().is_none_or(|v| {
v.lock()
.map(|mut v| {
v.should_render_face(BlockStateId(*state), neighbor, d, true)
.unwrap_or(true)
})
.unwrap_or(true)
})
})
.collect();
portal_cube(base, &faces, END_GATEWAY_LAYER, out);
}
Kind::Conduit => {
@@ -1741,8 +1714,6 @@ impl BeamPart {
layer: 6,
color,
light: self.wrap,
glint_uv: [0.0, 0.0],
});
}
}
@@ -1813,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],
});
}
}
@@ -2138,70 +2108,4 @@ mod tests {
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
}
/// `DecoratedPotRenderer` submits every part with `RenderTypes.entitySolid`,
/// whose pipeline culls back faces. The pot's `top` and `bottom` are flat
/// boxes, so without that cull both sides of each plane are drawn and they
/// z-fight: the pot renders wrong.
#[test]
fn the_decorated_pot_culls_its_back_faces() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("entity-models.json").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let registry = assets.registry;
let renderer = BlockEntityRenderer::load(&root, registry).unwrap();
let state = registry.default_state("minecraft:decorated_pot").unwrap();
let sprites: HashMap<String, Rect> = [
"entity/decorated_pot/decorated_pot_base",
"entity/decorated_pot/decorated_pot_side",
]
.into_iter()
.map(|name| {
(
name.to_owned(),
Rect {
u0: 0.25,
v0: 0.125,
u1: 0.375,
v1: 0.25,
w: 16,
h: 16,
},
)
})
.collect();
let world = World::new(BlockStateId(0), 1);
let scene = Scene {
camera: DVec3::ZERO,
orientation: Quat::IDENTITY,
anim: &|_| BlockAnim::default(),
texts: None,
partial: 0.5,
game_time: 0,
max_y: 320,
world: &world,
sprites: &sprites,
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
};
let mut verts = Vec::new();
renderer.render_block(&scene, [0; 3], state.0, Mat4::IDENTITY, 0xff, &mut verts);
// Vanilla submits 28 quads for a plain pot, and the cull keeps them all.
assert_eq!(
verts.len() / 4,
28,
"the pot submits all of vanilla's quads"
);
assert!(
verts.iter().all(|v| v.normal == CULL_BACK),
"entitySolid culls the pot's back faces"
);
}
}
File renamed without changes.
-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],
});
}
}
+239 -6
View File
@@ -2,18 +2,239 @@
//! in vanilla, ported as tables), data-driven `minecraft:brewing` recipes from
//! `data/minecraft/recipe/brewing`, potion colours and tooltips, and
//! `BrewingStandBlockEntity.serverTick`.
pub use crate::chunk::block_entity::BrewingStand;
use crate::{
containers::Providers,
items::{ItemStack, Items},
};
use serde_json::Value;
use std::{collections::HashSet, fs, path::Path};
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;
@@ -191,6 +412,18 @@ impl Brewing {
}
}
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
#[derive(Clone, Debug, Default)]
pub struct BrewingStand {
pub items: [Option<ItemStack>; 5],
pub brew_time: i32,
pub total_brew_time: i32,
pub fuel: i32,
pub total_fuel: i32,
pub speed: f32,
pub ingredient: Option<Arc<str>>,
pub last_bottles: Option<[bool; 3]>,
}
/// What a brewing tick asks the world to do.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct BrewTick {
@@ -50,12 +50,10 @@ impl Weight {
/// The sounds of `BundleItem` (`Entity.playSound` at the player): insert
/// and remove one at volume 0.8 and pitch `0.8 + random * 0.4`, a failed
/// insert at 1, 1; the drop of its contents is played at the player's block
/// like remove one.
/// insert at 1, 1.
pub const INSERT: &str = "minecraft:item.bundle.insert";
pub const INSERT_FAIL: &str = "minecraft:item.bundle.insert_fail";
pub const REMOVE_ONE: &str = "minecraft:item.bundle.remove_one";
pub const DROP_CONTENTS: &str = "minecraft:item.bundle.drop_contents";
fn max_stack(items: &Items, stack: &ItemStack) -> u32 {
items.get(&stack.item).map_or(64, |i| i.max_stack).max(1)
@@ -213,17 +211,6 @@ impl Contents {
self.weight.n >= self.weight.d
}
/// `BundleItem.isBarVisible`, `getBarWidth` and `getBarColor`: the fullness bar
/// (width, RGB), blue until the bundle is full, then red.
pub fn bar(&self) -> Option<(i32, u32)> {
if self.weight.n <= 0 {
return None;
}
let width = (1 + self.weight.n * 12 / self.weight.d).min(13) as i32;
let color = if self.is_full() { 0xFF5454 } else { 0x7087FF };
Some((width, color))
}
/// `toImmutable`, written back into the bundle's component.
pub fn store(self, bundle: &mut ItemStack) {
let data = bundle.data_mut();
@@ -375,7 +362,14 @@ mod tooltip_tests {
/// The prepared resources, when they are on this machine: the tooltip
/// asks the item registry whether a stack is a bundle.
fn items() -> Option<Items> {
crate::items::prepared_items()
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("items.json").exists() {
return None;
}
let assets = crate::assets::Assets::load(&root).ok()?;
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).ok()?;
Items::load(&root, assets.registry, &facts).ok()
}
fn bundle_with(counts: &[(&str, u32)]) -> ItemStack {
+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,
+12 -125
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(
@@ -556,34 +458,26 @@ impl Chat {
pub fn hover_at(&self, font: &crate::font::Font, height: i32, x: f64, y: f64) -> Option<Text> {
self.style_at(font, height, x, y)?.hover.map(|h| *h)
}
/// The wrapped line under a GUI-space cursor while chat is focused, and
/// the cursor's x within it (the text starts at 0).
fn line_at(&self, height: i32, x: f64, y: f64) -> Option<(&Line, f64)> {
let scale = self.options.scale as f32;
let x = x / f64::from(scale) - 4.0;
let bottom = hud_layout::floor((height - 40) as f32 / scale);
let from_bottom = f64::from(bottom) - y / f64::from(scale);
if x >= f64::from(self.max_width()) || from_bottom <= 0.0 {
return None;
}
let row = (from_bottom / f64::from(self.options.line_height())).ceil() as usize - 1;
if row >= self.lines_per_page(true) {
return None;
}
Some((self.lines.get(self.scroll + row)?, x))
}
/// ChatComponent.getClickedComponentStyleAt.
pub fn style_at(
fn style_at(
&self,
font: &crate::font::Font,
height: i32,
x: f64,
y: f64,
) -> Option<crate::text::Style> {
let (line, mut x) = self.line_at(height, x, y)?;
if x < 0.0 {
let scale = self.options.scale as f32;
let mut x = x / f64::from(scale) - 4.0;
let bottom = hud_layout::floor((height - 40) as f32 / scale);
let from_bottom = f64::from(bottom) - y / f64::from(scale);
if x < 0.0 || x >= f64::from(self.max_width()) || from_bottom <= 0.0 {
return None;
}
let row = (from_bottom / f64::from(self.options.line_height())).ceil() as usize - 1;
if row >= self.lines_per_page(true) {
return None;
}
let line = self.lines.get(self.scroll + row)?;
let content = line.content.resolve_legacy();
for (c, style) in content.chars() {
x -= f64::from(font.advance(c, style));
@@ -593,13 +487,6 @@ impl Chat {
}
None
}
/// Whether the cursor is on a line's indicator bar while chat is focused
/// (`DrawingFocusedGraphicsAccess.handleTag`), whose tooltip is the
/// message tag's text.
pub fn indicator_at(&self, height: i32, x: f64, y: f64) -> bool {
self.line_at(height, x, y)
.is_some_and(|(line, x)| line.indicator.is_some() && (-4.0..-2.0).contains(&x))
}
/// Plain text of the newest messages (for tests and the control socket).
pub fn recent_lines(&self, n: usize) -> Vec<String> {
self.messages
+2 -75
View File
@@ -2,12 +2,9 @@
//! 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};
pub mod block_entity;
pub const MIN_Y: i32 = -64;
pub const HEIGHT: i32 = 384;
pub const SECTIONS: usize = (HEIGHT / 16) as usize;
@@ -151,14 +148,14 @@ pub struct Chunk {
/// Newly generated (not loaded from a save): eligible for generation-time spawns.
pub fresh: bool,
/// Block entities read from a save, moved into the world on arrival.
pub block_entities: Vec<((i32, i32, i32), block_entity::BlockEntity)>,
pub block_entities: Vec<((i32, i32, i32), crate::containers::BlockEntity)>,
/// `CustomName` of the container block entities (shown as the screen title).
pub block_names: Vec<((i32, i32, i32), String)>,
/// Generation postprocessing marks (ProtoChunk.postProcessing) as world
/// 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`):
@@ -312,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::*;
@@ -1,409 +0,0 @@
//! Block entity data: what a chunk stores for its block entities (`BlockEntity`
//! and its payloads). Plain data, as vanilla's block entities are world data;
//! their behaviour lives with the features that use them (`containers`,
//! `brewing`, `signs`, ...), which re-export these types under their old paths.
use horizon_client_core::{text::Text, who::Who};
use horizon_items::{banner_layer::BannerLayer, items::ItemStack};
use horizon_world::{
level::sculk::SculkSpreader,
nbt::{Compound, Tag},
};
use std::{collections::HashMap, sync::Arc};
/// An entity's UUID as its four ints (`UUIDUtil.CODEC`).
pub type Uuid = [i32; 4];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FurnaceKind {
Furnace,
BlastFurnace,
Smoker,
}
#[derive(Clone, Debug)]
pub struct Furnace {
pub kind: FurnaceKind,
/// input, fuel, result
pub items: [Option<ItemStack>; 3],
pub lit_remaining: i32,
pub lit_total: i32,
pub cooking_timer: i32,
pub cooking_total: i32,
pub speed: f32,
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
/// made since a player last took from its output. The experience is owed per recipe
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
pub recipes_used: HashMap<String, u32>,
}
#[derive(Clone, Debug)]
pub enum BlockEntity {
Chest(Vec<Option<ItemStack>>),
Furnace(Furnace),
Brewing(Box<BrewingStand>),
/// Signs and hanging signs (`SignBlockEntity`).
Sign(SignData),
/// `JukeboxBlockEntity`: the record and its `JukeboxSongPlayer`.
Jukebox(Jukebox),
/// `BeaconBlockEntity`.
Beacon(Box<Beacon>),
/// `LecternBlockEntity`: the book and the open page.
Lectern(Lectern),
/// Sculk sensors, shriekers and catalysts (`crate::sculk`).
Sculk(Box<SculkEntity>),
/// `CreakingHeartBlockEntity`: its protector and the hurt emitter.
CreakingHeart(Box<CreakingHeart>),
/// `BannerBlockEntity`: the pattern layers.
Banner(Banner),
/// `CrafterBlockEntity`.
Crafter(Crafter),
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
Spawner(Box<Spawner>),
/// `TrialSpawnerBlockEntity` (`crate::mobs::trial_spawner`).
TrialSpawner(Box<TrialSpawner>),
/// `VaultBlockEntity` (`crate::vault`).
Vault(Box<Vault>),
/// `BeehiveBlockEntity` of beehives and bee nests (`crate::beehive`).
Beehive(Box<Beehive>),
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
CommandBlock(Box<CommandBlock>),
/// `CampfireBlockEntity` of campfires and soul campfires.
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).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Campfire {
pub items: [Option<ItemStack>; 4],
pub progress: [i32; 4],
pub total: [i32; 4],
}
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
/// a compound, or a string when it names a player only), the note block
/// sound and `custom_name`.
#[derive(Clone, Debug, Default)]
pub struct Skull {
pub profile: Option<horizon_world::nbt::Tag>,
pub note_block_sound: Option<String>,
pub custom_name: Option<horizon_world::nbt::Tag>,
/// What `profile` draws, read from it once rather than every frame.
pub skin: Option<SkullSkin>,
}
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
/// with the `skins/<sha1>` sprite it downloads to, and the
/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
/// profile with no id).
#[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<NameOrId>,
pub fallback: &'static str,
}
/// `Either<String, UUID>`: the key a `Dynamic` `ResolvableProfile` resolves
/// by (`ResolvableProfile.nameOrId`).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum NameOrId {
Name(String),
Id(u128),
}
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
/// and the ticks left in the crafting animation.
#[derive(Clone, Debug, Default)]
pub struct Crafter {
pub items: [Option<ItemStack>; 9],
pub disabled: [bool; 9],
pub triggered: bool,
pub crafting_ticks: i32,
/// The block state's `triggered` when last ticked (not saved): the block
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
pub state_triggered: Option<bool>,
}
#[derive(Clone, Debug, Default)]
pub struct Banner {
pub layers: Vec<BannerLayer>,
pub name: Option<String>,
/// Components not consumed by BannerBlockEntity's implicit fields.
pub components: horizon_world::nbt::Compound,
}
/// `CreakingHeartBlockEntity` (the logic is in `game/creaking.rs`).
#[derive(Clone, Debug, Default)]
pub struct CreakingHeart {
/// The protector: its entity id once resolved, and its UUID (the saved form).
pub creaking: Option<(Option<u32>, [i32; 4])>,
pub ticks_existed: i64,
pub ticker: i32,
/// Ticks left of the hurt effect (`emitter`) and where it heads.
pub emitter: i32,
pub emitter_target: Option<[f64; 3]>,
pub output_signal: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Lectern {
pub book: Option<ItemStack>,
pub page: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Beacon {
pub levels: i32,
/// Effect ids (`minecraft:speed`).
pub primary: Option<String>,
pub secondary: Option<String>,
/// Beam sections (RGB colour, height) and the ones being rebuilt.
pub sections: Vec<(u32, i32)>,
pub checking: Vec<(u32, i32)>,
pub last_check_y: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Jukebox {
pub record: Option<ItemStack>,
/// `ticks_since_song_started`, while a song plays.
pub ticks: i64,
pub playing: bool,
}
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
#[derive(Clone, Debug, Default)]
pub struct BrewingStand {
pub items: [Option<ItemStack>; 5],
pub brew_time: i32,
pub total_brew_time: i32,
pub fuel: i32,
pub total_fuel: i32,
pub speed: f32,
pub ingredient: Option<Arc<str>>,
pub last_bottles: Option<[bool; 3]>,
}
/// One sign side (`SignText`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignText {
pub lines: [String; 4],
/// `DyeColor` id.
pub color: u8,
pub glowing: bool,
}
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SignData {
pub hanging: bool,
pub front: SignText,
pub back: SignText,
pub waxed: bool,
}
/// `VibrationInfo`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Info {
pub event: &'static str,
pub distance: f32,
pub pos: [f64; 3],
pub source: Option<Who>,
pub owner: Option<Who>,
}
/// `VibrationSelector`: the closest, then highest-frequency candidate of a tick.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Selector {
pub(crate) current: Option<(Info, i64)>,
}
/// `VibrationSystem.Data`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Data {
pub current: Option<Info>,
pub travel: i32,
pub selector: Selector,
}
/// Which sculk block entity this is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SculkKind {
Sensor,
Calibrated,
Shrieker,
Catalyst,
}
/// `SculkSensorBlockEntity`, `CalibratedSculkSensorBlockEntity`,
/// `SculkShriekerBlockEntity` and `SculkCatalystBlockEntity`.
#[derive(Clone, Debug)]
pub struct SculkEntity {
pub kind: SculkKind,
pub vibration: Data,
/// `last_vibration_frequency` (sensors).
pub last_frequency: i32,
/// `warning_level` (shriekers).
pub warning_level: i32,
/// The catalyst's charge cursors.
pub spreader: SculkSpreader,
}
/// `SpawnData.CustomSpawnRules`: the block and sky light the spawn block
/// must have (inclusive ranges within 0..=15).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CustomSpawnRules {
pub block: (i32, i32),
pub sky: (i32, i32),
}
/// `SpawnData`: the entity to spawn, optional light rules and an equipment
/// table (kept as loaded).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SpawnData {
pub entity: Compound,
pub custom_spawn_rules: Option<CustomSpawnRules>,
pub equipment: Option<Tag>,
}
/// Which `SpawnData` is next: one of the potentials (the same object in
/// vanilla, so changing it changes the potential) or its own.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Next {
Potential(usize),
Own(SpawnData),
}
/// `BaseSpawner`.
#[derive(Clone, Debug, PartialEq)]
pub struct Spawner {
pub spawn_delay: i32,
/// `SpawnPotentials`: data and weight.
pub potentials: Vec<(SpawnData, i32)>,
pub(crate) next: Option<Next>,
pub min_spawn_delay: i32,
pub max_spawn_delay: i32,
pub spawn_count: i32,
pub max_nearby_entities: i32,
pub required_player_range: i32,
pub spawn_range: i32,
/// The client copy's delay (`spawnDelay` on the client level), which
/// speeds the spin up as the next spawn nears.
pub client_delay: i32,
pub spin: f64,
pub o_spin: f64,
}
/// `TrialSpawnerConfig`.
#[derive(Clone, Debug, PartialEq)]
pub struct TrialConfig {
pub spawn_range: i32,
pub total_mobs: f32,
pub simultaneous_mobs: f32,
pub total_mobs_added_per_player: f32,
pub simultaneous_mobs_added_per_player: f32,
pub ticks_between_spawn: i32,
pub spawn_potentials: Vec<(SpawnData, i32)>,
pub loot_tables_to_eject: Vec<(String, i32)>,
pub items_to_drop_when_ominous: String,
}
/// A `Holder<TrialSpawnerConfig>` as saved: a registry id
/// (`minecraft:trial_chamber/breeze/normal`) or an inline config.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ConfigSlot {
/// The value as loaded, written back as is; None for the default.
pub saved: Option<Tag>,
pub config: TrialConfig,
}
/// `TrialSpawner` with its `TrialSpawnerStateData`.
#[derive(Clone, Debug, PartialEq)]
pub struct TrialSpawner {
pub normal: ConfigSlot,
pub ominous: ConfigSlot,
pub target_cooldown_length: i32,
pub required_player_range: i32,
pub detected_players: Vec<Uuid>,
pub current_mobs: Vec<Uuid>,
pub cooldown_ends_at: i64,
pub next_mob_spawns_at: i64,
pub total_mobs_spawned: i32,
pub next_spawn_data: Option<SpawnData>,
pub ejecting_loot_table: Option<String>,
/// `isOminous`, set from the block on every server tick.
pub is_ominous: bool,
/// The server's display entity exists (`getOrCreateDisplayEntity`): it is
/// made once per loaded block entity and kept.
pub has_display: bool,
pub spin: f64,
pub o_spin: f64,
/// `getDispensingItems`, kept once rolled (not saved): the ominous items
/// and their weights (the rolled counts).
pub dispensing: Option<Vec<ItemStack>>,
}
/// `VaultConfig`.
#[derive(Clone, Debug, PartialEq)]
pub struct VaultConfig {
pub loot_table: String,
pub activation_range: f64,
pub deactivation_range: f64,
/// None when the config has no key (`ItemStack.EMPTY`): the vault never opens.
pub key_item: Option<ItemStack>,
pub override_loot_table_to_display: Option<String>,
}
/// `VaultBlockEntity`: its config, server data and shared data.
#[derive(Clone, Debug, PartialEq)]
pub struct Vault {
pub config: VaultConfig,
/// `VaultServerData`, oldest rewarded player first.
pub rewarded_players: Vec<Uuid>,
pub state_updating_resumes_at: i64,
/// Popped from the end.
pub items_to_eject: Vec<ItemStack>,
pub total_ejections_needed: i32,
pub last_insert_fail_timestamp: i64,
/// `VaultSharedData`.
pub display_item: Option<ItemStack>,
pub connected_players: Vec<Uuid>,
pub connected_particles_range: f64,
/// The shared data changed since the last tick sent it.
pub shared_dirty: bool,
/// `VaultClientData`: the display item's spin and the previous tick's, in degrees.
pub spin: f32,
pub o_spin: f32,
}
/// `BeehiveBlockEntity`: the bees inside and the flower one of them knew.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Beehive {
pub bees: Vec<Occupant>,
pub flower: Option<[i32; 3]>,
}
/// `BeehiveBlockEntity.Occupant`: a bee's tag (with its `id`), its time in
/// the hive and the time it stays at least.
#[derive(Clone, Debug, PartialEq)]
pub struct Occupant {
pub entity_data: Tag,
pub ticks_in_hive: i32,
pub min_ticks_in_hive: i32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CommandBlock {
pub command: String,
pub success_count: i32,
/// `CustomName` as saved (a text component).
pub custom_name: Option<Tag>,
pub track_output: bool,
pub last_output: Option<Text>,
pub update_last_execution: bool,
/// The game time of the last run, -1 for none.
pub last_execution: i64,
pub powered: bool,
pub auto: bool,
pub condition_met: bool,
}
-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:?}"
);
}
}
+16 -18
View File
@@ -1,7 +1,6 @@
//! `CommandBlockEntity` with its `BaseCommandBlock`: the command, the success
//! count comparators read, the output it keeps, and the powered, automatic
//! and condition flags `CommandBlock.tick` runs on.
pub use crate::chunk::block_entity::CommandBlock;
use crate::{
save::{number, text},
text::Text,
@@ -36,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 {
@@ -77,6 +59,22 @@ pub fn automatic_block(name: &str) -> bool {
name == "minecraft:chain_command_block"
}
#[derive(Clone, Debug, PartialEq)]
pub struct CommandBlock {
pub command: String,
pub success_count: i32,
/// `CustomName` as saved (a text component).
pub custom_name: Option<Tag>,
pub track_output: bool,
pub last_output: Option<Text>,
pub update_last_execution: bool,
/// The game time of the last run, -1 for none.
pub last_execution: i64,
pub powered: bool,
pub auto: bool,
pub condition_met: bool,
}
impl Default for CommandBlock {
fn default() -> Self {
Self {
+2 -13
View File
@@ -63,24 +63,13 @@ impl Confirm {
atlas: &HudAtlas,
cursor: (f32, f32),
labels: [&Text; 2],
) {
self.draw_active(batch, atlas, cursor, labels, true);
}
/// `draw` with the buttons greyed out while `setDelay` runs (`active` false).
pub fn draw_active(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
labels: [&Text; 2],
active: bool,
) {
for (line, x, y) in &self.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
for ((bx, by), label) in self.buttons.into_iter().zip(labels) {
let hover = active && ui::hovered(cursor, bx, by, 150.0, 20.0);
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, active, hover);
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, true, hover);
}
}
}
@@ -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)
);
}
}
+143 -42
View File
@@ -1,10 +1,6 @@
//! Container block entities: chests/barrels (27 slots) and the three furnace
//! kinds, ticking as `AbstractFurnaceBlockEntity.serverTick` with fuel times
//! from the data-driven `context_int_provider`/`context_float_provider` files.
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
};
use crate::{
crafting::Recipes,
items::{ItemStack, Items},
@@ -12,6 +8,12 @@ use crate::{
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path, sync::Arc};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FurnaceKind {
Furnace,
BlastFurnace,
Smoker,
}
impl FurnaceKind {
pub fn recipe_type(self) -> &'static str {
match self {
@@ -32,6 +34,21 @@ impl FurnaceKind {
}
}
}
#[derive(Clone, Debug)]
pub struct Furnace {
pub kind: FurnaceKind,
/// input, fuel, result
pub items: [Option<ItemStack>; 3],
pub lit_remaining: i32,
pub lit_total: i32,
pub cooking_timer: i32,
pub cooking_total: i32,
pub speed: f32,
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
/// made since a player last took from its output. The experience is owed per recipe
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
pub recipes_used: HashMap<String, u32>,
}
/// The key `recipes_used` keeps a recipe under: its full id, as vanilla's `ResourceKey<Recipe>`
/// writes it in `RecipesUsed`. Horizon's own recipe ids are file names without the namespace, so
/// keeping both forms would write a key twice, and vanilla refuses such a furnace
@@ -46,6 +63,53 @@ pub fn recipe_used_key(id: &str) -> String {
/// A hopper's slots: the only plain container with five.
pub const HOPPER_SLOTS: usize = 5;
#[derive(Clone, Debug)]
pub enum BlockEntity {
Chest(Vec<Option<ItemStack>>),
Furnace(Furnace),
Brewing(Box<crate::brewing::BrewingStand>),
/// Signs and hanging signs (`SignBlockEntity`).
Sign(crate::signs::SignData),
/// `JukeboxBlockEntity`: the record and its `JukeboxSongPlayer`.
Jukebox(Jukebox),
/// `BeaconBlockEntity`.
Beacon(Box<Beacon>),
/// `LecternBlockEntity`: the book and the open page.
Lectern(Lectern),
/// Sculk sensors, shriekers and catalysts (`crate::sculk`).
Sculk(Box<crate::sculk::SculkEntity>),
/// `CreakingHeartBlockEntity`: its protector and the hurt emitter.
CreakingHeart(Box<CreakingHeart>),
/// `BannerBlockEntity`: the pattern layers.
Banner(Banner),
/// `CrafterBlockEntity`.
Crafter(Crafter),
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
Spawner(Box<crate::mobs::spawner::Spawner>),
/// `TrialSpawnerBlockEntity` (`crate::mobs::trial_spawner`).
TrialSpawner(Box<crate::mobs::trial_spawner::TrialSpawner>),
/// `VaultBlockEntity` (`crate::vault`).
Vault(Box<crate::vault::Vault>),
/// `BeehiveBlockEntity` of beehives and bee nests (`crate::beehive`).
Beehive(Box<crate::beehive::Beehive>),
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
CommandBlock(Box<crate::command_block::CommandBlock>),
/// `CampfireBlockEntity` of campfires and soul campfires.
Campfire(Box<Campfire>),
/// `SkullBlockEntity` of every skull and head block.
Skull(Skull),
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
}
/// `CampfireBlockEntity`: four cooking slots, each with its
/// `cookingProgress` and `cookingTime` (the recipe's).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Campfire {
pub items: [Option<ItemStack>; 4],
pub progress: [i32; 4],
pub total: [i32; 4],
}
impl Campfire {
/// `placeFood`'s slot: the first empty one, given the item's
/// `campfire_cooking` time; it takes one item of `held`.
@@ -93,6 +157,17 @@ impl Campfire {
changed
}
}
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
/// a compound, or a string when it names a player only), the note block
/// sound and `custom_name`.
#[derive(Clone, Debug, Default)]
pub struct Skull {
pub profile: Option<horizon_world::nbt::Tag>,
pub note_block_sound: Option<String>,
pub custom_name: Option<horizon_world::nbt::Tag>,
/// What `profile` draws, read from it once rather than every frame.
pub skin: Option<SkullSkin>,
}
impl Skull {
pub fn new(
profile: Option<horizon_world::nbt::Tag>,
@@ -108,27 +183,25 @@ impl Skull {
}
}
}
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
/// with the `skins/<sha1>` sprite it downloads to, and the
/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
/// profile with no id).
#[derive(Clone, Debug)]
pub struct SkullSkin {
pub download: Option<(String, String)>,
pub fallback: &'static str,
}
impl SkullSkin {
pub fn of(profile: &horizon_world::nbt::Tag) -> Self {
let download = crate::skins::profile_skin_url_nbt(profile).and_then(|url| {
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
@@ -136,6 +209,18 @@ impl SkullSkin {
fn is_skull(name: &str) -> bool {
name.ends_with("_skull") || (name.ends_with("_head") && name != "minecraft:piston_head")
}
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
/// and the ticks left in the crafting animation.
#[derive(Clone, Debug, Default)]
pub struct Crafter {
pub items: [Option<ItemStack>; 9],
pub disabled: [bool; 9],
pub triggered: bool,
pub crafting_ticks: i32,
/// The block state's `triggered` when last ticked (not saved): the block
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
pub state_triggered: Option<bool>,
}
impl Crafter {
/// `slotCanBeDisabled`: only empty slots.
pub fn slot_can_be_disabled(&self, slot: usize) -> bool {
@@ -175,6 +260,48 @@ impl Crafter {
})
}
}
#[derive(Clone, Debug, Default)]
pub struct Banner {
pub layers: Vec<crate::banner::BannerLayer>,
pub name: Option<String>,
/// Components not consumed by BannerBlockEntity's implicit fields.
pub components: horizon_world::nbt::Compound,
}
/// `CreakingHeartBlockEntity` (the logic is in `game/creaking.rs`).
#[derive(Clone, Debug, Default)]
pub struct CreakingHeart {
/// The protector: its entity id once resolved, and its UUID (the saved form).
pub creaking: Option<(Option<u32>, [i32; 4])>,
pub ticks_existed: i64,
pub ticker: i32,
/// Ticks left of the hurt effect (`emitter`) and where it heads.
pub emitter: i32,
pub emitter_target: Option<[f64; 3]>,
pub output_signal: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Lectern {
pub book: Option<ItemStack>,
pub page: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Beacon {
pub levels: i32,
/// Effect ids (`minecraft:speed`).
pub primary: Option<String>,
pub secondary: Option<String>,
/// Beam sections (RGB colour, height) and the ones being rebuilt.
pub sections: Vec<(u32, i32)>,
pub checking: Vec<(u32, i32)>,
pub last_check_y: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Jukebox {
pub record: Option<ItemStack>,
/// `ticks_since_song_started`, while a song plays.
pub ticks: i64,
pub playing: bool,
}
impl BlockEntity {
pub fn for_block(name: &str) -> Option<Self> {
if let Some(hanging) = crate::signs::sign_kind(name) {
@@ -239,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);
}
@@ -257,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,
@@ -297,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> {
@@ -317,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(),
@@ -325,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>] {
@@ -339,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(_)
@@ -365,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(_)
@@ -615,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 {
File renamed without changes.
+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}")));
+16 -21
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 {
@@ -129,16 +129,15 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
_ => None,
}
}
/// `HealOrHarmMobEffect.applyInstantaneousEffect` (potions apply these directly;
/// the amount rounds to the nearest whole point).
/// `InstantenousMobEffect.applyInstantenousEffect` (potions apply these directly).
pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
match id {
"instant_health" => Some(EffectAction::Heal(
(scale * f64::from(4 << amp.min(30)) + 0.5) as i32 as f32,
(scale * f64::from(4 << amp.min(30))) as i32 as f32,
)),
"instant_damage" => Some(EffectAction::Hurt(
"minecraft:magic",
(scale * f64::from(6 << amp.min(30)) + 0.5) as i32 as f32,
(scale * f64::from(6 << amp.min(30))) as i32 as f32,
)),
"saturation" => Some(EffectAction::Eat(amp + 1, 1.0)),
_ => None,
@@ -180,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);
}
}
@@ -215,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
@@ -227,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();
@@ -250,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 {
@@ -307,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).
@@ -470,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);
@@ -491,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]);
}
@@ -334,14 +334,14 @@ impl Enchantments {
}
/// `Enchantment.getFullname` as plain text and whether it is a curse (red).
pub fn full_name(&self, id: &str, level: u32) -> (String, bool) {
let language = horizon_client_core::lang::current();
let language = crate::lang::current();
self.full_name_in(language.as_deref(), id, level)
}
/// `full_name` against `language`; `None` (no language loaded: a
/// dedicated server, the probes) keeps the English names loaded above.
pub fn full_name_in(
&self,
language: Option<&horizon_client_core::lang::Language>,
language: Option<&crate::lang::Language>,
id: &str,
level: u32,
) -> (String, bool) {
@@ -802,8 +802,8 @@ pub fn apply_value_effect(
}
}
#[cfg(any(test, feature = "test-support"))]
pub mod tests {
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use serde_json::json;
@@ -1136,7 +1136,7 @@ pub mod tests {
#[test]
fn names_follow_the_language() {
struct Mem(HashMap<String, Vec<u8>>);
impl horizon_client_core::lang::ResourceSource for Mem {
impl crate::lang::ResourceSource for Mem {
fn read(&self, path: &str) -> Option<Vec<u8>> {
self.0.get(path).cloned()
}
@@ -1147,7 +1147,7 @@ pub mod tests {
.as_bytes()
.to_vec(),
)]));
let ru = horizon_client_core::lang::Language::load(&[&ru], "ru_ru");
let ru = crate::lang::Language::load(&[&ru], "ru_ru");
let e = sample();
assert_eq!(
e.full_name_in(Some(&ru), "minecraft:sharpness", 5).0,
+5 -32
View File
@@ -19,8 +19,6 @@ pub struct ItemEntity {
pub id: u32,
pub stack: ItemStack,
pub thrower: Option<[i32; 4]>,
/// `ItemEntity.target`: the only player who may pick the item up.
pub target: Option<[i32; 4]>,
pub position: Vec3,
pub previous: Vec3,
pub velocity: Vec3,
@@ -357,10 +355,7 @@ pub fn merge_with_neighbours(
}
// ItemEntity.areMergable: both fit one stack of the same item and components.
let (this, other) = (&items[i].stack, &items[j].stack);
if items[i].target != items[j].target
|| this.count + other.count > max_stack(other)
|| !this.same_item_same_components(other)
{
if this.count + other.count > max_stack(other) || !this.same_item_same_components(other) {
continue;
}
let (to, from) = if other.count < this.count {
@@ -587,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
@@ -776,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,
@@ -812,7 +788,6 @@ impl ArmorStand {
custom_name: None,
name_visible: false,
last_hit: i64::MIN / 2,
pose: Self::DEFAULT_POSE,
}
}
pub fn environment_tick(
@@ -956,7 +931,6 @@ mod fire_environment_tests {
let make = |id: &str| ItemEntity {
id: 1,
thrower: None,
target: None,
stack: ItemStack {
item: std::sync::Arc::from(id),
count: 1,
@@ -1053,7 +1027,6 @@ mod merge_tests {
ItemEntity {
id: 0,
thrower: None,
target: None,
stack: ItemStack {
item: std::sync::Arc::from(id),
count,
@@ -189,7 +189,6 @@ mod tests {
extra: None,
},
thrower: None,
target: None,
position: at,
previous: at,
velocity: Vec3::ZERO,
+16 -79
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,11 +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>,
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
@location(12) @interpolate(flat) cull: u32,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
@@ -104,10 +92,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
// minecraft_mix_light: the vertex colour times the cardinal light only.
var c = color;
var back = c;
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5);
if abs(normal.w) >= 0.5 {
if normal.w != 0.0 {
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
}
@@ -118,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 {
@@ -186,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,35 +198,27 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
// clouds.fsh: alpha fades out towards the cloud fog end.
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y)));
}
if color.a < 0.1 || (in.cull != 0u && !front) { discard; }
if color.a < 0.1 { discard; }
// entity.fsh calculateFinalColor: the overlay (hurt red, TNT white) over the
// 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;
}
@@ -286,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])];
+14 -207
View File
@@ -13,6 +13,7 @@ use crate::{
doubles, float, floats, item_from_nbt, item_to_nbt, number, put, read_doubles, text,
uuid_from_tag,
},
text::Text,
};
use horizon_core::geometry::Vec3;
use horizon_world::nbt::{Compound, Tag};
@@ -186,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)),
@@ -338,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" {
@@ -502,38 +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))),
);
if let Some(owner) = m.extra.owner {
map.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
} else {
map.remove(&"Owner".into());
}
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
}
"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();
@@ -557,10 +490,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
"memories",
Tag::compound([(
"minecraft:liked_player",
Tag::compound([(
"value",
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
)]),
Tag::compound([("value", Tag::IntArray(vec![0, 0, 0, 1]))]),
)]),
)]),
);
@@ -597,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));
@@ -614,9 +540,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.insert("life_ticks".into(), Tag::Int(m.extra.egg_time));
}
}
"minecraft:endermite" => {
map.insert("Lifetime".into(), Tag::Int(m.extra.counter));
}
"minecraft:shulker" => {
map.insert("AttachFace".into(), Tag::Byte(m.extra.variant3 as i8));
map.insert("Peek".into(), Tag::Byte(m.extra.counter as i8));
@@ -723,17 +646,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
// A wolf's armor keeps the durability it lost.
if slot == "body"
&& m.species() == crate::mobs::Species::Wolf
&& let Some(id) = item
{
let mut stack = crate::items::ItemStack::new(id, 1);
stack.damage = m.extra.body_damage.max(0) as u32;
stack.extra = m.extra.body_extra.clone();
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
match item {
Some(id) => {
let keep = matches!(
@@ -873,7 +785,14 @@ fn custom_name(tag: &Tag) -> Option<String> {
_ => None,
}
}
pub use horizon_client_core::component::name_text;
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
/// else is a component (`ComponentSerialization`).
pub fn name_text(tag: &Tag) -> Text {
match tag {
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
}
}
/// Mob.finalizeSpawn's follow range bonus in the saved attributes.
pub(crate) fn spawn_bonus(tag: &Tag) -> Option<f64> {
let Some(Tag::List(attrs)) = tag.get("attributes") else {
@@ -981,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 {
@@ -1121,16 +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::Parrot {
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
m.extra.tame = m.extra.owner.is_some();
m.extra.sitting = flag(tag, "Sitting");
}
if m.species() == crate::mobs::Species::Wolf {
let health = m.health;
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
@@ -1138,12 +1028,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
crate::mobs::tame::set_tame(&mut m, tame);
m.health = health;
m.extra.sitting = flag(tag, "Sitting");
let armor = tag
.get("equipment")
.and_then(|e| e.get("body"))
.and_then(crate::save::item_from_nbt);
m.extra.body_damage = armor.as_ref().map_or(0, |s| s.damage as i32);
m.extra.body_extra = armor.and_then(|s| s.extra);
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
}
@@ -1164,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 {
@@ -1174,14 +1057,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.ints[1] = number(first.get("count")).unwrap_or(1) as i32;
}
m.extra.egg_time = number(tag.get("DuplicationCooldown")).unwrap_or(0) as i32;
let liked = tag
m.extra.tame = tag
.get("Brain")
.and_then(|b| b.get("memories"))
.and_then(|b| b.get("minecraft:liked_player"));
m.extra.tame = liked.is_some();
m.extra.owner = liked
.and_then(|l| l.get("value"))
.and_then(|v| crate::save::uuid_from_tag(Some(v)));
.and_then(|b| b.get("minecraft:liked_player"))
.is_some();
}
if crate::mobs::camel::is_camel(m.species()) {
let sitting = matches!(tag.get("LastPoseTick"), Some(Tag::Long(v)) if *v < 0);
@@ -1248,20 +1128,11 @@ 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;
m.extra.anim_ticks = number(tag.get("RoarTick")).unwrap_or(0) as i32;
}
if m.species() == crate::mobs::Species::Endermite {
m.extra.counter = number(tag.get("Lifetime")).unwrap_or(0) as i32;
}
if m.species() == crate::mobs::Species::Vex {
if let Some(Tag::IntArray(v)) = tag.get("bound_pos")
&& v.len() == 3
@@ -1352,11 +1223,6 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
} else if let Tag::Compound(map) = &mut tag {
map.remove(&"Thrower".into());
}
if let Some(target) = e.target {
put(&mut tag, "Owner", Tag::IntArray(target.to_vec()));
} else if let Tag::Compound(map) = &mut tag {
map.remove(&"Owner".into());
}
tag
}
pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemEntity> {
@@ -1368,7 +1234,6 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
id,
stack,
thrower: uuid_from_tag(tag.get("Thrower")),
target: uuid_from_tag(tag.get("Owner")),
position,
previous: position,
velocity: motion(tag),
@@ -1887,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 [
@@ -1935,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`...):
@@ -2278,7 +2121,6 @@ mod tests {
health: 2,
fire_ticks: 73,
thrower: Some([5, 6, 7, 8]),
target: None,
id: 7,
stack: ItemStack {
item: Arc::from("minecraft:diamond_pickaxe"),
@@ -2387,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;
+12 -18
View File
@@ -26,13 +26,6 @@ pub enum ItemPose {
Bow {
ticks_used: f32,
},
/// A spear being charged (`ItemUseAnimation.SPEAR`): the time held and
/// the ticks since its last hit's feedback.
Spear {
kinetic: crate::spear::Kinetic,
time_held: f32,
ticks_since_hit: f32,
},
Crossbow {
ticks_used: Option<f32>,
charge_duration: f32,
@@ -76,16 +69,6 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
if matches!(pose, ItemPose::Using) {
return m;
}
// The spear's own place in the hand, without the equip height.
if let ItemPose::Spear {
kinetic,
time_held,
ticks_since_hit,
} = pose
{
return Mat4::from_translation(Vec3::new(invert * 0.56, -0.52, -0.72))
* crate::spear::first_person_use(&kinetic, ticks_since_hit, time_held, !left);
}
if matches!(pose, ItemPose::SpinAttack) {
return m
* Mat4::from_translation(Vec3::new(invert * -0.4, 0.8, 0.3))
@@ -443,7 +426,18 @@ impl UseResult {
matches!(self, Self::Success { swing: true }) && before == after
}
}
pub use horizon_items::use_animation::consumable_animation;
pub fn consumable_animation(v: &serde_json::Value) -> horizon_world::consumable::UseAnimation {
use horizon_world::consumable::UseAnimation;
match v
.get("animation")
.and_then(serde_json::Value::as_str)
.unwrap_or("eat")
{
"eat" => UseAnimation::Eat,
"drink" => UseAnimation::Drink,
_ => UseAnimation::None,
}
}
/// What the click handling reads and does in the world.
pub trait HandWorld {
fn hit(&mut self) -> Hit;
+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 -346
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,136 +681,6 @@ impl Game<'_> {
adv::conditions(&c["player"], &pctx, w) && adv::conditions(&c[&key], &ectx, w)
});
}
/// `KilledByArrowTrigger`: what the arrow killed and the weapon it came from.
pub(super) fn trigger_killed_by_arrow(
&mut self,
victims: &[EntityView],
weapon: Option<&ItemStack>,
) {
let me = self.player_entity();
let victims = victims.to_vec();
let weapon = weapon.map(item_view);
self.trigger("minecraft:killed_by_arrow", move |c, w| {
adv::killed_by_arrow(c, &me, &victims, weapon.as_ref(), 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();
@@ -876,101 +708,6 @@ impl Game<'_> {
let it = item_view(stack);
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
}
/// `ConstructBeaconTrigger`: the pyramid levels of the new beacon.
pub(super) fn trigger_construct_beacon(&mut self, levels: i32) {
let me = self.player_entity();
self.trigger("minecraft:construct_beacon", 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::range_i(&c["level"], i64::from(levels))
});
}
/// `EnchantedItemTrigger`: the enchanted item and the levels it cost.
pub(super) fn trigger_enchanted_item(&mut self, stack: &ItemStack, levels: i32) {
let me = self.player_entity();
let it = item_view(stack);
self.trigger("minecraft:enchanted_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)
&& adv::range_i(&c["levels"], i64::from(levels))
});
}
/// `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 {
@@ -1069,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),
@@ -1177,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
@@ -1363,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,
+5 -41
View File
@@ -8,6 +8,7 @@ use horizon_core::random::RandomSource;
impl Game<'_> {
pub(super) fn tick_allays(&mut self) {
let griefing = self.ui.rules.bool("mob_griefing");
let player = self.player.as_ref().map(|p| p.position);
// The playing jukeboxes, found at the first allay: no allay starts
// or stops one, and the block entities run into the thousands.
let mut jukeboxes: Option<Vec<(i32, i32, i32)>> = None;
@@ -57,7 +58,7 @@ impl Game<'_> {
};
let Some((j, _)) = wanted else {
self.mobs[i].extra.point = None;
self.allay_deliver(i);
self.allay_deliver(i, player);
continue;
};
let e = &self.dropped[j];
@@ -89,46 +90,14 @@ impl Game<'_> {
}
}
}
/// `AllayAi.getLikedPlayer`: the player the allay was given an item by,
/// found by UUID among the host and the guests of the level (the window
/// is a guest on the server thread), when they are not a spectator and
/// within 64 blocks.
fn allay_liked_player(&self, i: usize) -> Option<horizon_core::geometry::Vec3> {
let m = &self.mobs[i];
if !m.extra.tame {
return None;
}
// An allay saved before its player was kept belongs to the host.
let uuid = m
.extra
.owner
.filter(|u| *u != [0, 0, 0, 1])
.or(self.level.player_uuid)
.unwrap_or([0, 0, 0, 1]);
let host = self
.player_view()
.filter(|p| !p.spectator && self.level.player_uuid.unwrap_or([0, 0, 0, 1]) == uuid)
.map(|p| p.position);
let found = host.or_else(|| {
self.lan_present()
.into_iter()
.find(|p| p.uuid == uuid && !p.spectator)
.map(|p| {
horizon_core::geometry::Vec3::new(p.position[0], p.position[1], p.position[2])
})
});
found.filter(|p| p.distance_squared(m.position) < 64.0 * 64.0)
}
/// `GoAndGiveItemsToTarget` / `AllayAi.throwItem`: within 3 blocks of
/// the deposit, one item a tick, each restarting the three-second pause
/// before it picks anything up again.
fn allay_deliver(&mut self, i: usize) {
fn allay_deliver(&mut self, i: usize, player: Option<horizon_core::geometry::Vec3>) {
let m = &self.mobs[i];
if m.extra.ints[1] <= 0 {
return;
}
let player = self.allay_liked_player(i);
let m = &self.mobs[i];
let target = match m.extra.home.filter(|_| m.extra.ints[2] > 0) {
Some(h) => horizon_core::geometry::Vec3::new(
f64::from(h[0]) + 0.5,
@@ -162,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,
@@ -192,20 +160,17 @@ 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();
return true;
}
let acting = self.acting_uuid();
let holds = m.extra.main_hand.is_some();
if !holds && !item.is_empty() {
let m = &mut self.mobs[index];
m.extra.main_hand = Some(item.into());
m.extra.tame = true;
// `Allay.mobInteract` remembers LIKED_PLAYER by UUID.
m.extra.owner = Some(acting);
self.audio
.play("minecraft:entity.allay.item_given", Some(at), 2.0, 1.0);
self.consume_held();
@@ -220,7 +185,6 @@ impl Game<'_> {
.unwrap_or_else(|| "minecraft:air".into());
let carried = std::mem::take(&mut m.extra.ints[1]);
m.extra.tame = false;
m.extra.owner = None;
self.audio
.play("minecraft:entity.allay.item_taken", Some(at), 2.0, 1.0);
if carried > 0 {
+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
-24
View File
@@ -164,35 +164,11 @@ impl Game<'_> {
let (was, is) = (previous > 0, b.levels > 0);
if !was && is {
self.beacon_sound([x, y, z], "minecraft:block.beacon.activate");
let levels = b.levels;
self.for_each_player(|g| g.beacon_constructed([x, y, z], levels));
} else if was && !is {
self.beacon_sound([x, y, z], "minecraft:block.beacon.deactivate");
}
}
}
/// `BeaconBlockEntity.tick` after activating: `CONSTRUCT_BEACON` for a
/// player in the box `AABB(x, y, z, x, y - 4, z).inflate(10, 5, 10)`.
fn beacon_constructed(&mut self, p: [i32; 3], levels: i32) {
let Some(bb) = self
.player
.as_ref()
.map(|pl| pl.dimensions.bounds(pl.position))
else {
return;
};
let (lo, hi) = (bb.min_position(), bb.max_position());
let [x, y, z] = p.map(f64::from);
if lo.x < x + 10.0
&& hi.x > x - 10.0
&& lo.y < y + 5.0
&& hi.y > y - 9.0
&& lo.z < z + 10.0
&& hi.z > z - 10.0
{
self.trigger_construct_beacon(levels);
}
}
pub(super) fn beacon_sound(&mut self, p: [i32; 3], sound: &str) {
let at = [
f64::from(p[0]) + 0.5,
+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)
}
}
+2 -42
View File
@@ -4,13 +4,9 @@
//! placed item gives its block entity.
use super::Game;
use horizon_client::{
Result, block_entity_item, block_entity_nbt, containers::BlockEntity, item_data,
items::ItemStack, save,
};
use horizon_world::{
nbt::{Compound, Tag},
registry::BlockStateId,
block_entity_item, block_entity_nbt, containers::BlockEntity, item_data, items::ItemStack, save,
};
use horizon_world::nbt::{Compound, Tag};
/// The component patch of `stack`.
fn components(stack: &ItemStack) -> Compound {
@@ -81,42 +77,6 @@ impl Game<'_> {
Some(stack)
}
/// `BlockItem.updateBlockStateFromTag`: the properties of the item's
/// `block_state` component (a hive's honey level, a statue's pose) set on
/// the block just placed at `pos`. Returns the state it has now.
pub(super) fn apply_item_block_state(
&mut self,
pos: [i32; 3],
state: BlockStateId,
stack: &ItemStack,
) -> Result<BlockStateId> {
let Some(Tag::Compound(saved)) =
stack.data().and_then(|d| d.other("minecraft:block_state"))
else {
return Ok(state);
};
let Some(current) = self.pack.assets.registry.state(state) else {
return Ok(state);
};
// Only the properties the block has.
let props: Vec<(String, String)> = saved
.iter()
.filter_map(|(k, v)| match v {
Tag::String(v) => Some((k.to_string_lossy(), v.to_string_lossy())),
_ => None,
})
.filter(|(k, v)| current.properties.get(k).is_some_and(|old| old != v))
.collect();
let props: Vec<(&str, &str)> = props.iter().map(|(k, v)| (&**k, &**v)).collect();
match self.with_props(state, &props) {
Some(new) if !props.is_empty() && new != state => {
self.level_set(pos, new)?;
Ok(new)
}
_ => Ok(state),
}
}
/// `BlockItem.updateCustomBlockEntityTag` and `updateBlockEntityComponents`
/// for the block entity just placed at `pos` from `stack`; `game_master`:
/// the placer can use game master blocks (a dispenser can't).
+21 -90
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);
@@ -1144,12 +1080,7 @@ impl Game<'_> {
BLACK,
);
}
// BookSignScreen: `owner.getName()`, the account's name on a server.
let owner = self
.remote
.as_ref()
.map_or(super::chat::PLAYER_NAME, |r| r.profile_name.as_str());
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", owner);
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", super::chat::PLAYER_NAME);
let tw = HudBatch::text_width(atlas, &by);
batch.text(
atlas,
@@ -1267,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,
-246
View File
@@ -1,246 +0,0 @@
//! Brushing suspicious sand and gravel: `BrushItem.onUseTick` and
//! `BrushableBlockEntity`. The block's loot table stays in its saved block
//! entity (like a decorated pot's) until the first counted stroke rolls it.
use super::{Game, Hit, decorated_pot, spill, view_vector};
use horizon_client::{
particles::{ParticleOptions, ParticleType},
save,
};
use horizon_core::random::RandomSource;
use std::collections::HashMap;
/// `BrushItem.USE_DURATION`.
const USE_DURATION: u32 = 200;
/// A brushable block's counters. Vanilla never saves them: the block's
/// `dusted` state is all that is left after a reload.
#[derive(Default)]
pub(super) struct BrushState {
count: i32,
resets_at: i64,
cooldown_ends_at: i64,
hit_face: Option<[i32; 3]>,
}
/// `BrushableBlockEntity.getCompletionState`: the `dusted` property.
fn completion_state(count: i32) -> i32 {
match count {
0 => 0,
1..=2 => 1,
3..=5 => 2,
_ => 3,
}
}
pub(super) type Brushing = HashMap<[i32; 3], BrushState>;
impl Game<'_> {
/// `BrushItem.onUseTick` for a brush used `ticks` ticks so far, the
/// hand of `slot` acting.
pub(super) fn tick_brush(&mut self, slot: usize, ticks: u32) {
// No block under the crosshair: releaseUsingItem.
let Some(hit) = self.pick() else {
self.using = None;
return;
};
let elapsed = ticks + 1;
// The last tick before the backswing.
if elapsed % 10 == 5 {
self.brush_stroke(&hit, slot);
}
// The use ends after 200 ticks; the held button starts it again.
self.using = (elapsed < USE_DURATION).then_some((slot, elapsed));
}
/// One stroke on the block aimed at: dust (this window only), the brush
/// sound, and the block entity's count. The brush wears when the block
/// changed state.
fn brush_stroke(&mut self, hit: &Hit, slot: usize) {
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let name = self.res.facts().get(state).name.clone();
let (sound, brushable) = match &*name {
"minecraft:suspicious_sand" => ("minecraft:item.brush.brushing.sand", true),
"minecraft:suspicious_gravel" => ("minecraft:item.brush.brushing.gravel", true),
_ => ("minecraft:item.brush.brushing.generic", false),
};
if self.lan_acting.is_none() {
self.brush_dust(hit, slot == super::shield::OFFHAND_SLOT);
}
let at = hit.pos.map(|v| f64::from(v) + 0.5);
self.audio.play(sound, Some(at), 1.0, 1.0);
if brushable && self.brush_block(hit.pos, hit.face) {
self.damage_held(1);
}
}
/// `BrushItem.spawnDustParticles`.
pub(super) fn brush_dust(&mut self, hit: &Hit, off_hand: bool) {
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let Some((sprite, tint)) = self.particle_sprite(state, hit.pos) else {
return;
};
let options = ParticleOptions::block(ParticleType::Block, sprite, tint);
// The arm that brushes: the main arm, or the other one for the off hand.
let flip = if off_hand == self.settings.main_hand_left {
1.0
} else {
-1.0
};
let view = view_vector(self.yaw, self.pitch);
// DustParticlesDelta.fromDirection.
let (dx, dz) = match hit.face {
[0, _, 0] => (view[2], -view[0]),
[0, 0, -1] => (1.0, -0.1),
[0, 0, 1] => (-1.0, 0.1),
[-1, 0, 0] => (-0.1, -1.0),
_ => (0.1, 1.0),
};
let count = 7 + self.particles.level_random().next_int_bound(5).unwrap_or(0);
let x = hit.point[0] - if hit.face == [-1, 0, 0] { 1.0e-6 } else { 0.0 };
let z = hit.point[2] - if hit.face == [0, 0, -1] { 1.0e-6 } else { 0.0 };
for _ in 0..count {
let random = self.particles.level_random();
let v = [
dx * flip * 3.0 * random.next_double(),
0.0,
dz * flip * 3.0 * random.next_double(),
];
self.particles.add(&options, [x, hit.point[1], z], v);
}
}
/// `BrushableBlockEntity.brush`: whether the block changed (the brush wears).
fn brush_block(&mut self, pos: [i32; 3], face: [i32; 3]) -> bool {
let time = self.world.game_time;
let mut brush = self.brushing.remove(&pos).unwrap_or_default();
brush.hit_face.get_or_insert(face);
brush.resets_at = time + 40;
if time < brush.cooldown_ends_at {
self.brushing.insert(pos, brush);
return false;
}
brush.cooldown_ends_at = time + 10;
self.unpack_brush_loot(pos);
let before = completion_state(brush.count);
brush.count += 1;
if brush.count >= 10 {
self.brushing_completed(pos, brush.hit_face);
return true;
}
self.schedule_brushable_tick(pos);
let after = completion_state(brush.count);
self.brushing.insert(pos, brush);
if before != after {
self.set_dusted(pos, after);
}
false
}
/// `BrushableBlockEntity.unpackLootTable`: the table's roll becomes the
/// block entity's item.
fn unpack_brush_loot(&mut self, pos: [i32; 3]) {
let Some(mut tag) = spill::saved_entity(&self.world, pos) else {
return;
};
if tag.get("LootTable").is_none() {
return;
}
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
decorated_pot::unpack_loot(
&mut tag,
loot.as_deref(),
&mut self.world.random,
&mut self.rand_sequences,
self.level.seed,
);
spill::put_saved_entity(&mut self.world, pos, tag);
}
/// `BrushableBlockEntity.brushingCompleted`: the item drops out of the
/// brushed face and the block turns into its sand or gravel.
fn brushing_completed(&mut self, pos: [i32; 3], face: Option<[i32; 3]>) {
let state = self.world.block(pos[0], pos[1], pos[2]);
let turns_into = match &*self.res.facts().get(state).name {
"minecraft:suspicious_sand" => "minecraft:sand",
"minecraft:suspicious_gravel" => "minecraft:gravel",
_ => "minecraft:air",
};
// dropContent: the item at the middle of the block beside that face.
let item = spill::saved_entity(&self.world, pos)
.and_then(|tag| tag.get("item").and_then(save::item_from_nbt));
if let Some(mut item) = item {
let face = face.unwrap_or([0, 1, 0]);
let take = 10 + self.world.random.next_int_bound(21).unwrap_or(0);
item.count = item.count.min(take as u32);
let at = [
f64::from(pos[0] + face[0]) + 0.5,
f64::from(pos[1] + face[1]) + 0.625,
f64::from(pos[2] + face[2]) + 0.5,
];
self.spawn_item(item, at, [0.0; 3], 0);
}
// Level event 3008 for every player, then the block change.
self.level_event_at(3008, pos, state.0 as i32);
if let Some(base) = self.pack.assets.registry.default_state(turns_into) {
let _ = self.level_set(pos, base);
}
}
fn set_dusted(&mut self, pos: [i32; 3], dusted: i32) {
let registry = self.pack.assets.registry;
let Some(mut state) = registry
.state(self.world.block(pos[0], pos[1], pos[2]))
.cloned()
else {
return;
};
state.properties.insert("dusted".into(), dusted.to_string());
if let Ok(state) = registry.resolve(&state) {
let _ = self.level_set(pos, state);
}
}
fn schedule_brushable_tick(&mut self, pos: [i32; 3]) {
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.schedule_tick(level, ticks, at, 2));
}
/// `BrushableBlockEntity.checkReset`, from `BrushableBlock.tick`: a block
/// left alone loses two counts, again every four ticks.
pub(super) fn brushable_tick(&mut self, pos: [i32; 3]) {
let time = self.world.game_time;
let Some(mut brush) = self.brushing.remove(&pos) else {
return;
};
let before = completion_state(brush.count);
if brush.count != 0 && time >= brush.resets_at {
brush.count = (brush.count - 2).max(0);
brush.resets_at = time + 4;
}
let after = completion_state(brush.count);
if brush.count != 0 {
self.brushing.insert(pos, brush);
self.schedule_brushable_tick(pos);
}
if before != after {
self.set_dusted(pos, after);
}
}
/// Level event 3008 (`LevelEventHandler`): the finished block's sound
/// and break particles.
pub(super) fn brush_complete_event(&mut self, pos: [i32; 3], data: i32) {
let state = horizon_world::registry::BlockStateId(data as u32);
let sound = match &*self.res.facts().get(state).name {
"minecraft:suspicious_sand" => "minecraft:item.brush.brushing.sand.complete",
"minecraft:suspicious_gravel" => "minecraft:item.brush.brushing.gravel.complete",
_ => "",
};
if !sound.is_empty() {
let at = pos.map(|v| f64::from(v) + 0.5);
// Each client plays it from the event itself.
self.audio.play_unshared(sound, Some(at), 1.0, 1.0);
}
self.destroy_particles(pos, state);
}
}
@@ -2,60 +2,8 @@
//! shovels) put on a lit campfire puts it out.
use super::Game;
use horizon_client::{Result, animate_tick::make_campfire_smoke, items::ItemStack};
use horizon_core::position::BlockPos;
use horizon_world::chunk::BlockState;
use winit::keyboard::KeyCode;
impl Game<'_> {
/// The lit campfire at `pos` that `held` puts out, when the item is in
/// `#minecraft:douses_campfires` (`CampfireBlock.useItemOn`).
fn campfire_to_douse(&self, pos: [i32; 3], held: Option<&ItemStack>) -> Option<BlockState> {
held.filter(|s| {
self.res
.items()
.item_tags
.get("minecraft:douses_campfires")
.is_some_and(|members| members.contains(&*s.item))
})?;
self.pack
.assets
.registry
.state(self.world.block(pos[0], pos[1], pos[2]))
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
.cloned()
}
/// `CampfireBlock.useItemOn` as this client runs it before the server's
/// answer (`MultiPlayerGameMode.useItemOn`): the twenty smoke puffs of
/// `CampfireBlock.douse` and the campfire gone out, kept as predicted
/// under `seq`. The level event's sound comes from the server. True when
/// it doused (SUCCESS), which ends the click for both hands.
pub(super) fn predict_campfire_douse(
&mut self,
pos: [i32; 3],
held: Option<&ItemStack>,
seq: i32,
) -> bool {
// Player.isSecondaryUseActive: a held item means both hands count.
if self.keys.contains(&KeyCode::ShiftLeft) {
return false;
}
let Some(mut state) = self.campfire_to_douse(pos, held) else {
return false;
};
let signal = state.properties.get("signal_fire").map(String::as_str) == Some("true");
for _ in 0..20 {
make_campfire_smoke(&mut self.particles, pos, signal, true);
}
state.properties.insert("lit".into(), "false".into());
if let Ok(unlit) = self.pack.assets.registry.resolve(&state) {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.predict_blocks(&vec![(at, unlit)], seq);
}
true
}
/// `CampfireBlock.useItemOn` with `held` in the hand that acts, the block
/// at `pos` clicked: true when it doused a lit campfire (SUCCESS).
pub(super) fn use_campfire_douse(
@@ -63,11 +11,24 @@ impl Game<'_> {
pos: [i32; 3],
held: Option<&ItemStack>,
) -> Result<bool> {
let (Some(used), Some(mut state)) = (held.cloned(), self.campfire_to_douse(pos, held))
else {
let Some(tool) = held.filter(|s| {
self.res
.items()
.item_tags
.get("minecraft:douses_campfires")
.is_some_and(|members| members.contains(&*s.item))
}) else {
return Ok(false);
};
let registry = self.pack.assets.registry;
let Some(mut state) = registry
.state(self.world.block(pos[0], pos[1], pos[2]))
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
.cloned()
else {
return Ok(false);
};
// Level event 1009 (SOUND_EXTINGUISH_FIRE) goes to every player.
self.level_event_at(1009, pos, 0);
// CampfireBlock.douse: the acting client's own smoke (twenty puffs),
@@ -83,6 +44,7 @@ impl Game<'_> {
if let Ok(unlit) = registry.resolve(&state) {
self.level_set(pos, unlit)?;
}
let used = tool.clone();
self.damage_held(1);
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, &used);
@@ -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;
}
}
}
+71 -236
View File
@@ -81,8 +81,6 @@ pub struct UiState {
pub chat_screen: Option<ChatScreen>,
/// When the chat screen opened (its input took the focus: the cursor blink).
pub chat_opened: Option<std::time::Instant>,
/// The chat screen is `InBedChatScreen`: the sleeper's, with a Leave Bed button.
pub in_bed_chat: bool,
pub registries: Registries,
pub rules: GameRules,
pub clock: WorldClock,
@@ -104,25 +102,10 @@ pub struct UiState {
/// Death screen cause and when the player died (buttons wait 20 ticks).
pub death_text: Option<Text>,
pub died_at: Option<std::time::Instant>,
/// `DeathScreen.TitleConfirmScreen` and when it opened (its buttons wait
/// 20 ticks, `setDelay`).
pub death_confirm: Option<std::time::Instant>,
/// `LivingEntity.deathTime` of the window's player: ticks since it died.
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 {
@@ -457,12 +440,6 @@ impl Game<'_> {
self.capture(false);
}
pub(super) fn close_chat(&mut self) {
// InBedChatScreen.onClose: leaving the screen means getting up; the
// screen goes once the player has woken (`tick_in_bed_chat`).
if self.ui.in_bed_chat && self.sleeping.is_some() {
self.wake_up();
return;
}
self.ui.chat_screen = None;
self.ui.chat.reset_scroll();
if self.screen == Screen::Chat {
@@ -470,35 +447,6 @@ impl Game<'_> {
self.capture(true);
}
}
/// `Gui.tick`: a sleeper gets `InBedChatScreen`; when the player has
/// woken (`onPlayerWokeUp`) it closes, or turns into the plain chat
/// screen if something was typed.
pub(super) fn tick_in_bed_chat(&mut self) {
if self.screen != Screen::Chat {
self.ui.in_bed_chat = false;
}
if self.ui.in_bed_chat {
if self.sleeping.is_none() {
self.ui.in_bed_chat = false;
let text = self
.ui
.chat_screen
.as_ref()
.map_or_else(String::new, |s| s.value().to_owned());
self.close_chat();
if !text.is_empty() {
self.open_chat(&text);
}
}
} else if self.sleeping.is_some() && self.screen == Screen::Playing {
self.open_chat("");
self.ui.in_bed_chat = self.screen == Screen::Chat;
}
}
/// The Leave Bed button of `InBedChatScreen`: its bounds.
fn leave_bed_button(width: f32, height: f32) -> (i32, i32, i32, i32) {
(width as i32 / 2 - 100, height as i32 - 40, 200, 20)
}
/// `Minecraft.computeChatAbilities` from the Chat option.
pub(super) fn chat_abilities(&self) -> ChatAbilities {
ChatAbilities::new(ChatVisibility::from_option(self.settings.chat_visibility))
@@ -611,15 +559,7 @@ impl Game<'_> {
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
ChatAction::Close => self.close_chat(),
ChatAction::Submit(message) => {
// InBedChatScreen sends without closing (`closeOnSubmit` is
// false): the box empties and the screen stays.
if self.ui.in_bed_chat && self.sleeping.is_some() {
screen.suggest_command(&m, &c, "");
self.ui.chat.reset_scroll();
self.ui.chat_screen = Some(screen);
} else {
self.close_chat();
}
self.close_chat();
if !message.is_empty() {
self.handle_chat_input(&message);
}
@@ -721,8 +661,7 @@ impl Game<'_> {
{
return;
}
// ChatScreen.mouseScrolled: `hasShiftDown`, either Shift key.
let shift = self.has_shift_down();
let shift = self.keys.contains(&KeyCode::ShiftLeft);
let amount = amount.clamp(-1.0, 1.0) * if shift { 1.0 } else { 7.0 };
self.ui.chat.scroll_by(amount as i32, true);
}
@@ -736,16 +675,6 @@ impl Game<'_> {
dispatcher: chat_tree(&self.remote, &self.mods),
registries: &self.ui.registries,
};
if self.ui.in_bed_chat && self.sleeping.is_some() {
let width = self.renderer.as_ref().map_or(1280.0, |r| {
HudBatch::new([r.config.width, r.config.height]).width
});
let (x, y, w, h) = Self::leave_bed_button(width, height);
if horizon_client::screen_draw::inside((cursor.0 as i32, cursor.1 as i32), x, y, w, h) {
self.wake_up();
return;
}
}
if let Some(screen) = self.ui.chat_screen.as_mut() {
if screen.click(&m, &c, cursor.0 as f64, cursor.1 as f64) {
return;
@@ -753,25 +682,12 @@ impl Game<'_> {
} else {
return;
}
let Some(style) = self.ui.chat.style_at(
match self.ui.chat.click_at(
&self.gui.atlas.font,
height as i32,
cursor.0 as f64,
cursor.1 as f64,
) else {
return;
};
// ChatScreen.handleComponentClicked: with Shift held only an
// insertion acts, else only the click event.
if self.has_shift_down() {
if let Some(insertion) = style.insertion
&& let Some(screen) = self.ui.chat_screen.as_mut()
{
screen.type_text(&m, &c, &insertion);
}
return;
}
match style.click {
) {
Some(ClickEvent::CopyToClipboard(value)) => {
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&value);
@@ -779,95 +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);
}
}
Some(ClickEvent::RunCommand(command)) => self.click_command(&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);
}
}
/// `Screen.clickCommandAction`: run a clicked command as the player
/// without adding it to the chat history. A client mod's command goes
/// first, as `ClientPacketListener.sendUnattendedCommand` lets client
/// commands take it. (Vanilla asks first when the command fails to
/// parse; here the server answers it.)
fn click_command(&mut self, command: &str) {
let command = command.strip_prefix('/').unwrap_or(command);
if self.mod_command(command) {
return;
}
if self.remote.is_some() {
self.send_chat(&format!("/{command}"));
} else if !self.local_server_mod_command(command) {
self.run_command(command);
None => {}
}
}
/// Chat lines over the HUD (focused while the chat screen is open).
@@ -904,59 +737,17 @@ impl Game<'_> {
let draws = screen.draw(&m, mouse, blink, background);
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &draws);
self.ui.chat_screen = Some(screen);
if self.ui.in_bed_chat && self.sleeping.is_some() {
let (x, y, w, h) = Self::leave_bed_button(batch.width, batch.height);
let label = Text::literal(horizon_client::lang::get_or(
"multiplayer.stopSleeping",
"Leave Bed",
));
let mut button = Vec::new();
horizon_client::screen_draw::button(
&mut button,
&m,
&label,
x,
y,
w,
h,
true,
horizon_client::screen_draw::inside(mouse, x, y, w, h),
);
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &button);
}
// The hovered message part's tooltip (`renderComponentHoverEffect`),
// or the text of the message tag under the cursor.
let height = batch.height as i32;
let (cx, cy) = (f64::from(self.cursor.0), f64::from(self.cursor.1));
let wrap = (batch.width as i32 / 2).max(200);
if let Some(style) = self.ui.chat.style_at(&self.gui.atlas.font, height, cx, cy) {
if let Some(hover) = style.hover {
let lines = self.wrapped_rows(&hover, wrap);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
} else if let Some(stack) = style
.hover_item
.as_deref()
.and_then(horizon_client::item_data::stack_from_hover)
.filter(|s| self.res.items().get(&s.item).is_some())
{
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
}
} else if self.ui.chat.indicator_at(height, cx, cy) {
// GuiMessageTag.systemSinglePlayer's text, split at 210.
let tag = Text::translate("chat.tag.system_single_player", &[]);
let lines = self.wrapped_rows(&tag, 210);
// The hovered message part's tooltip, one row per line of its text.
if let Some(hover) = self.ui.chat.hover_at(
&self.gui.atlas.font,
batch.height as i32,
f64::from(self.cursor.0),
f64::from(self.cursor.1),
) {
let lines = hover_rows(&hover);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
}
/// `Font.split`: a tooltip text wrapped at `width`, one white row per line.
fn wrapped_rows(&self, text: &Text, width: i32) -> Vec<(Text, u32)> {
let split = self
.gui
.atlas
.font
.split(&text.resolve_legacy(), width as f32);
split.into_iter().map(|row| (row, u32::MAX)).collect()
}
/// Advance the overworld clock (ServerClockManager.tick) when
/// `advance_time` allows it.
pub(super) fn advance_clock(&mut self) {
@@ -998,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,
@@ -1015,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, _)) =
@@ -1044,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;
@@ -1094,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).
@@ -1101,7 +886,6 @@ impl Game<'_> {
let cause = self.ui.death_cause.take();
self.ui.died_at = Some(std::time::Instant::now());
self.ui.death_text = None;
self.ui.death_confirm = None;
if !self.ui.rules.bool("show_death_messages") {
return;
}
@@ -1204,9 +988,60 @@ 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).
fn owner_permission(allow_commands: bool) -> u8 {
if allow_commands { 4 } else { 0 }
}
/// A hover text's rows for the tooltip: split at line breaks, each in the
/// colour of its first styled character (white without one).
fn hover_rows(hover: &horizon_client::text::Text) -> Vec<(String, u32)> {
let mut rows = vec![(String::new(), None)];
for (c, style) in hover.resolve_legacy().chars() {
if c == '\n' {
rows.push((String::new(), None));
continue;
}
let row = rows.last_mut().expect("one row");
if row.0.is_empty() {
row.1 = style.color;
}
row.0.push(c);
}
rows.into_iter()
.map(|(text, color)| {
let [_, r, g, b] = color.unwrap_or(0xFF_FFFF).to_be_bytes();
(text, horizon_client::hud::rgba(r, g, b, 255))
})
.collect()
}
@@ -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 -72
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,
@@ -42,20 +33,9 @@ pub(super) struct CombatTracker {
/// The projectile hitting the player now is another player's that
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
pub(super) refuse_player_hit: bool,
/// The player whose melee hit is landing on the player now: where they
/// stand and the seconds their weapon disables a shield for
/// (`getSecondsToDisableBlocking`).
pub(super) player_attacker: Option<([f64; 3], f32)>,
/// 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.
@@ -65,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)?;
@@ -134,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;
@@ -201,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;
};
+157 -379
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!(
@@ -1162,17 +1041,6 @@ impl Game<'_> {
// An air stack is empty: nothing is given (but the message still is).
let empty = id == "minecraft:air";
for &t in targets.iter().filter(|_| !empty) {
// The player's UUID, which an item they could not hold is set to.
let conn = match t {
Target::Guest(conn) => Some(conn),
_ => self.lan_acting,
};
let uuid = match conn {
Some(conn) => self
.command_guest(conn)
.map(|g| horizon_client::lan::playerdata::uuid_ints(g.uuid)),
None => Some(self.level.ensure_player_uuid()),
};
self.for_player(t, |g| {
let mut remaining = count;
while remaining > 0 {
@@ -1189,19 +1057,8 @@ impl Game<'_> {
.target_position(Target::Player)
.unwrap_or(g.source_position());
if let Some(rest) = left {
// Inventory full: Player.createItemStackToDrop (thrown from
// the eyes), then setNoPickUpDelay and setTarget(player).
let eye = g.eye_position().unwrap_or([
p[0],
p[1] + g.target_eye_height(Target::Player),
p[2],
]);
g.throw_from_eyes(rest, eye, false);
if let Some(item) = g.dropped.last_mut() {
item.thrower = None;
item.pickup_delay = 0;
item.target = uuid;
}
// Inventory full: drop the rest at the player's feet without pickup delay.
g.spawn_item(rest, [p[0], p[1] + 1.32, p[2]], [0.0, 0.0, 0.0], 0);
} else {
let r = &mut g.world.random;
let pitch = ((r.next_float() - r.next_float()) * 0.7 + 1.0) * 2.0;
@@ -1246,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(_) => {
@@ -1298,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;
}
@@ -1345,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(_) => {}
}
@@ -1469,7 +1311,7 @@ impl Game<'_> {
{
return Err(err("commands.teleport.invalidPosition", &[]));
}
let rotation = match ctx.get("rotation") {
let mut rotation = match ctx.get("rotation") {
Some(Arg::Coords(c)) => Some(c.rotation(self.source_rotation())),
_ => None,
};
@@ -1484,31 +1326,21 @@ impl Game<'_> {
(Some(f), _) => Some(f),
(None, Some(_)) => {
let e = self.entities(ctx, "facingEntity", false)?[0];
// EntityAnchorArgument.Anchor.apply: the feet, or the eyes.
let eyes = matches!(ctx.get("facingAnchor"), Some(Arg::Anchor(true)));
let eye_height = if eyes { self.target_eye_height(e) } else { 0.0 };
self.target_position(e)
.map(|p| [p[0], p[1] + eye_height, p[2]])
.map(|p| [p[0], p[1] + if eyes { 1.62 } else { 0.0 }, p[2]])
}
_ => None,
};
// Entity.lookAt: from the source's anchor (the feet, unless `anchored
// eyes`) on the teleported entity, so the eyes depend on the target.
let anchored_eyes = self.command_source.is_some_and(|s| s.eyes);
let look_at = |this: &Self, t: Target| {
let f = facing?;
let eye = if anchored_eyes {
pos[1] + this.target_eye_height(t)
} else {
pos[1]
};
if let Some(f) = facing {
let eye = pos[1] + 1.62;
let (dx, dy, dz) = (f[0] - pos[0], f[1] - eye, f[2] - pos[2]);
let horizontal = (dx * dx + dz * dz).sqrt();
Some([
rotation = Some([
((dz.atan2(dx).to_degrees()) - 90.0) as f32,
(-(dy.atan2(horizontal).to_degrees())) as f32,
])
};
]);
}
// A server's player in another level than the source's (`execute
// in`, the console's Overworld) moves there (ServerPlayer.teleport
// into another level), after the command.
@@ -1518,7 +1350,6 @@ impl Game<'_> {
None => self.dim,
};
for &t in &targets {
let rotation = look_at(self, t).or(rotation);
if self.teleport_across(t, source_level, pos, rotation) {
continue;
}
@@ -1726,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));
@@ -1749,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),
@@ -1763,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() {
@@ -1775,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 {
@@ -1878,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);
@@ -2082,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
@@ -2243,8 +2057,7 @@ impl Game<'_> {
let dmax = [dest[0] + size[0], dest[1] + size[1], dest[2] + size[2]];
let overlap = (0..3).all(|i| dest[i] <= max[i] && dmax[i] >= min[i]);
let moving = ctx.has("move");
// Mode.canOverlap: only the normal mode refuses overlapping regions.
if !ctx.has("force") && !moving && overlap {
if !ctx.has("force") && overlap {
return Err(err("commands.clone.overlap", &[]));
}
let area = (0..3).map(|i| i64::from(size[i] + 1)).product::<i64>();
@@ -2453,54 +2266,26 @@ impl Game<'_> {
let levels = ctx.has("levels");
let unit = if levels { "levels" } else { "points" };
if ctx.has("query") {
let target = self.entities(ctx, "target", true)?[0];
// ExperienceCommand.queryExperience: the target's own level or points.
let value = self
.for_player(target, |g| {
let e = &g.experience;
if levels {
e.level
} else {
(e.progress * horizon_client::entities::Experience::needed(e.level) as f32)
as i32
}
})
.unwrap_or(0);
let _ = self.entities(ctx, "target", true)?;
let e = &self.experience;
let value = if levels {
e.level
} else {
(e.progress * horizon_client::entities::Experience::needed(e.level) as f32) as i32
};
self.system_message(Text::translate(
&format!("commands.experience.query.{unit}"),
&[self.target_name(target), Text::literal(value.to_string())],
&[
Text::literal(self.acting_player_name()),
Text::literal(value.to_string()),
],
));
return Ok(value);
}
let targets = self.entities(ctx, "target", true)?;
let amount = Self::int(ctx, "amount").unwrap_or(0);
let add = ctx.has("add");
let op = if add { "add" } else { "set" };
// Every target changes (a set of points can be refused for some).
let changed: Vec<Target> = targets
.iter()
.copied()
.filter(|&t| {
self.for_player(t, |g| g.change_experience(add, levels, amount)) == Some(true)
})
.collect();
if changed.is_empty() {
return Err(err("commands.experience.set.points.invalid", &[]));
}
let (key, who) = match changed[..] {
[only] => ("single", self.target_name(only)),
_ => ("multiple", Text::literal(changed.len().to_string())),
};
self.feedback(Text::translate(
&format!("commands.experience.{op}.{unit}.success.{key}"),
&[Text::literal(amount.to_string()), who],
));
Ok(changed.len() as i32)
}
/// ExperienceCommand's add and set on the player in place: false when
/// the points to set do not fit the level.
fn change_experience(&mut self, add: bool, levels: bool, amount: i32) -> bool {
if add {
let op = if ctx.has("add") { "add" } else { "set" };
if op == "add" {
if levels {
self.experience.level = (self.experience.level + amount).max(0);
if self.experience.level == 0 && amount < 0 {
@@ -2514,11 +2299,26 @@ impl Game<'_> {
} else {
let needed = horizon_client::entities::Experience::needed(self.experience.level);
if amount > needed - 1 {
return false;
return Err(err("commands.experience.set.points.invalid", &[]));
}
self.experience.progress = amount as f32 / needed as f32;
}
true
let key = format!(
"commands.experience.{op}.{unit}.success.{}",
if targets.len() == 1 {
"single"
} else {
"multiple"
}
);
self.feedback(Text::translate(
&key,
&[
Text::literal(amount.to_string()),
Text::literal(self.acting_player_name()),
],
));
Ok(targets.len() as i32)
}
fn cmd_gamerule(&mut self, ctx: &Context) -> R<i32> {
let Some(name) = ctx.literals.get(1).cloned() else {
@@ -2603,26 +2403,21 @@ impl Game<'_> {
};
let yaw = ((rot[0] + 180.0).rem_euclid(360.0)) - 180.0;
let pitch = rot[1].clamp(-90.0, 90.0);
// SetSpawnCommand: a forced point in the source's level, for each target.
let level = self.dim;
for &t in &targets {
self.for_player(t, |g| {
if level == Dim::Overworld {
g.spawn = Some(Vec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
));
g.respawn_config = None;
} else {
g.spawn = None;
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
dim: level,
pos,
yaw,
forced: true,
});
}
// SetSpawnCommand: a forced point in the source's level.
if self.dim == Dim::Overworld {
self.spawn = Some(Vec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
));
self.respawn_config = None;
} else {
self.spawn = None;
self.respawn_config = Some(super::respawn_anchor::RespawnConfig {
dim: self.dim,
pos,
yaw,
forced: true,
});
}
let dim = Text::literal(self.dim.id());
@@ -2802,44 +2597,6 @@ impl Game<'_> {
},
};
let max = Self::int(ctx, "maxCount").unwrap_or(-1);
let counting = max == 0;
// ClearInventoryCommands.clearInventory: each player on their own.
let mut removed = 0i32;
let mut found = Vec::new();
for &t in &targets {
let n = self
.for_player(t, |g| g.clear_matching(&predicate, max))
.unwrap_or(0);
removed += n;
if n != 0 {
found.push(t);
}
}
if removed == 0 {
return Err(if targets.len() == 1 {
CommandError::new(Text::translate(
"clear.failed.single",
&[self.target_name(targets[0])],
))
} else {
err("clear.failed.multiple", &[&targets.len()])
});
}
let kind = if counting { "test" } else { "success" };
let (key, who) = match found[..] {
[only] => ("single", self.target_name(only)),
_ => ("multiple", Text::literal(found.len().to_string())),
};
self.feedback(Text::translate(
&format!("commands.clear.{kind}.{key}"),
&[Text::literal(removed.to_string()), who],
));
Ok(removed)
}
/// Inventory.clearOrCountMatchingItems for the player in place: `max`
/// of the matching items go (all when negative, none and only counted
/// when 0). Returns how many matched.
fn clear_matching(&mut self, predicate: &ItemInput, max: i32) -> i32 {
let counting = max == 0;
// Inventory slots 0..40: main, armor feet to head, off hand;
// then clearOrCountMatchingItems visits the crafting grid and carried stack.
@@ -2855,7 +2612,10 @@ impl Game<'_> {
};
let matching: Vec<bool> = all(self)
.iter()
.map(|s| s.as_ref().is_some_and(|s| self.stack_matches(predicate, s)))
.map(|s| {
s.as_ref()
.is_some_and(|s| self.stack_matches(&predicate, s))
})
.collect();
let mut slots: Vec<&mut Option<ItemStack>> = Vec::new();
slots.extend(self.inventory.iter_mut());
@@ -2885,7 +2645,25 @@ impl Game<'_> {
}
}
}
removed
if removed == 0 {
return Err(if targets.len() == 1 {
CommandError::new(Text::translate(
"clear.failed.single",
&[Text::literal(self.acting_player_name())],
))
} else {
err("clear.failed.multiple", &[&targets.len()])
});
}
let kind = if counting { "test" } else { "success" };
self.feedback(Text::translate(
&format!("commands.clear.{kind}.single"),
&[
Text::literal(removed.to_string()),
Text::literal(self.acting_player_name()),
],
));
Ok(removed)
}
/// `PoiTypes`: the point of interest type of a block state.
fn poi_type(&self, state: horizon_world::registry::BlockStateId) -> Option<&'static str> {
@@ -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))
@@ -105,7 +105,6 @@ impl Game<'_> {
match ty.trim_start_matches("minecraft:") {
"player_attack" => DamageKind::PlayerAttack,
"mace_smash" => DamageKind::MaceSmash,
"spear" => DamageKind::Spear,
"mob_attack" | "mob_attack_no_aggro" => DamageKind::MobAttack,
"arrow" => DamageKind::Arrow,
"thrown" | "trident" | "mob_projectile" => DamageKind::Thrown,
@@ -119,9 +118,7 @@ impl Game<'_> {
"dry_out" => DamageKind::DryOut,
"magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic,
"sting" => DamageKind::Sting,
"cactus" => DamageKind::Cactus,
"sweet_berry_bush" => DamageKind::SweetBerryBush,
"freeze" => DamageKind::Freeze,
"cactus" | "sweet_berry_bush" => DamageKind::Cactus,
"in_wall" => DamageKind::InWall,
"thorns" => DamageKind::Thorns,
_ => DamageKind::Generic,
@@ -1221,7 +1218,7 @@ impl Game<'_> {
/// LivingEntity.stopRiding for a mob: off its vehicle
/// (Entity.removePassenger), then to where the vehicle lets it out
/// (LivingEntity.dismountVehicle).
pub(in crate::game) fn dismount_mob(&mut self, rider: u32) {
fn dismount_mob(&mut self, rider: u32) {
let Some(i) = self.mobs.iter().position(|m| m.id == rider) else {
return;
};
-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
@@ -204,64 +204,6 @@ impl Game<'_> {
}
}
// ---- interactions -----------------------------------------------------
/// `WeatheringCopperGolemStatueBlock.useItemOn`: an axe on an unoxidized
/// statue turns it back into a golem (`CopperGolemStatueBlockEntity.removeStatue`),
/// wearing the axe. Weathered statues are scraped by the axe's own
/// transform instead.
pub(super) fn use_golem_statue(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
if !held.is_some_and(|s| s.item.ends_with("_axe")) {
return false;
}
let registry = self.pack.assets.registry;
let Some(state) = registry.state(self.world.block(pos[0], pos[1], pos[2])) else {
return false;
};
if state.name != "minecraft:copper_golem_statue" {
return false;
}
// `Direction.toYRot` of the statue's facing.
let yaw = match state.properties.get("facing").map(String::as_str) {
Some("west") => 90.0,
Some("north") => 180.0,
Some("east") => 270.0,
_ => 0.0,
};
let waterlogged = state
.properties
.get("waterlogged")
.is_some_and(|w| w == "true");
// initCopperGolem: at the block's feet, facing as the statue does.
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:copper_golem", at, true) else {
return false;
};
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.yaw = yaw;
m.previous_yaw = yaw;
m.body_yaw = yaw;
m.previous_body_yaw = yaw;
m.head_yaw = yaw;
m.previous_head_yaw = yaw;
}
self.damage_held(1);
self.audio
.play("minecraft:entity.copper_golem.spawn", Some(at), 1.0, 1.0);
// Level.removeBlock(pos, false): air, or the water the statue stood in.
let replacement = registry.default_state(if waterlogged {
"minecraft:water"
} else {
"minecraft:air"
});
if let Some(replacement) = replacement {
let _ = self.level_set(pos, replacement);
}
self.world.block_entities.remove(&(pos[0], pos[1], pos[2]));
true
}
/// `CopperGolem.mobInteract`; true when handled.
pub(super) fn interact_copper_golem(&mut self, index: usize, item: &str) -> bool {
if self.mobs[index].species() != Species::CopperGolem || !self.mobs[index].alive() {
@@ -1,39 +0,0 @@
//! The creative toolbars, `hotbar.nbt` beside `options.txt`: hold the save or
//! load key and press a hotbar key (`Minecraft.handleKeybinds`).
use super::Game;
use horizon_client::{hotbar, keybinds, text::Text};
impl Game<'_> {
/// Whether the key bound to `mapping` is held.
pub(super) fn mapped_key_down(&self, mapping: &str) -> bool {
keybinds::key_code(self.settings.keys.get(mapping))
.is_some_and(|k| self.physical_keys.contains(&k))
}
/// `CreativeModeInventoryScreen.handleHotbarLoadOrSave` for the hotbar key
/// `index`: load puts the saved row in the hotbar (the creative slot
/// packets follow from the inventory change), save stores the hotbar and
/// says so on the action bar.
pub(super) fn hotbar_load_or_save(&mut self, index: usize, load: bool) {
let path = Self::options_path().with_file_name("hotbar.nbt");
let known = |id: &str| self.res.items().get(id).is_some();
let mut rows = hotbar::load(&path, &known);
if load {
for (slot, stack) in rows[index].iter().enumerate() {
self.inventory[slot] = stack.clone();
}
return;
}
rows[index] = self.inventory[..hotbar::SIZE].to_vec();
let _ = hotbar::save(&path, &rows);
let key = |mapping: &str| keybinds::display_name(self.settings.keys.get(mapping));
let message = Text::translate(
"inventory.hotbarSaved",
&[
key("key.loadToolbarActivator"),
key(&format!("key.hotbar.{}", index + 1)),
],
);
self.titles.set_overlay(message, false);
}
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 425 files, more files were not shown because too many files have changed in this diff. Show more