mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
419
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9033a9c3b1 | ||
|
|
19d1b2a741 | ||
|
|
9903e742f6 | ||
|
|
991db79d15 | ||
|
|
4845b77e06 | ||
|
|
e2568161af | ||
|
|
9af5fb2f12 | ||
|
|
280eb89eed | ||
|
|
7a82db6937 | ||
|
|
03f0885752 | ||
|
|
60f05f309b | ||
|
|
22c184dcd8 | ||
|
|
d14af43a1c | ||
|
|
fdd1f3e958 | ||
|
|
691652b099 | ||
|
|
1eca57ed9b | ||
|
|
7da27831df | ||
|
|
42f655380a | ||
|
|
730a6e9a48 | ||
|
|
f6b1a26448 | ||
|
|
5bd0362e86 | ||
|
|
216fd751f1 | ||
|
|
21c3498abc | ||
|
|
98ce2e54dd | ||
|
|
d928c97862 | ||
|
|
5b8e344b5b | ||
|
|
254f828565 | ||
|
|
19f9b60093 | ||
|
|
3b1df392b6 | ||
|
|
5ba4740cbb | ||
|
|
ae6a5029de | ||
|
|
8732145923 | ||
|
|
c1ed16f410 | ||
|
|
c9e627325b | ||
|
|
8bca5abc51 | ||
|
|
f0e04f78f3 | ||
|
|
d3523be123 | ||
|
|
3bd5ae069c | ||
|
|
8ada413ed3 | ||
|
|
5205766d60 | ||
|
|
2ec0a6fdd4 | ||
|
|
d8f74a578a | ||
|
|
87881a9b1d | ||
|
|
e0d829890c | ||
|
|
76f74a8e67 | ||
|
|
0f0e9623bc | ||
|
|
c0ef6a78be | ||
|
|
60feb3db3c | ||
|
|
eb04a80563 | ||
|
|
b51f755607 | ||
|
|
c352c80c96 | ||
|
|
fd6fdcbf75 | ||
|
|
fd1d6a2762 | ||
|
|
a0ef14f1e2 | ||
|
|
0a75323caa | ||
|
|
4f5ed6a19b | ||
|
|
d12dc5d1e1 | ||
|
|
64f9ff4529 | ||
|
|
f5b7bdba1d | ||
|
|
1d0d827f3a | ||
|
|
844441874a | ||
|
|
b6dd6c35ae | ||
|
|
1af4d0e432 | ||
|
|
68c9597237 | ||
|
|
0db65d7a61 | ||
|
|
aef652c8af | ||
|
|
4be5c8eb53 | ||
|
|
6ed9e9632d | ||
|
|
111db2827b | ||
|
|
17f8419acd | ||
|
|
9555f375d4 | ||
|
|
1b1571ed6a | ||
|
|
a891037c9f | ||
|
|
266c7a2cd9 | ||
|
|
a727f617f0 | ||
|
|
12bf15cfd7 | ||
|
|
b0783dac38 | ||
|
|
840d11a3b2 | ||
|
|
4504223265 | ||
|
|
774fa9d897 | ||
|
|
0c7f6b7f0b | ||
|
|
c1a811c4bb | ||
|
|
19bb6c1262 | ||
|
|
9bf853804e | ||
|
|
7e4ddee8b1 | ||
|
|
f8ba650559 | ||
|
|
92b99c58aa | ||
|
|
7e82902d15 | ||
|
|
d090686ffa | ||
|
|
aba4f19a81 | ||
|
|
5a1d1c38ed | ||
|
|
cc58083574 | ||
|
|
aa0eb406d7 | ||
|
|
8f02822a9a | ||
|
|
031f1e58a3 | ||
|
|
d3a662b760 | ||
|
|
3f0e70882c | ||
|
|
2929bb6dba | ||
|
|
92e3f496f0 | ||
|
|
54ae097faa | ||
|
|
bd1436f162 | ||
|
|
1b0937abff | ||
|
|
1fe0f5fcb9 | ||
|
|
34f57dbcac | ||
|
|
83b544f04f | ||
|
|
c4d0cb1ec0 | ||
|
|
1258c2473b | ||
|
|
0cba2edbd4 | ||
|
|
d6a80c5193 | ||
|
|
a373eea189 | ||
|
|
a2cd70e8e1 | ||
|
|
331e3a7202 | ||
|
|
b836eea062 | ||
|
|
3bb6e40304 | ||
|
|
eab8d0f911 | ||
|
|
d7fece5825 | ||
|
|
bab4251715 | ||
|
|
9eb913affe | ||
|
|
c808fae288 | ||
|
|
effb29e571 | ||
|
|
26561d315c | ||
|
|
65c60ab996 | ||
|
|
56bf45cee5 | ||
|
|
4afd517c22 | ||
|
|
abb4e7b5cd | ||
|
|
0e96fb6d2a | ||
|
|
f4a235e027 | ||
|
|
cdafcd756f | ||
|
|
cb43e32787 | ||
|
|
67c27701e9 | ||
|
|
69bb813128 | ||
|
|
b360e3c26c | ||
|
|
94748162bc | ||
|
|
06b046d8fc | ||
|
|
9312411e53 | ||
|
|
9cc9792b28 | ||
|
|
37559dae82 | ||
|
|
fff9cd1580 | ||
|
|
bb9b0424b4 | ||
|
|
c85f6f2e39 | ||
|
|
851789830a | ||
|
|
565cf6af4e | ||
|
|
34dabe4afd | ||
|
|
36c06d6133 | ||
|
|
ad6f2320b0 | ||
|
|
b6a2bb2c0d | ||
|
|
4201fd23c0 | ||
|
|
8920f08fd9 | ||
|
|
46b71954e1 | ||
|
|
b92a53e47c | ||
|
|
ffc343aadd | ||
|
|
4cf9ffd847 | ||
|
|
e23fcb1040 | ||
|
|
27b81d9a8f | ||
|
|
7b3595b8f2 | ||
|
|
c417556834 | ||
|
|
bf62ccdabc | ||
|
|
41d8b9e48a | ||
|
|
6ef61ac13b | ||
|
|
0e658e3e62 | ||
|
|
041ecb7b7a | ||
|
|
41ca489d4e | ||
|
|
82d2b61a4a | ||
|
|
f9b8b40a7d | ||
|
|
08cd05ed6e | ||
|
|
f312aae7db | ||
|
|
4d6875ecfb | ||
|
|
e82fa3d957 | ||
|
|
d026c6db3e | ||
|
|
e704a8dccb | ||
|
|
1975fe486d | ||
|
|
98e29159eb | ||
|
|
df045f1e9a | ||
|
|
55a71d3502 | ||
|
|
02cd2254d0 | ||
|
|
1132d2986a | ||
|
|
1cdc8ca76b | ||
|
|
a1098c0769 | ||
|
|
73993e7cda | ||
|
|
53a95a6e1c | ||
|
|
3ec6c73037 | ||
|
|
591b7b74da | ||
|
|
d78238f765 | ||
|
|
159f2bf890 | ||
|
|
6628d8b950 | ||
|
|
ce5be7ffa1 | ||
|
|
2806902c93 | ||
|
|
8839f0bc72 | ||
|
|
303281ed09 | ||
|
|
18f8a6868b | ||
|
|
0f726a9f7c | ||
|
|
f73cfff300 | ||
|
|
6ac5d148ec | ||
|
|
5689e63500 | ||
|
|
d721ea2a44 | ||
|
|
efd6c60a27 | ||
|
|
43d1f4b801 | ||
|
|
ac4309d3bf | ||
|
|
a08f3eb2fa | ||
|
|
6e01dac2f9 | ||
|
|
96b538d48f | ||
|
|
f9b2f949ee | ||
|
|
29eae3a1af | ||
|
|
d241c54e05 | ||
|
|
4efa49ae82 | ||
|
|
85db9a23a9 | ||
|
|
00cb813dc8 | ||
|
|
9e848670db | ||
|
|
dbd24e2e16 | ||
|
|
15b38ba030 | ||
|
|
5a70691d63 | ||
|
|
e7e6e5bc07 | ||
|
|
4ebfaef0d3 | ||
|
|
7112d0cd64 | ||
|
|
818b377c02 | ||
|
|
46150ba08c | ||
|
|
72e678be8c | ||
|
|
7674ef77cc | ||
|
|
cddb07c9db | ||
|
|
a99e31d01f | ||
|
|
b28b499135 | ||
|
|
97ddd2c6f9 | ||
|
|
f01a4e2119 | ||
|
|
61df807b33 | ||
|
|
7f8a6680a8 | ||
|
|
ec81bea5bf | ||
|
|
1eb7a04a4e | ||
|
|
55a066e88a | ||
|
|
8c2ef15171 | ||
|
|
705e3d10cf | ||
|
|
68db8847e7 | ||
|
|
3a5aa2b3e7 | ||
|
|
edc4a58397 | ||
|
|
c2e4eee251 | ||
|
|
bc13a67e74 | ||
|
|
f6ff2319a5 | ||
|
|
5d7097877b | ||
|
|
f8415efe29 | ||
|
|
c35575f462 | ||
|
|
8ec47d6419 | ||
|
|
5ecce8a7a3 | ||
|
|
e57faff4a3 | ||
|
|
badceed27c | ||
|
|
45159841bc | ||
|
|
cfc55bb6c6 | ||
|
|
6af65a142b | ||
|
|
60109e0d91 | ||
|
|
d7ec7b6077 | ||
|
|
1f88194b61 | ||
|
|
17fa57db3d | ||
|
|
dae701a3c7 | ||
|
|
e2b1dc6691 | ||
|
|
e60cbe8d4d | ||
|
|
a4407a4bcc | ||
|
|
0b98555009 | ||
|
|
d6a7f7f32c | ||
|
|
2ca383f36d | ||
|
|
5812071a1b | ||
|
|
eb66e42e38 | ||
|
|
6a62ad67cc | ||
|
|
1a43fa5859 | ||
|
|
212775a11f | ||
|
|
a4764f5b0d | ||
|
|
edd7f712b6 | ||
|
|
cbfb8845d2 | ||
|
|
0b1ed3cdf5 | ||
|
|
1825b3991c | ||
|
|
db13c68585 | ||
|
|
9422f698ee | ||
|
|
64f4e9b6ff | ||
|
|
61ff050927 | ||
|
|
800b91567a | ||
|
|
ad557fef47 | ||
|
|
fc9305bb15 | ||
|
|
fbbed63f04 | ||
|
|
21a89c2e7f | ||
|
|
e24ed85036 | ||
|
|
06a45d86a1 | ||
|
|
64fab2cc83 | ||
|
|
57f5e1fb8e | ||
|
|
763fc284ed | ||
|
|
b8da45f5ef | ||
|
|
823941756f | ||
|
|
487a461a8a | ||
|
|
aa839fa99a | ||
|
|
d01a621fec | ||
|
|
0f22a274fa | ||
|
|
a42857ab48 | ||
|
|
eee7253b1b | ||
|
|
5963e6cc01 | ||
|
|
d26252bff5 | ||
|
|
f69d23019b | ||
|
|
2fc2ee1e13 | ||
|
|
abd2d9f3fe | ||
|
|
b8e90da1c2 | ||
|
|
d8e68148be | ||
|
|
7e5fa6f91b | ||
|
|
e0423bba77 | ||
|
|
04cdf22527 | ||
|
|
94e8b184bc | ||
|
|
9f1784dc0c | ||
|
|
9aba251de5 | ||
|
|
3ae2aad690 | ||
|
|
0bd6a576a9 | ||
|
|
5f1c25c226 | ||
|
|
f7170d6b9f | ||
|
|
43ae7a0974 | ||
|
|
96984300d3 | ||
|
|
91295ab88c | ||
|
|
bb81cb4586 | ||
|
|
bf39e5d49e | ||
|
|
8bb27b44ca | ||
|
|
c5f9e3404c | ||
|
|
c3f5e9f79c | ||
|
|
4471e59132 | ||
|
|
402b8dc01e | ||
|
|
aa8a78e1b7 | ||
|
|
0c8e8037df | ||
|
|
dbbe3a45d9 | ||
|
|
8df89ca4d6 | ||
|
|
81c8e0e815 | ||
|
|
4b89d0cd85 | ||
|
|
37825b750a | ||
|
|
68aa70108f | ||
|
|
bf2c867966 | ||
|
|
2394d6e7dc | ||
|
|
409a828128 | ||
|
|
625c4c559e | ||
|
|
e157c5a0a5 | ||
|
|
92e59024f2 | ||
|
|
c75854a968 | ||
|
|
864ba24c3d | ||
|
|
608a6d9bee | ||
|
|
75f7029d01 | ||
|
|
1d51f9d873 | ||
|
|
7d8b6f3b90 | ||
|
|
aed8b571c4 | ||
|
|
545370b023 | ||
|
|
48811b12f1 | ||
|
|
7fbfbb5e2b | ||
|
|
6bdf5fe368 | ||
|
|
7d5a052261 | ||
|
|
84b621096a | ||
|
|
f0d2a2ae74 | ||
|
|
0c22d75a2e | ||
|
|
4abf5c0ea2 | ||
|
|
6e0a49029f | ||
|
|
bd59cdc25c | ||
|
|
4ae514d0ae | ||
|
|
628ff01a44 | ||
|
|
c7bacfb293 | ||
|
|
ee19d2c00c | ||
|
|
f24ca405a8 | ||
|
|
091a98656e | ||
|
|
fd1d857f1b | ||
|
|
fd0cb00296 | ||
|
|
3e78846975 | ||
|
|
8bfa7a5b7b | ||
|
|
b1136ac287 | ||
|
|
199178907c | ||
|
|
7abb092089 | ||
|
|
fcfd425b94 | ||
|
|
d8fcebdf71 | ||
|
|
551a9ee9c5 | ||
|
|
fb86430813 | ||
|
|
6875c44bbe | ||
|
|
bcdf769f8d | ||
|
|
54fe73854e | ||
|
|
4aa7838d1b | ||
|
|
991406d0e0 | ||
|
|
0ba73d0a7e | ||
|
|
05fd9c3795 | ||
|
|
71981a95a2 | ||
|
|
af6b375626 | ||
|
|
19c7a4a008 | ||
|
|
23d546c6d5 | ||
|
|
3353f1b6e4 | ||
|
|
13e946a89c | ||
|
|
7c16e83fdf | ||
|
|
d0509e2bee | ||
|
|
d057c89164 | ||
|
|
d622030988 | ||
|
|
b752a821b1 | ||
|
|
a489d65ab0 | ||
|
|
688d86db7c | ||
|
|
5d30776b07 | ||
|
|
8178ec3dae | ||
|
|
02e5c728c3 | ||
|
|
5b0ae25ff8 | ||
|
|
48c0f39e96 | ||
|
|
02f222728d | ||
|
|
4d0116762c | ||
|
|
48c289f775 | ||
|
|
d68ab989c0 | ||
|
|
65bebdfc49 | ||
|
|
c4b9067d6d | ||
|
|
d0af843094 | ||
|
|
aec3b7e262 | ||
|
|
0a116d9523 | ||
|
|
ebf3c1567c | ||
|
|
23d089aa7c | ||
|
|
6c7a90cd7e | ||
|
|
e0fea857ce | ||
|
|
b0d378fe99 | ||
|
|
f5ef003bad | ||
|
|
9ef7a6fa25 | ||
|
|
858eeba1b8 | ||
|
|
5389f72b9a | ||
|
|
0f6569070d | ||
|
|
515c0bdb7d | ||
|
|
fb9b26a06c | ||
|
|
94d8130193 | ||
|
|
2143c26a2e | ||
|
|
c67a963da7 | ||
|
|
fc0b73954f | ||
|
|
66f32a7a3a | ||
|
|
606efe5af0 | ||
|
|
d422564ab3 | ||
|
|
35a89eaebe |
No files matched your search
@@ -73,6 +73,42 @@ jobs:
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
|
||||
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
|
||||
launcher:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Does this pull request change the launcher or a crate it builds?
|
||||
if: github.event_name == 'pull_request'
|
||||
id: launcher
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py --launcher "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Install Rust and the headers GPUI links against
|
||||
if: steps.launcher.outputs.required != 'false'
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
|
||||
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.launcher.outputs.required != 'false'
|
||||
with:
|
||||
workspaces: launcher
|
||||
shared-key: launcher
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Check the launcher's formatting, lint and tests
|
||||
if: steps.launcher.outputs.required != 'false'
|
||||
working-directory: launcher
|
||||
run: |
|
||||
cargo +stable fmt --check
|
||||
cargo +stable clippy --all-targets --locked -- -D warnings
|
||||
cargo +stable test --locked --lib
|
||||
# Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
|
||||
# (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
|
||||
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
|
||||
@@ -108,6 +144,7 @@ jobs:
|
||||
with:
|
||||
base: ${{ github.event.pull_request.base.sha }}
|
||||
head: ${{ github.event.pull_request.head.sha }}
|
||||
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
|
||||
# Horizon never downloads the game. These jobs need a self-hosted Linux runner
|
||||
# (label horizon-game) holding the maintainer's own copy: the official launcher's
|
||||
# game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in
|
||||
|
||||
@@ -25,8 +25,8 @@ jobs:
|
||||
parity-main:
|
||||
# A Mac mini runner of its own (ludovics-mac-mini-2 holds only this label since 2026-10-26): behind PR parity jobs
|
||||
# this run waited 2 h while every merge replaced the pending one, and on the release runner a release then waited
|
||||
# behind the next run. PR parity jobs have ludovics-mac-mini and the desktop. Not the desktop: a job slowed down
|
||||
# because the maintainer came back could time out, and a failure here bisects and comments on merges.
|
||||
# behind the next run. PR parity jobs have ludovics-mac-mini and playhorizon. Not playhorizon: its 4 CPUs
|
||||
# make a core-crate run several times longer, and a failure here bisects and comments on merges.
|
||||
runs-on: [self-hosted, macOS, horizon-parity-main]
|
||||
timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures
|
||||
steps:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: parity
|
||||
# The vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the maintainer's
|
||||
# own Minecraft files (Horizon never downloads the game). Called by ci.yml for pull requests from this repository
|
||||
# The vanilla harnesses a pull request's changes can affect, on the Mac mini's runner or playhorizon's, which hold the
|
||||
# maintainer's own Minecraft files (Horizon never downloads the game). Called by ci.yml for pull requests from this repository
|
||||
# and by run-parity.yml when a maintainer labels a fork's pull request run-parity. See docs/release-flow.md.
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -13,6 +13,13 @@ on:
|
||||
description: The commit to check out and test
|
||||
type: string
|
||||
required: true
|
||||
full:
|
||||
description: >-
|
||||
The pull request carries the full-parity label (a change every harness reaches, such as a crate split): it
|
||||
runs on playhorizon's runner only, so the Mac mini's runners stay free for the others. While that runner is
|
||||
busy the job waits for it.
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
@@ -21,40 +28,53 @@ env:
|
||||
|
||||
jobs:
|
||||
parity:
|
||||
runs-on: [self-hosted, horizon-game]
|
||||
timeout-minutes: 120 # a change to core crates reruns ~300 harnesses: ~60-90 min on the Mac mini
|
||||
runs-on: ${{ inputs.full && fromJSON('["self-hosted", "horizon-game", "playhorizon"]') || fromJSON('["self-hosted", "horizon-game"]') }}
|
||||
# A change to core crates reruns ~350 harnesses: ~60-90 min on the Mac mini; on playhorizon (4 of its 8 vCPUs, 4
|
||||
# harnesses at a time) up to a few hours, and any pull request's job may land there.
|
||||
timeout-minutes: ${{ inputs.full && 360 || 180 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The commit the event names: a fork's later pushes need another run-parity label.
|
||||
# The commit the event names: a fork's later pushes need another run-parity label. The next step merges
|
||||
# main into it.
|
||||
ref: ${{ inputs.head }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
# Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs.
|
||||
clean: false
|
||||
- name: The head merged into main (what merging it would land)
|
||||
id: merge
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base }}
|
||||
HEAD_SHA: ${{ inputs.head }}
|
||||
run: |
|
||||
# Harness fixes and game fixes that reached main since the branch was made then apply here too: a head
|
||||
# tested alone failed harnesses already fixed on main (server_knockback before #2114, mob_ai's heap before
|
||||
# #2270, save_roundtrip before #2212). Selection then compares with this main, so it sees only the pull
|
||||
# request's own changes. A head that conflicts with main is tested alone.
|
||||
set -euo pipefail
|
||||
git merge --abort 2>/dev/null || true
|
||||
git fetch -q --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
main=$(git rev-parse origin/main)
|
||||
if git -c user.name=parity -c user.email=parity@localhost merge -q --no-edit --no-ff "$main" >/dev/null 2>&1; then
|
||||
echo "base=$main" >> "$GITHUB_OUTPUT"
|
||||
echo "Tested: head ${HEAD_SHA:0:9} merged into main ${main:0:9}" | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
git merge --abort
|
||||
echo "base=$BASE_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::head ${HEAD_SHA:0:9} conflicts with main ${main:0:9}: tested alone, without main's fixes"
|
||||
echo "Tested: head ${HEAD_SHA:0:9} alone (it conflicts with main ${main:0:9})" | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Tools from the runner
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Vanilla data, and resources prepared by this preparer
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base }}
|
||||
run: |
|
||||
# main's script: one written before a fix to it left a real .local/horizon-client without sounds.json on
|
||||
# the runner, and every later job there failed `subtitle`. A branch that changes the script runs its own.
|
||||
set -euo pipefail
|
||||
if git rev-parse --verify -q origin/main >/dev/null \
|
||||
&& git diff --quiet "$(git merge-base "$BASE_SHA" HEAD)" HEAD -- tools/ci/vanilla-data.sh; then
|
||||
git show origin/main:tools/ci/vanilla-data.sh > "$RUNNER_TEMP/vanilla-data.sh"
|
||||
bash "$RUNNER_TEMP/vanilla-data.sh"
|
||||
else
|
||||
bash tools/ci/vanilla-data.sh
|
||||
fi
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
- name: The prepared output is the one the preparer's OUTPUT_VERSION says
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base }}
|
||||
BASE_SHA: ${{ steps.merge.outputs.base }}
|
||||
run: |
|
||||
# The job runs main's workflow on the head's files: a head made before OUTPUT_VERSION (#1947) has neither
|
||||
# the tool nor the number, so there is nothing to check.
|
||||
# A head made before OUTPUT_VERSION (#1947) that conflicts with main has neither the tool nor the number.
|
||||
if [ ! -f tools/prepare_output.py ] || ! grep -q OUTPUT_VERSION crates/horizon-prepare/src/lib.rs; then
|
||||
echo "This head's preparer has no OUTPUT_VERSION: nothing to check"
|
||||
exit 0
|
||||
@@ -65,47 +85,8 @@ jobs:
|
||||
- name: The harnesses this change can affect
|
||||
env:
|
||||
PARITY_HOSTS: ''
|
||||
BASE_SHA: ${{ inputs.base }}
|
||||
run: |
|
||||
# main's selection (tools/parity.py --changed --list with main's parity.py and parity_reach.py), so a
|
||||
# branch made before a better selection landed gets it too; the head's own runner then runs those
|
||||
# harnesses. A branch that changes the selection itself selects with its own code.
|
||||
set -euo pipefail
|
||||
own() { python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400; } # world_spawn takes ~1480 s on a busy runner
|
||||
fork_point=$(git merge-base "$BASE_SHA" HEAD)
|
||||
if ! git rev-parse --verify -q origin/main >/dev/null || ! git diff --quiet "$fork_point" HEAD -- tools/parity.py tools/parity_reach.py; then
|
||||
own
|
||||
exit
|
||||
fi
|
||||
had_reach=$(git ls-files tools/parity_reach.py)
|
||||
git show origin/main:tools/parity.py > tools/parity.py
|
||||
git show origin/main:tools/parity_reach.py > tools/parity_reach.py
|
||||
status=0
|
||||
python3 tools/parity.py --changed "$BASE_SHA" --list > .local/parity-selection.txt || status=$?
|
||||
git checkout -- tools/parity.py
|
||||
if [ -n "$had_reach" ]; then git checkout -- tools/parity_reach.py; else rm -f tools/parity_reach.py; fi
|
||||
cat .local/parity-selection.txt
|
||||
if [ "$status" != 0 ]; then
|
||||
echo "main's selection failed: the head's own selection runs"
|
||||
own
|
||||
exit
|
||||
fi
|
||||
names=$(sed '$d' .local/parity-selection.txt | grep -E '^[A-Za-z0-9_#]+$' || true)
|
||||
# The summary line says how many would run: a count that doesn't match the names read (a format change)
|
||||
# must not let the job pass untested.
|
||||
expected=$(tail -1 .local/parity-selection.txt | sed -nE 's/^([0-9]+) of [0-9]+ harnesses would run$/\1/p')
|
||||
count=$(printf '%s' "$names" | grep -c . || true)
|
||||
if [ -z "$expected" ] || [ "$expected" != "$count" ]; then
|
||||
echo "main's selection listed $count names, its summary says '${expected:-?}': the head's own selection runs"
|
||||
own
|
||||
exit
|
||||
fi
|
||||
if [ "$count" = 0 ]; then
|
||||
echo "Nothing to run."
|
||||
exit 0
|
||||
fi
|
||||
# shellcheck disable=SC2086
|
||||
python3 tools/parity.py $names --timeout 2400
|
||||
BASE_SHA: ${{ steps.merge.outputs.base }}
|
||||
run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner
|
||||
- name: Logs of the failed harnesses
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -22,3 +22,4 @@ jobs:
|
||||
with:
|
||||
base: ${{ github.event.pull_request.base.sha }}
|
||||
head: ${{ github.event.pull_request.head.sha }}
|
||||
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
|
||||
@@ -34,7 +34,9 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
|
||||
| Crate | Owns |
|
||||
|---|---|
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~190 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `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 |
|
||||
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
|
||||
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
|
||||
@@ -118,16 +120,16 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
| 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`, `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: L: `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`, `nbt_path.rs`; G: `functions.rs`, `command_blocks.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` |
|
||||
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
|
||||
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
|
||||
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `block_entity_nbt.rs`, `options_file.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
|
||||
Generated
+33
@@ -216,6 +216,13 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "beacons"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
@@ -1365,6 +1372,8 @@ dependencies = [
|
||||
"glam",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-client-core",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-dualsense",
|
||||
"horizon-friends",
|
||||
@@ -1396,6 +1405,23 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-client-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-world",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-command"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-client-core",
|
||||
"horizon-protocol",
|
||||
"horizon-world",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-core"
|
||||
version = "0.1.0"
|
||||
@@ -2971,6 +2997,13 @@ dependencies = [
|
||||
"objc2-quartz-core 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "recipes"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.4.1"
|
||||
|
||||
+40
-1
@@ -1,6 +1,45 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-hdr", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
|
||||
members = [
|
||||
"crates/horizon-core",
|
||||
"crates/horizon-dualsense",
|
||||
"crates/horizon-threads",
|
||||
"crates/horizon-hdr",
|
||||
"crates/horizon-world",
|
||||
"crates/horizon-client",
|
||||
"crates/horizon-client-core",
|
||||
"crates/horizon-command",
|
||||
"crates/horizon-server-files",
|
||||
"crates/horizon-server",
|
||||
"crates/horizon-bots",
|
||||
"crates/horizon-assets",
|
||||
"crates/horizon-protocol",
|
||||
"crates/horizon-theme",
|
||||
"crates/horizon-prepare",
|
||||
"crates/horizon-telemetry",
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
"crates/horizon-mod-sdk",
|
||||
"crates/horizon-mods",
|
||||
"crates/horizon-mods/tests/guest",
|
||||
"crates/horizon-mods/tests/ui_guest",
|
||||
"crates/cargo-horizon-mod",
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"crates/horizon-mod-sdk/examples/coords-hud",
|
||||
"crates/horizon-mod-sdk/examples/predator-missile",
|
||||
"crates/horizon-mod-sdk/examples/welcome",
|
||||
"crates/horizon-mod-sdk/examples/beacons",
|
||||
"mods/official/fps-overlay",
|
||||
"mods/official/keystrokes",
|
||||
"mods/official/minimap",
|
||||
"mods/official/zoom",
|
||||
"xtask",
|
||||
"mods/official/recipes",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -262,9 +262,21 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
format!("{}: {text}", if error { "error" } else { "feedback" })
|
||||
}
|
||||
Output::Command { line } => format!("command: /{line}"),
|
||||
Output::QuietCommands { lines, .. } => {
|
||||
format!("{} quiet command(s)", lines.len())
|
||||
}
|
||||
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
|
||||
Output::SetBlocks {
|
||||
dimension, blocks, ..
|
||||
} => {
|
||||
format!("set {} block(s) in {dimension}", blocks.len())
|
||||
}
|
||||
Output::OpenScreen { .. } => "opened a screen".to_string(),
|
||||
Output::CloseScreen { .. } => "closed its screen".to_string(),
|
||||
Output::PlaceRecipe { book_id, all } => {
|
||||
format!("placed recipe {book_id}{}", if all { " (all)" } else { "" })
|
||||
}
|
||||
Output::Give { item, count, .. } => format!("gave {count} {item}"),
|
||||
Output::Shake { strength, seconds } => {
|
||||
format!("shook the camera: {strength:.2} for {seconds:.1} s")
|
||||
}
|
||||
|
||||
@@ -1031,19 +1031,22 @@ pub struct ItemModels {
|
||||
}
|
||||
|
||||
impl ItemModels {
|
||||
pub fn components(&self, stack: &StackView) -> Components {
|
||||
let mut out = self
|
||||
.prototypes
|
||||
.get(&stack.item)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
/// The stack's components: its item's defaults with the stack's patch on top. A stack
|
||||
/// with no patch (most stacks a screen draws) borrows the defaults instead of copying them.
|
||||
pub fn components(&self, stack: &StackView) -> std::borrow::Cow<'_, Components> {
|
||||
static NONE: Components = BTreeMap::new();
|
||||
let prototype = self.prototypes.get(&stack.item).unwrap_or(&NONE);
|
||||
if stack.patch.is_empty() {
|
||||
return std::borrow::Cow::Borrowed(prototype);
|
||||
}
|
||||
let mut out = prototype.clone();
|
||||
for (key, value) in &stack.patch {
|
||||
match value {
|
||||
Some(v) => out.insert(key.clone(), v.clone()),
|
||||
None => out.remove(key),
|
||||
};
|
||||
}
|
||||
out
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
/// `ItemModelResolver.updateForTopItem`.
|
||||
pub fn resolve(&self, stack: &StackView, ctx: &Context, env: &dyn ItemEnv) -> ResolvedItem {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "horizon-client-core"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
description = "The client's lowest layer: chat text and translations, SNBT and NBT paths, the scoreboard, boss bars, game rules and the registry and particle tables commands name"
|
||||
|
||||
[dependencies]
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
+1
-1
@@ -259,7 +259,7 @@ fn is_pos(tag: &Tag) -> bool {
|
||||
matches!(tag, Tag::IntArray(v) if v.len() == 3)
|
||||
}
|
||||
/// The loaded `SignText` of one side, or the empty text.
|
||||
pub(crate) fn sign_side(tag: Option<&Tag>) -> Tag {
|
||||
pub fn sign_side(tag: Option<&Tag>) -> Tag {
|
||||
let empty = || string("");
|
||||
let mut messages: Vec<Tag> = match tag.and_then(|t| t.get("messages")) {
|
||||
Some(Tag::List(list)) => list.iter().filter_map(component).collect(),
|
||||
File renamed without changes.
+17
-1
@@ -173,6 +173,22 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
if let Some(e) = o.get("click_event").and_then(Value::as_object) {
|
||||
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
|
||||
style.click = match e.get("action").and_then(Value::as_str) {
|
||||
// ClickEvent.OpenUrl.CODEC: ExtraCodecs.UNTRUSTED_URI allows http and
|
||||
// https only, the scheme in any case (Util.parseAndValidateUntrustedUri).
|
||||
Some("open_url") => text("url")
|
||||
.filter(|u| {
|
||||
u.split_once(':').is_some_and(|(scheme, _)| {
|
||||
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
|
||||
})
|
||||
})
|
||||
.map(crate::text::ClickEvent::OpenUrl),
|
||||
// ClickEvent.ChangePage.CODEC: ExtraCodecs.POSITIVE_INT.
|
||||
Some("change_page") => e
|
||||
.get("page")
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|p| i32::try_from(p).ok())
|
||||
.filter(|p| *p >= 1)
|
||||
.map(crate::text::ClickEvent::ChangePage),
|
||||
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
|
||||
Some("copy_to_clipboard") => {
|
||||
text("value").map(crate::text::ClickEvent::CopyToClipboard)
|
||||
@@ -252,7 +268,7 @@ pub fn to_text_with(
|
||||
} else if let Some(sel) = o.get("selector").and_then(Value::as_str) {
|
||||
resolve(sel)
|
||||
} else if let Some(key) = o.get("keybind").and_then(Value::as_str) {
|
||||
crate::keybinds::display_name(key)
|
||||
crate::key_display::display_name(key)
|
||||
} else {
|
||||
Text::empty()
|
||||
};
|
||||
File renamed without changes.
@@ -292,27 +292,4 @@ mod tests {
|
||||
assert_eq!(GameRules::load(&dir), rules);
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
/// The world's save (Level.write) writes the rules as they are now, and a
|
||||
/// world opened again reads them back (#74).
|
||||
#[test]
|
||||
fn level_save_keeps_changed_rules() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-gamerules-level-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
let mut level = crate::save::Level::default();
|
||||
level.write(&dir).unwrap();
|
||||
// Rules changed while playing, then saved and loaded again.
|
||||
let mut rules = GameRules::load(&dir);
|
||||
assert_eq!(rules, GameRules::default());
|
||||
assert!(rules.set("advance_time", Value::Bool(false)));
|
||||
assert!(rules.set("advance_weather", Value::Bool(false)));
|
||||
assert!(rules.set("random_tick_speed", Value::Int(0)));
|
||||
level.game_rules = rules.by_name();
|
||||
level.write(&dir).unwrap();
|
||||
assert_eq!(GameRules::load(&dir), rules);
|
||||
let read = crate::save::Level::read(&dir).unwrap();
|
||||
assert_eq!(read.game_rules, rules.by_name());
|
||||
assert_eq!(read.game_rule("advance_time"), 0);
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Resource identifiers (`Identifier`).
|
||||
|
||||
/// Identifier.parse: `ns:path` or `path`, validated.
|
||||
pub fn parse_identifier(raw: &str) -> Option<String> {
|
||||
let (ns, path) = match raw.split_once(':') {
|
||||
Some(("", path)) => ("minecraft", path),
|
||||
Some((ns, path)) => (ns, path),
|
||||
None => ("minecraft", raw),
|
||||
};
|
||||
let ns_ok = ns != ".."
|
||||
&& ns
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-'));
|
||||
let path_ok = path.chars().all(|c| {
|
||||
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/')
|
||||
});
|
||||
(ns_ok && path_ok).then(|| format!("{ns}:{path}"))
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Key display names (`InputConstants.Key.getDisplayName`), shared by key
|
||||
//! mappings and `keybind` text components.
|
||||
use crate::text::Text;
|
||||
|
||||
/// InputConstants.Key.getDisplayName: one-character keys as the upper-case
|
||||
/// character, others by translation.
|
||||
pub fn display_name(key: &str) -> Text {
|
||||
if let Some(rest) = key.strip_prefix("key.keyboard.")
|
||||
&& rest.chars().count() == 1
|
||||
{
|
||||
let c = rest.to_uppercase();
|
||||
return Text::literal(c);
|
||||
}
|
||||
let symbol = match key {
|
||||
"key.keyboard.minus" => Some("-"),
|
||||
"key.keyboard.equal" => Some("="),
|
||||
"key.keyboard.left.bracket" => Some("["),
|
||||
"key.keyboard.right.bracket" => Some("]"),
|
||||
"key.keyboard.backslash" => Some("\\"),
|
||||
"key.keyboard.semicolon" => Some(";"),
|
||||
"key.keyboard.apostrophe" => Some("'"),
|
||||
"key.keyboard.grave.accent" => Some("`"),
|
||||
"key.keyboard.comma" => Some(","),
|
||||
"key.keyboard.period" => Some("."),
|
||||
"key.keyboard.slash" => Some("/"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(s) = symbol {
|
||||
return Text::literal(s);
|
||||
}
|
||||
if let Some(n) = key.strip_prefix("key.mouse.")
|
||||
&& n.parse::<u32>().is_ok()
|
||||
&& !crate::lang::has(key)
|
||||
{
|
||||
return Text::translate("key.mouse", &[Text::literal(n)]);
|
||||
}
|
||||
Text::translate(key, &[])
|
||||
}
|
||||
@@ -42,8 +42,12 @@ struct Bundled;
|
||||
impl ResourceSource for Bundled {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
let body = match path {
|
||||
"assets/horizon/lang/en_us.json" => include_str!("../assets/horizon/lang/en_us.json"),
|
||||
"assets/horizon/lang/ru_ru.json" => include_str!("../assets/horizon/lang/ru_ru.json"),
|
||||
"assets/horizon/lang/en_us.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
|
||||
}
|
||||
"assets/horizon/lang/ru_ru.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(body.as_bytes().to_vec())
|
||||
@@ -0,0 +1,23 @@
|
||||
//! The client's lowest layer, below commands and gameplay: chat text and
|
||||
//! translations, SNBT and NBT paths, text components, the scoreboard, boss
|
||||
//! bars, game rules, block entity NBT tables, and the registry, particle and
|
||||
//! sound source tables commands name. horizon-client re-exports each module
|
||||
//! under its old path (`horizon_client::text`, ...).
|
||||
#![forbid(unsafe_code)]
|
||||
pub mod block_entity_nbt;
|
||||
pub mod bossbar;
|
||||
pub mod component;
|
||||
pub mod fastutil_order;
|
||||
pub mod gamerules;
|
||||
pub mod identifier;
|
||||
pub mod key_display;
|
||||
pub mod lang;
|
||||
pub mod nbt_path;
|
||||
pub mod particle_type;
|
||||
pub mod registries;
|
||||
pub mod scoreboard;
|
||||
pub mod snbt;
|
||||
pub mod sound_source;
|
||||
pub mod text;
|
||||
pub mod uuid;
|
||||
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
File renamed without changes.
@@ -0,0 +1,209 @@
|
||||
//! Particle types (`ParticleTypes`): the vanilla particle types with a client
|
||||
//! implementation, by registry name, and their per-type flags.
|
||||
|
||||
macro_rules! particle_types {
|
||||
($($variant:ident = $name:literal),* $(,)?) => {
|
||||
/// Vanilla particle types with a client implementation here.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum ParticleType { $($variant),* }
|
||||
impl ParticleType {
|
||||
pub const ALL: &'static [ParticleType] = &[$(ParticleType::$variant),*];
|
||||
/// Registry path (`minecraft:<name>`), also the sprite-set file name.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self { $(ParticleType::$variant => $name),* }
|
||||
}
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
let name = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
match name { $($name => Some(ParticleType::$variant),)* _ => None }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
particle_types! {
|
||||
Block = "block",
|
||||
BlockCrumble = "block_crumble",
|
||||
Trail = "trail",
|
||||
DustPillar = "dust_pillar",
|
||||
FallingDust = "falling_dust",
|
||||
Smoke = "smoke",
|
||||
LargeSmoke = "large_smoke",
|
||||
WhiteSmoke = "white_smoke",
|
||||
Flame = "flame",
|
||||
SoulFireFlame = "soul_fire_flame",
|
||||
CopperFireFlame = "copper_fire_flame",
|
||||
SmallFlame = "small_flame",
|
||||
Lava = "lava",
|
||||
DrippingWater = "dripping_water",
|
||||
FallingWater = "falling_water",
|
||||
DrippingLava = "dripping_lava",
|
||||
FallingLava = "falling_lava",
|
||||
LandingLava = "landing_lava",
|
||||
DrippingHoney = "dripping_honey",
|
||||
FallingHoney = "falling_honey",
|
||||
LandingHoney = "landing_honey",
|
||||
FallingNectar = "falling_nectar",
|
||||
FallingSporeBlossom = "falling_spore_blossom",
|
||||
SporeBlossomAir = "spore_blossom_air",
|
||||
DrippingObsidianTear = "dripping_obsidian_tear",
|
||||
FallingObsidianTear = "falling_obsidian_tear",
|
||||
LandingObsidianTear = "landing_obsidian_tear",
|
||||
DrippingDripstoneWater = "dripping_dripstone_water",
|
||||
FallingDripstoneWater = "falling_dripstone_water",
|
||||
DrippingDripstoneLava = "dripping_dripstone_lava",
|
||||
FallingDripstoneLava = "falling_dripstone_lava",
|
||||
Splash = "splash",
|
||||
Rain = "rain",
|
||||
Bubble = "bubble",
|
||||
SulfurBubbles = "sulfur_bubbles",
|
||||
BubblePop = "bubble_pop",
|
||||
BubbleColumnUp = "bubble_column_up",
|
||||
CurrentDown = "current_down",
|
||||
Underwater = "underwater",
|
||||
Portal = "portal",
|
||||
ReversePortal = "reverse_portal",
|
||||
Enchant = "enchant",
|
||||
Nautilus = "nautilus",
|
||||
VaultConnection = "vault_connection",
|
||||
Crit = "crit",
|
||||
EnchantedHit = "enchanted_hit",
|
||||
DamageIndicator = "damage_indicator",
|
||||
Heart = "heart",
|
||||
AngryVillager = "angry_villager",
|
||||
HappyVillager = "happy_villager",
|
||||
Composter = "composter",
|
||||
Mycelium = "mycelium",
|
||||
Dolphin = "dolphin",
|
||||
EggCrack = "egg_crack",
|
||||
Poof = "poof",
|
||||
Explosion = "explosion",
|
||||
ExplosionEmitter = "explosion_emitter",
|
||||
CampfireCosySmoke = "campfire_cosy_smoke",
|
||||
CampfireSignalSmoke = "campfire_signal_smoke",
|
||||
Note = "note",
|
||||
Dust = "dust",
|
||||
TotemOfUndying = "totem_of_undying",
|
||||
Firework = "firework",
|
||||
EndRod = "end_rod",
|
||||
Ash = "ash",
|
||||
WhiteAsh = "white_ash",
|
||||
CrimsonSpore = "crimson_spore",
|
||||
WarpedSpore = "warped_spore",
|
||||
CherryLeaves = "cherry_leaves",
|
||||
PaleOakLeaves = "pale_oak_leaves",
|
||||
TintedLeaves = "tinted_leaves",
|
||||
Effect = "effect",
|
||||
InstantEffect = "instant_effect",
|
||||
EntityEffect = "entity_effect",
|
||||
Witch = "witch",
|
||||
Infested = "infested",
|
||||
Cloud = "cloud",
|
||||
Sneeze = "sneeze",
|
||||
Snowflake = "snowflake",
|
||||
Soul = "soul",
|
||||
SculkSoul = "sculk_soul",
|
||||
DragonBreath = "dragon_breath",
|
||||
Gust = "gust",
|
||||
SmallGust = "small_gust",
|
||||
GustEmitterLarge = "gust_emitter_large",
|
||||
GustEmitterSmall = "gust_emitter_small",
|
||||
DustColorTransition = "dust_color_transition",
|
||||
SculkCharge = "sculk_charge",
|
||||
SculkChargePop = "sculk_charge_pop",
|
||||
Shriek = "shriek",
|
||||
SonicBoom = "sonic_boom",
|
||||
Vibration = "vibration",
|
||||
SweepAttack = "sweep_attack",
|
||||
Glow = "glow",
|
||||
WaxOn = "wax_on",
|
||||
WaxOff = "wax_off",
|
||||
ElectricSpark = "electric_spark",
|
||||
Scrape = "scrape",
|
||||
SquidInk = "squid_ink",
|
||||
GlowSquidInk = "glow_squid_ink",
|
||||
Item = "item",
|
||||
ItemSlime = "item_slime",
|
||||
ItemSnowball = "item_snowball",
|
||||
ItemCobweb = "item_cobweb",
|
||||
SulfurCubeGoo = "sulfur_cube_goo",
|
||||
Flash = "flash",
|
||||
Firefly = "firefly",
|
||||
TrialSpawnerDetection = "trial_spawner_detection",
|
||||
TrialSpawnerDetectionOminous = "trial_spawner_detection_ominous",
|
||||
TrialOmen = "trial_omen",
|
||||
RaidOmen = "raid_omen",
|
||||
OminousSpawning = "ominous_spawning",
|
||||
Spit = "spit",
|
||||
Fishing = "fishing",
|
||||
DustPlume = "dust_plume",
|
||||
RedPoplarLeaves = "red_poplar_leaves",
|
||||
OrangePoplarLeaves = "orange_poplar_leaves",
|
||||
YellowPoplarLeaves = "yellow_poplar_leaves",
|
||||
PauseMobGrowth = "pause_mob_growth",
|
||||
ResetMobGrowth = "reset_mob_growth",
|
||||
BlockMarker = "block_marker",
|
||||
}
|
||||
impl ParticleType {
|
||||
/// `ParticleType.getOverrideLimiter` (`ParticleTypes.register(name, true)`):
|
||||
/// spawned regardless of distance.
|
||||
pub fn override_limiter(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::BlockMarker
|
||||
| Self::DamageIndicator
|
||||
| Self::ExplosionEmitter
|
||||
| Self::Explosion
|
||||
| Self::Gust
|
||||
| Self::GustEmitterLarge
|
||||
| Self::GustEmitterSmall
|
||||
| Self::SonicBoom
|
||||
| Self::SculkCharge
|
||||
| Self::SculkChargePop
|
||||
| Self::Vibration
|
||||
| Self::Poof
|
||||
| Self::Spit
|
||||
| Self::SquidInk
|
||||
| Self::SweepAttack
|
||||
| Self::CampfireCosySmoke
|
||||
| Self::CampfireSignalSmoke
|
||||
| Self::GlowSquidInk
|
||||
| Self::Glow
|
||||
| Self::WaxOn
|
||||
| Self::WaxOff
|
||||
| Self::ElectricSpark
|
||||
| Self::Scrape
|
||||
| Self::TrialSpawnerDetection
|
||||
| Self::TrialSpawnerDetectionOminous
|
||||
| Self::VaultConnection
|
||||
| Self::OminousSpawning
|
||||
)
|
||||
}
|
||||
/// `SingleQuadParticle.Layer.translucent`: types whose vanilla particle class
|
||||
/// hardcodes the translucent layer, drawn after translucent terrain.
|
||||
/// `TerrainParticle`, `BreakingItemParticle` and `BlockMarker` resolve their
|
||||
/// layer from the sprite instead (`Layer.bySprite`); see the `translucent`
|
||||
/// sprite set on the engine.
|
||||
pub fn translucent_layer(self) -> bool {
|
||||
match self {
|
||||
// CampfireSmokeParticle.
|
||||
Self::CampfireCosySmoke | Self::CampfireSignalSmoke
|
||||
// PlayerCloudParticle.
|
||||
| Self::Cloud | Self::Sneeze
|
||||
// SpellParticle.
|
||||
| Self::Effect | Self::InstantEffect | Self::EntityEffect
|
||||
| Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen
|
||||
// EmissiveRisingParticle.
|
||||
| Self::Soul | Self::SculkSoul
|
||||
// SculkChargeParticle, SculkChargePopParticle, ShriekParticle,
|
||||
// VibrationSignalParticle, FireflyParticle, OverlayParticle.
|
||||
| Self::SculkCharge | Self::SculkChargePop | Self::Shriek
|
||||
| Self::Vibration | Self::Firefly | Self::Flash
|
||||
// SimpleAnimatedParticle and its subclasses: EndRodParticle,
|
||||
// SparkParticle (firework), SquidInkParticle, TotemParticle.
|
||||
| Self::EndRod | Self::Firework | Self::SquidInk | Self::GlowSquidInk
|
||||
| Self::TotemOfUndying
|
||||
// FlyTowardsPositionParticle: its LifetimeAlpha(0, 0.6, 0.25, 1) is not opaque.
|
||||
| Self::VaultConnection => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Registry contents and source state that commands validate and complete
|
||||
//! arguments against (what the server's registry access holds).
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Registry contents and source state used to validate and complete arguments.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Registries {
|
||||
/// Permission level of the command source.
|
||||
pub permission: u8,
|
||||
pub items: Vec<String>,
|
||||
pub item_tags: Vec<String>,
|
||||
/// Block id -> (property, allowed values).
|
||||
pub blocks: BTreeMap<String, Vec<(String, Vec<String>)>>,
|
||||
pub block_tags: Vec<String>,
|
||||
/// Entity types that `summon` can create.
|
||||
pub summonable: Vec<String>,
|
||||
/// Every entity type (selector `type=`).
|
||||
pub entity_types: Vec<String>,
|
||||
pub effects: Vec<String>,
|
||||
pub enchantments: Vec<String>,
|
||||
pub structures: Vec<String>,
|
||||
pub biomes: Vec<String>,
|
||||
pub dimensions: Vec<String>,
|
||||
pub clocks: Vec<String>,
|
||||
pub timelines: Vec<String>,
|
||||
pub time_markers: Vec<String>,
|
||||
pub advancements: Vec<String>,
|
||||
/// Game rule name -> integer-valued.
|
||||
pub game_rules: Vec<(String, bool)>,
|
||||
pub players: Vec<String>,
|
||||
/// Scoreboard objective names, team names and tracked score holders.
|
||||
pub objectives: Vec<String>,
|
||||
pub teams: Vec<String>,
|
||||
pub tracked: Vec<String>,
|
||||
/// Loaded datapack functions (`ns:path`) and function tags (`ns:path`).
|
||||
pub functions: Vec<String>,
|
||||
/// `/data ... storage` ids.
|
||||
pub storages: Vec<String>,
|
||||
/// Boss bar ids and datapack predicate ids.
|
||||
pub bossbars: Vec<String>,
|
||||
pub predicates: Vec<String>,
|
||||
pub item_modifiers: Vec<String>,
|
||||
pub schedules: Vec<String>,
|
||||
pub stopwatches: Vec<String>,
|
||||
pub recipes: Vec<String>,
|
||||
pub sounds: Vec<String>,
|
||||
pub damage_types: Vec<String>,
|
||||
pub attributes: Vec<String>,
|
||||
pub loot_tables: Vec<String>,
|
||||
pub templates: Vec<String>,
|
||||
pub random_sequences: Vec<String>,
|
||||
pub datapacks_enabled: Vec<String>,
|
||||
pub datapacks_disabled: Vec<String>,
|
||||
pub function_tags: Vec<String>,
|
||||
/// The block the source is looking at (relevant coordinates).
|
||||
pub target_block: Option<[i32; 3]>,
|
||||
/// Every registry's element and tag ids (`minecraft:item` -> ...), as
|
||||
/// the server's registry access and reloadable registries hold them.
|
||||
pub by_registry: BTreeMap<String, RegistryIds>,
|
||||
/// Blocks whose default state has a block entity.
|
||||
pub block_entities: std::collections::BTreeSet<String>,
|
||||
/// Block tag -> member blocks.
|
||||
pub block_tag_members: BTreeMap<String, Vec<String>>,
|
||||
/// Data component types with a codec (not transient).
|
||||
pub persistent_components: Vec<String>,
|
||||
/// Recipes `/recipe` can give (not special).
|
||||
pub learnable_recipes: Vec<String>,
|
||||
/// Advancement id -> criterion names.
|
||||
pub advancement_criteria: BTreeMap<String, Vec<String>>,
|
||||
/// World clock -> time markers shown in commands.
|
||||
pub clock_time_markers: BTreeMap<String, Vec<String>>,
|
||||
/// Trigger objectives the source may use (`/trigger` suggestions).
|
||||
pub trigger_objectives: Vec<String>,
|
||||
/// Objectives with the `trigger` criterion -> holder -> whether its score is locked.
|
||||
pub trigger_scores: BTreeMap<String, BTreeMap<String, bool>>,
|
||||
}
|
||||
/// The ids and tag ids of one registry.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct RegistryIds {
|
||||
pub ids: Vec<String>,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
const EMPTY: &[String] = &[];
|
||||
impl Registries {
|
||||
/// Fill what the game did not build itself from vanilla's exported
|
||||
/// registries (`command-registries.json`, written by
|
||||
/// `VanillaCommandParseOracle registries`): built-in registries such as
|
||||
/// mob effects, data component types and dialogs, block entities and tag
|
||||
/// members. Registries that data packs reload stay with the game.
|
||||
pub fn fill_from_export(&mut self, r: &serde_json::Value) {
|
||||
fn strings(v: &serde_json::Value) -> Vec<String> {
|
||||
v.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|x| x.as_str().map(str::to_owned))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
let fill = |field: &mut Vec<String>, key: &str| {
|
||||
if field.is_empty() {
|
||||
*field = strings(&r[key]);
|
||||
}
|
||||
};
|
||||
fill(&mut self.effects, "effects");
|
||||
fill(&mut self.enchantments, "enchantments");
|
||||
fill(&mut self.attributes, "attributes");
|
||||
fill(&mut self.damage_types, "damage_types");
|
||||
fill(&mut self.templates, "templates");
|
||||
fill(&mut self.persistent_components, "persistent_components");
|
||||
fill(&mut self.learnable_recipes, "learnable_recipes");
|
||||
const RELOADED: [&str; 5] = [
|
||||
"minecraft:advancement",
|
||||
"minecraft:recipe",
|
||||
"minecraft:loot_table",
|
||||
"minecraft:predicate",
|
||||
"minecraft:item_modifier",
|
||||
];
|
||||
for (name, entry) in r["by_registry"].as_object().into_iter().flatten() {
|
||||
if RELOADED.contains(&name.as_str()) || self.by_registry.contains_key(name) {
|
||||
continue;
|
||||
}
|
||||
self.by_registry.insert(
|
||||
name.clone(),
|
||||
RegistryIds {
|
||||
ids: strings(&entry["ids"]),
|
||||
tags: strings(&entry["tags"]),
|
||||
},
|
||||
);
|
||||
}
|
||||
if self.block_entities.is_empty() {
|
||||
self.block_entities = strings(&r["block_entities"]).into_iter().collect();
|
||||
}
|
||||
if self.block_tag_members.is_empty() {
|
||||
for (tag, members) in r["block_tag_members"].as_object().into_iter().flatten() {
|
||||
self.block_tag_members.insert(tag.clone(), strings(members));
|
||||
}
|
||||
}
|
||||
if self.clock_time_markers.is_empty() {
|
||||
for (clock, markers) in r["clock_time_markers"].as_object().into_iter().flatten() {
|
||||
self.clock_time_markers
|
||||
.insert(clock.clone(), strings(markers));
|
||||
}
|
||||
}
|
||||
if self.advancement_criteria.is_empty() {
|
||||
for (id, criteria) in r["advancement_criteria"].as_object().into_iter().flatten() {
|
||||
self.advancement_criteria
|
||||
.insert(id.clone(), strings(criteria));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The element ids of a registry (sorted).
|
||||
pub fn ids(&self, registry: &str) -> &[String] {
|
||||
if let Some(r) = self.by_registry.get(registry) {
|
||||
return &r.ids;
|
||||
}
|
||||
match registry {
|
||||
"minecraft:item" => &self.items,
|
||||
"minecraft:entity_type" => &self.entity_types,
|
||||
"minecraft:mob_effect" => &self.effects,
|
||||
"minecraft:enchantment" => &self.enchantments,
|
||||
"minecraft:worldgen/structure" => &self.structures,
|
||||
"minecraft:worldgen/biome" => &self.biomes,
|
||||
"minecraft:world_clock" => &self.clocks,
|
||||
"minecraft:timeline" => &self.timelines,
|
||||
"minecraft:attribute" => &self.attributes,
|
||||
"minecraft:damage_type" => &self.damage_types,
|
||||
"minecraft:sound_event" => &self.sounds,
|
||||
"minecraft:predicate" => &self.predicates,
|
||||
"minecraft:item_modifier" => &self.item_modifiers,
|
||||
"minecraft:loot_table" => &self.loot_tables,
|
||||
"minecraft:recipe" => &self.recipes,
|
||||
"minecraft:advancement" => &self.advancements,
|
||||
_ => EMPTY,
|
||||
}
|
||||
}
|
||||
/// The tag ids of a registry (sorted).
|
||||
pub fn tags(&self, registry: &str) -> &[String] {
|
||||
if let Some(r) = self.by_registry.get(registry) {
|
||||
return &r.tags;
|
||||
}
|
||||
match registry {
|
||||
"minecraft:item" => &self.item_tags,
|
||||
"minecraft:block" => &self.block_tags,
|
||||
_ => EMPTY,
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -2,9 +2,9 @@
|
||||
//! `ObjectiveCriteria`, `NumberFormat`) and its saved data
|
||||
//! (`data/minecraft/scoreboard.dat`, `ScoreboardSaveData.Packed`).
|
||||
use crate::{
|
||||
command::Registries,
|
||||
component,
|
||||
fastutil_order::{HashSetOrder, OpenOrder},
|
||||
registries::Registries,
|
||||
text::{self, Style, Text},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
@@ -1266,7 +1266,7 @@ fn entity_uuids(tag: &mut Tag) {
|
||||
})
|
||||
.collect::<Option<Vec<i32>>>(),
|
||||
Tag::String(s) => {
|
||||
crate::save::uuid_from_text(&s.to_string_lossy()).map(Vec::from)
|
||||
crate::uuid::uuid_from_text(&s.to_string_lossy()).map(Vec::from)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
File renamed without changes.
@@ -0,0 +1,7 @@
|
||||
//! Sound sources (`SoundSource`), by name.
|
||||
|
||||
/// SoundSource names in vanilla order.
|
||||
pub const SOUND_SOURCES: [&str; 11] = [
|
||||
"master", "music", "record", "weather", "block", "hostile", "neutral", "player", "ambient",
|
||||
"voice", "ui",
|
||||
];
|
||||
@@ -42,6 +42,10 @@ pub fn named_color(name: &str) -> Option<u32> {
|
||||
/// Click actions carried by locally constructed text components.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ClickEvent {
|
||||
/// `ClickEvent.OpenUrl`: an http or https link.
|
||||
OpenUrl(String),
|
||||
/// `ClickEvent.ChangePage`: a book's page, counted from 1.
|
||||
ChangePage(i32),
|
||||
SuggestCommand(String),
|
||||
CopyToClipboard(String),
|
||||
/// A local file to open with the system handler.
|
||||
@@ -67,7 +71,7 @@ pub struct Style {
|
||||
}
|
||||
/// A style's `font` field: an identifier (`alt` is `minecraft:alt`).
|
||||
pub fn font_id(v: &Value) -> Option<String> {
|
||||
crate::command::args::parse_identifier(v.as_str()?)
|
||||
crate::identifier::parse_identifier(v.as_str()?)
|
||||
}
|
||||
impl Style {
|
||||
pub fn color(c: u32) -> Self {
|
||||
@@ -529,6 +533,8 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
}
|
||||
if let Some(click) = &style.click {
|
||||
let event = match click {
|
||||
ClickEvent::OpenUrl(url) => json!({ "action": "open_url", "url": url }),
|
||||
ClickEvent::ChangePage(page) => json!({ "action": "change_page", "page": page }),
|
||||
ClickEvent::SuggestCommand(command) => {
|
||||
json!({ "action": "suggest_command", "command": command })
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! UUIDs as vanilla's four-int form, to and from their text form.
|
||||
|
||||
pub fn uuid_text(u: [i32; 4]) -> String {
|
||||
let hex: String = u.iter().map(|v| format!("{:08x}", *v as u32)).collect();
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&hex[0..8],
|
||||
&hex[8..12],
|
||||
&hex[12..16],
|
||||
&hex[16..20],
|
||||
&hex[20..32]
|
||||
)
|
||||
}
|
||||
pub fn uuid_from_text(s: &str) -> Option<[i32; 4]> {
|
||||
let hex: String = s.chars().filter(|c| *c != '-').collect();
|
||||
if hex.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0i32; 4];
|
||||
for (i, v) in out.iter_mut().enumerate() {
|
||||
*v = u32::from_str_radix(&hex[i * 8..i * 8 + 8], 16).ok()? as i32;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -9,6 +9,8 @@ description = "Horizon native development client"
|
||||
|
||||
[dependencies]
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
|
||||
@@ -163,6 +163,8 @@ pub fn main() -> Result<()> {
|
||||
ArrowSound::HitGround => "hit",
|
||||
ArrowSound::Splash => "splash",
|
||||
ArrowSound::Extinguish => "extinguish",
|
||||
ArrowSound::TridentHitGround => "trident_hit_ground",
|
||||
ArrowSound::TridentHit => "trident_hit",
|
||||
},
|
||||
volume.to_bits(),
|
||||
pitch.to_bits()
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
use horizon_client::{
|
||||
Result,
|
||||
item_data::StackData,
|
||||
items::{ItemStack, Items},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<_> = crate::args().collect();
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
let items = Items::catalog(&root)?;
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for line in BufReader::new(File::open(&args[1])?).lines() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
let mut stack = ItemStack::new(
|
||||
row["item"].as_str().unwrap(),
|
||||
row["count"].as_u64().unwrap() as u32,
|
||||
);
|
||||
match row["tool"].as_str().unwrap() {
|
||||
"default" => {}
|
||||
mode => {
|
||||
let mut data = StackData::default();
|
||||
let tool = if mode == "implicit" {
|
||||
Tag::compound([] as [(&str, Tag); 0])
|
||||
} else {
|
||||
Tag::compound([(
|
||||
"can_destroy_blocks_in_creative",
|
||||
Tag::Byte(i8::from(mode == "allow")),
|
||||
)])
|
||||
};
|
||||
data.set_other("minecraft:tool", Some(tool));
|
||||
stack.extra = Some(Box::new(data));
|
||||
}
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
items.can_destroy_block(Some(&stack), row["creative"].as_bool().unwrap())
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -592,6 +592,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
pose,
|
||||
num("maxCrossbowChageDuration"),
|
||||
num("ticksUsingItem"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator => {
|
||||
|
||||
@@ -23,6 +23,7 @@ fn stack(spec: &str) -> LootStack {
|
||||
map: false,
|
||||
exploration: None,
|
||||
ominous_amplifier: None,
|
||||
stew_effects: Vec::new(),
|
||||
};
|
||||
for e in parts {
|
||||
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
|
||||
|
||||
@@ -27,6 +27,7 @@ mod chat_screen_fixture;
|
||||
mod cloud_fixture;
|
||||
mod command_parse_fixture;
|
||||
mod container_screen_probe;
|
||||
mod creative_mining_probe;
|
||||
mod creative_search_fixture;
|
||||
mod cushion_placement_probe;
|
||||
mod emitter_particles_probe;
|
||||
@@ -84,6 +85,7 @@ mod sound_engine_fixture;
|
||||
mod sound_event_fixture;
|
||||
mod spawn_rules_fixture;
|
||||
mod sprint_probe;
|
||||
mod squid_client_probe;
|
||||
mod subtitle_fixture;
|
||||
mod tab_list_fixture;
|
||||
mod team_rules_fixture;
|
||||
@@ -108,6 +110,8 @@ probes! {
|
||||
block_transform_fixture,
|
||||
breaking_fixture,
|
||||
camera_probe,
|
||||
squid_client_probe,
|
||||
creative_mining_probe,
|
||||
cushion_placement_probe,
|
||||
cauldron_fixture,
|
||||
chat_screen_fixture,
|
||||
|
||||
@@ -1057,6 +1057,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
velocity: Vec3::ZERO,
|
||||
yaw: 0.0,
|
||||
hero: false,
|
||||
effects: Vec::new(),
|
||||
uuid: PLAYER_UUID,
|
||||
allies: Default::default(),
|
||||
remote: false,
|
||||
|
||||
@@ -164,6 +164,8 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> {
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
MotionEvent::Fall(e) => Some(e.trace()),
|
||||
// The oracle's player wears no armor; the game applies this one.
|
||||
MotionEvent::Health(horizon_client::health::HealthEvent::ArmorHurt { .. }) => None,
|
||||
MotionEvent::Health(e) => Some(e.trace()),
|
||||
MotionEvent::Snowflake { position, velocity } => Some(format!(
|
||||
"snowflake {:016x},{:016x},{:016x} {:016x},{:016x},{:016x}",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
use horizon_client::{
|
||||
Result,
|
||||
mobs::{Mob, MobTypes, aquatic},
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<_> = crate::args().collect();
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
let types = MobTypes::load(&root)?;
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for line in BufReader::new(File::open(&args[1])?).lines() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
let kind = Arc::new(
|
||||
types.types[&format!("minecraft:{}", row["species"].as_str().unwrap())].clone(),
|
||||
);
|
||||
let mut mob = Mob::new(1, kind, Vec3::ZERO, 0.0);
|
||||
mob.fluids.in_water = row["water"].as_bool().unwrap();
|
||||
mob.velocity = Vec3::new(
|
||||
row["x"].as_f64().unwrap(),
|
||||
row["y"].as_f64().unwrap(),
|
||||
row["z"].as_f64().unwrap(),
|
||||
);
|
||||
mob.body_yaw = row["body"].as_f64().unwrap() as f32;
|
||||
for (i, value) in row["v"].as_array().unwrap().iter().enumerate() {
|
||||
mob.anim.v[i] = value.as_f64().unwrap() as f32;
|
||||
}
|
||||
if row["reset"].as_bool().unwrap() {
|
||||
aquatic::squid_cycle_event(&mut mob);
|
||||
}
|
||||
let velocity = mob.velocity;
|
||||
aquatic::squid_client_step(&mut mob);
|
||||
assert_eq!(
|
||||
velocity, mob.velocity,
|
||||
"a remote animation must preserve packet velocity"
|
||||
);
|
||||
write!(out, "{:08x}", mob.body_yaw.to_bits())?;
|
||||
for value in &mob.anim.v[..6] {
|
||||
write!(out, " {:08x}", value.to_bits())?;
|
||||
}
|
||||
writeln!(out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -35,8 +35,9 @@ enum Animate {
|
||||
Campfire {
|
||||
spawn_particles: bool,
|
||||
},
|
||||
/// Leaves: optional falling-leaf particle and chance, tinted by foliage.
|
||||
Leaves(Option<(T, f32)>),
|
||||
/// Leaves: optional falling-leaf particle and chance; a tinted leaf takes
|
||||
/// the biome foliage colour unless a fixed colour is given (azalea).
|
||||
Leaves(Option<(T, f32)>, Option<u32>),
|
||||
NetherPortal,
|
||||
EnchantingTable,
|
||||
EndRod([i32; 3]),
|
||||
@@ -49,6 +50,8 @@ enum Animate {
|
||||
EnderChest,
|
||||
/// FallingBlock with its dust colour.
|
||||
Falling(u32),
|
||||
/// A beehive or bee nest full of honey (`BeehiveBlock.animateTick`).
|
||||
Beehive,
|
||||
RedstoneOre,
|
||||
RedstoneWire {
|
||||
power: u8,
|
||||
@@ -104,10 +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))),
|
||||
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1))),
|
||||
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02))),
|
||||
"spruce_leaves" | "azalea_leaves" | "flowering_azalea_leaves" => Animate::Leaves(None),
|
||||
| "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))
|
||||
}
|
||||
"spruce_leaves" => Animate::Leaves(None, None),
|
||||
"nether_portal" => Animate::NetherPortal,
|
||||
"enchanting_table" => Animate::EnchantingTable,
|
||||
"end_rod" => Animate::EndRod(facing()),
|
||||
@@ -119,6 +129,14 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
"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),
|
||||
"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" => {
|
||||
let power = prop("power").and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||
@@ -161,6 +179,27 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
_ => Animate::None,
|
||||
}
|
||||
}
|
||||
/// `DyeColor.getMapColor` of a concrete powder colour.
|
||||
fn concrete_powder_color(dye: &str) -> u32 {
|
||||
match dye {
|
||||
"white" => 0xFFFFFF,
|
||||
"orange" => 0xD87F33,
|
||||
"magenta" => 0xB24CD8,
|
||||
"light_blue" => 0x6699D8,
|
||||
"yellow" => 0xE5E533,
|
||||
"lime" => 0x7FCC19,
|
||||
"pink" => 0xF27FA5,
|
||||
"gray" => 0x4C4C4C,
|
||||
"light_gray" => 0x999999,
|
||||
"cyan" => 0x4C7F99,
|
||||
"purple" => 0x7F3FB2,
|
||||
"blue" => 0x334CB2,
|
||||
"brown" => 0x664C33,
|
||||
"green" => 0x667F33,
|
||||
"red" => 0x993333,
|
||||
_ => 0x191919,
|
||||
}
|
||||
}
|
||||
/// `RedstoneWireBlock.COLORS`.
|
||||
fn redstone_color(power: u8) -> [f32; 3] {
|
||||
let f = f32::from(power) / 15.0;
|
||||
@@ -504,6 +543,53 @@ fn try_spawn_drip(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `BeehiveBlock.trySpawnDripParticles`.
|
||||
fn beehive_drip(
|
||||
tables: &AnimateTables,
|
||||
engine: &mut ParticleEngine,
|
||||
ctx: &AnimateContext,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
) {
|
||||
if ctx.facts.get(state).fluid != Fluid::None || engine.random().next_float() < 0.3 {
|
||||
return;
|
||||
}
|
||||
let Some((bottom, top, bounds)) = ctx.shape_y(pos) else {
|
||||
return;
|
||||
};
|
||||
if top < 1.0
|
||||
|| tables
|
||||
.impermeable
|
||||
.get(state.0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let y = if bottom > 0.0 {
|
||||
f64::from(pos[1]) + bottom - 0.05
|
||||
} else {
|
||||
let below = [pos[0], pos[1] - 1, pos[2]];
|
||||
let below_top = ctx.shape_y(below).map_or(1.0, |s| s.1);
|
||||
// `belowTop < 1 || !isCollisionShapeFullBlock` and no fluid below.
|
||||
if (below_top < 1.0 || !ctx.sturdy(below))
|
||||
&& ctx.facts.get(ctx.state(below)).fluid == Fluid::None
|
||||
{
|
||||
f64::from(pos[1]) - 0.05
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let r = engine.random();
|
||||
let (a, b) = (r.next_double(), r.next_double());
|
||||
let (fx, fz) = (f64::from(pos[0]), f64::from(pos[2]));
|
||||
let p = [
|
||||
fx + bounds[0] + (bounds[1] - bounds[0]) * a,
|
||||
y,
|
||||
fz + bounds[2] + (bounds[3] - bounds[2]) * b,
|
||||
];
|
||||
add(engine, T::DrippingHoney, p, [0.0; 3]);
|
||||
}
|
||||
/// `WaterFluid.animateTick` / `LavaFluid.animateTick`.
|
||||
fn fluid_animate_tick(
|
||||
engine: &mut ParticleEngine,
|
||||
@@ -685,7 +771,7 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::Leaves(falling) => {
|
||||
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
|
||||
@@ -705,7 +791,7 @@ fn block_animate_tick(
|
||||
let r = engine.random();
|
||||
let p = [fx + r.next_double(), fy - 0.05, fz + r.next_double()];
|
||||
let options = if ty == T::TintedLeaves {
|
||||
let c = ctx.foliage(pos);
|
||||
let c = fixed_tint.unwrap_or_else(|| ctx.foliage(pos));
|
||||
let f = |s: u32| ((c >> s) & 255) as f32 / 255.0;
|
||||
ParticleOptions::colored(ty, [f(16), f(8), f(0), 1.0])
|
||||
} else {
|
||||
@@ -977,6 +1063,13 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::Beehive => {
|
||||
// BeehiveBlock.animateTick: nextInt(1) + 1 attempts.
|
||||
let attempts = next_int(engine.random(), 1) + 1;
|
||||
for _ in 0..attempts {
|
||||
beehive_drip(tables, engine, ctx, state, pos);
|
||||
}
|
||||
}
|
||||
Animate::RedstoneOre => {
|
||||
for d in [
|
||||
[0, -1, 0],
|
||||
@@ -1209,32 +1302,71 @@ fn candle(engine: &mut ParticleEngine, p: [f64; 3]) {
|
||||
}
|
||||
add(engine, T::SmallFlame, p, [0.0; 3]);
|
||||
}
|
||||
/// `CampfireBlockEntity.particleTick` for every lit campfire (no cooking items).
|
||||
/// `CampfireBlockEntity.particleTick` for every lit campfire; `cooking` says
|
||||
/// which of a campfire's four slots hold an item.
|
||||
pub fn campfire_particles<'a>(
|
||||
tables: &AnimateTables,
|
||||
engine: &mut ParticleEngine,
|
||||
ctx: &AnimateContext,
|
||||
blocks: impl Iterator<Item = &'a ([i32; 3], u32)>,
|
||||
cooking: &dyn Fn([i32; 3]) -> [bool; 4],
|
||||
) {
|
||||
for &(pos, state) in blocks {
|
||||
if !tables.is_lit_campfire(state) {
|
||||
continue;
|
||||
}
|
||||
let signal = ctx
|
||||
let properties = ctx
|
||||
.registry
|
||||
.state(BlockStateId(state))
|
||||
.is_some_and(|s| s.properties.get("signal_fire").map(String::as_str) == Some("true"));
|
||||
.map(|s| &s.properties);
|
||||
let prop = |k: &str| properties.and_then(|p| p.get(k)).map(String::as_str);
|
||||
let signal = prop("signal_fire") == Some("true");
|
||||
// Direction.get2DDataValue: south, west, north, east.
|
||||
let rotation = match prop("facing") {
|
||||
Some("west") => 1,
|
||||
Some("north") => 2,
|
||||
Some("east") => 3,
|
||||
_ => 0,
|
||||
};
|
||||
let r = engine.random();
|
||||
if r.next_float() < 0.11 {
|
||||
let count = next_int(r, 2) + 2;
|
||||
for _ in 0..count {
|
||||
make_campfire_smoke(engine, pos, signal);
|
||||
make_campfire_smoke(engine, pos, signal, false);
|
||||
}
|
||||
}
|
||||
// The smoke over each cooking item, at the slot's corner of the fire.
|
||||
let occupied = cooking(pos);
|
||||
for (slot, _) in occupied.iter().enumerate().filter(|(_, held)| **held) {
|
||||
if engine.random().next_float() >= 0.2 {
|
||||
continue;
|
||||
}
|
||||
// Direction.from2DDataValue((slot + rotation) % 4) and its clockwise side.
|
||||
let (step, clockwise) = match (slot + rotation) % 4 {
|
||||
0 => ([0.0, 1.0], [-1.0, 0.0]),
|
||||
1 => ([-1.0, 0.0], [0.0, -1.0]),
|
||||
2 => ([0.0, -1.0], [1.0, 0.0]),
|
||||
_ => ([1.0, 0.0], [0.0, 1.0]),
|
||||
};
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5 - step[0] * 0.3125 + clockwise[0] * 0.3125,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5 - step[1] * 0.3125 + clockwise[1] * 0.3125,
|
||||
];
|
||||
for _ in 0..4 {
|
||||
add(engine, T::Smoke, at, [0.0, 5.0e-4, 0.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter).
|
||||
pub fn make_campfire_smoke(engine: &mut ParticleEngine, pos: [i32; 3], signal: bool) {
|
||||
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter);
|
||||
/// `smoking` adds the plain smoke puff at the logs.
|
||||
pub fn make_campfire_smoke(
|
||||
engine: &mut ParticleEngine,
|
||||
pos: [i32; 3],
|
||||
signal: bool,
|
||||
smoking: bool,
|
||||
) {
|
||||
let r = engine.random();
|
||||
let sign = |r: &mut LegacyRandom| if r.next_boolean() { 1.0 } else { -1.0 };
|
||||
let dx = r.next_double() / 3.0;
|
||||
@@ -1248,6 +1380,17 @@ pub fn make_campfire_smoke(engine: &mut ParticleEngine, pos: [i32; 3], signal: b
|
||||
T::CampfireCosySmoke
|
||||
};
|
||||
engine.create(&ParticleOptions::simple(ty), [x, y, z], [0.0, 0.07, 0.0]);
|
||||
if smoking {
|
||||
let r = engine.random();
|
||||
let dx = r.next_double() / 4.0 * sign(r);
|
||||
let dz = r.next_double() / 4.0 * sign(r);
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5 + dx,
|
||||
f64::from(pos[1]) + 0.4,
|
||||
f64::from(pos[2]) + 0.5 + dz,
|
||||
];
|
||||
engine.create(&ParticleOptions::simple(T::Smoke), at, [0.0, 0.005, 0.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
collisions::SceneCollisions,
|
||||
mobs::Who,
|
||||
motion::{FluidResources, LAVA, WATER},
|
||||
projectiles::{Hit, Projectile},
|
||||
projectiles::{Hit, Projectile, ProjectileKind},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -132,6 +132,10 @@ pub enum ArrowSound {
|
||||
HitGround,
|
||||
/// `entity.generic.extinguish_fire`.
|
||||
Extinguish,
|
||||
/// A thrown trident's `getDefaultHitGroundSoundEvent` (`item.trident.hit_ground`).
|
||||
TridentHitGround,
|
||||
/// `ThrownTrident.onHitEntity`'s `item.trident.hit`.
|
||||
TridentHit,
|
||||
}
|
||||
/// What a burning arrow does to the block it hits (`onProjectileHit`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -156,7 +160,11 @@ pub struct ArrowState {
|
||||
/// `pickupItemStack`: what picking the arrow up gives.
|
||||
pub pickup_item: crate::items::ItemStack,
|
||||
pub no_physics: bool,
|
||||
/// `AbstractArrow.getPierceLevel`.
|
||||
pub pierce: u8,
|
||||
/// `piercingIgnoreEntityIds`: the entities a piercing arrow has gone
|
||||
/// through (cleared when it sticks in a block).
|
||||
pub pierced: Vec<Who>,
|
||||
/// `Entity.remainingFireTicks`.
|
||||
pub fire_ticks: i32,
|
||||
pub fall_distance: f64,
|
||||
@@ -165,6 +173,9 @@ pub struct ArrowState {
|
||||
pub was_eye_in_water: bool,
|
||||
pub first_tick: bool,
|
||||
pub has_been_shot: bool,
|
||||
/// `ThrownTrident.dealtDamage`: it has hit an entity (or sat in a block
|
||||
/// for over 4 ticks) and hits no more.
|
||||
pub dealt_damage: bool,
|
||||
pub removed: bool,
|
||||
/// The arrow's own random (`Entity.random`).
|
||||
pub random: LegacyRandom,
|
||||
@@ -199,6 +210,7 @@ impl ArrowState {
|
||||
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
|
||||
no_physics: false,
|
||||
pierce: 0,
|
||||
pierced: Vec::new(),
|
||||
fire_ticks: 0,
|
||||
fall_distance: 0.0,
|
||||
fluids: EntityFluidInteraction::new([WATER, LAVA]),
|
||||
@@ -206,6 +218,7 @@ impl ArrowState {
|
||||
was_eye_in_water: false,
|
||||
first_tick: true,
|
||||
has_been_shot: false,
|
||||
dealt_damage: false,
|
||||
removed: false,
|
||||
random,
|
||||
events: Vec::new(),
|
||||
@@ -362,6 +375,29 @@ fn bounds(at: Vec3) -> Aabb {
|
||||
Vec3::new(at.x + w, at.y + f64::from(HEIGHT), at.z + w),
|
||||
)
|
||||
}
|
||||
/// The bounding box of an arrow, or a thrown trident, at `at`.
|
||||
pub fn arrow_bounds(at: Vec3) -> Aabb {
|
||||
bounds(at)
|
||||
}
|
||||
/// The blocks an arrow that moved from `from` to `to` was inside
|
||||
/// (`Entity.checkInsideBlocks`: the sweep of its box, 16 steps at most), for
|
||||
/// the blocks that react to it (`entityInside`).
|
||||
pub fn blocks_inside(from: Vec3, to: Vec3) -> Vec<BlockPos> {
|
||||
let deflated = bounds(to).deflate(Vec3::new(
|
||||
f64::from(1.0e-5f32),
|
||||
f64::from(1.0e-5f32),
|
||||
f64::from(1.0e-5f32),
|
||||
));
|
||||
let mut inside = Vec::new();
|
||||
let _ = sweep_intersected_blocks(from, to, deflated, 1_000_000, |p, iteration| {
|
||||
if iteration >= 16 {
|
||||
return false;
|
||||
}
|
||||
inside.push(p);
|
||||
true
|
||||
});
|
||||
inside
|
||||
}
|
||||
/// `Projectile.lerpRotation`.
|
||||
fn lerp_rotation(mut old: f32, new: f32) -> f32 {
|
||||
while new - old < -180.0 {
|
||||
@@ -423,18 +459,29 @@ pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: Entity
|
||||
let Some(mut st) = p.arrow.take() else {
|
||||
return false;
|
||||
};
|
||||
let trident = matches!(p.kind, ProjectileKind::Trident);
|
||||
let mut ticker = Ticker {
|
||||
p,
|
||||
st: &mut st,
|
||||
scene,
|
||||
};
|
||||
match outcome {
|
||||
EntityHit::Hurt | EntityHit::Refused if trident => ticker.trident_bounce(),
|
||||
EntityHit::Hurt => {
|
||||
ticker.hit_sound();
|
||||
ticker.st.removed = true;
|
||||
// A piercing arrow flies on (`getPierceLevel() <= 0` discards).
|
||||
if ticker.st.pierce == 0 {
|
||||
ticker.st.removed = true;
|
||||
}
|
||||
}
|
||||
EntityHit::Refused => ticker.turn_back(),
|
||||
EntityHit::Deflected => {}
|
||||
EntityHit::Deflected => {
|
||||
// The deflection comes before `onHitEntity` adds the entity to
|
||||
// `piercingIgnoreEntityIds`.
|
||||
if ticker.st.pierce > 0 {
|
||||
ticker.st.pierced.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
ticker.finish();
|
||||
p.stuck = st.in_ground;
|
||||
@@ -464,7 +511,16 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
p.z,
|
||||
))
|
||||
}
|
||||
/// Whether this is a `ThrownTrident` (`AbstractArrow` with its own water
|
||||
/// inertia, sounds and hit).
|
||||
fn trident(&self) -> bool {
|
||||
matches!(self.p.kind, ProjectileKind::Trident)
|
||||
}
|
||||
fn tick(&mut self, targets: &[(Who, Aabb)]) -> Hit {
|
||||
// ThrownTrident.tick.
|
||||
if self.trident() && self.st.in_ground_time > 4 {
|
||||
self.st.dealt_damage = true;
|
||||
}
|
||||
let physics = !self.st.no_physics;
|
||||
let movement = self.p.velocity;
|
||||
let block_pos = self.block_position();
|
||||
@@ -512,8 +568,9 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
self.st.in_ground_time = 0;
|
||||
let original = self.position();
|
||||
if self.st.was_touching_water {
|
||||
// getWaterInertia, then the bubbles behind it.
|
||||
self.p.velocity = self.p.velocity.scale(f64::from(0.6f32));
|
||||
// getWaterInertia (0.99 for a trident), then the bubbles behind it.
|
||||
let inertia = if self.trident() { 0.99f32 } else { 0.6f32 };
|
||||
self.p.velocity = self.p.velocity.scale(f64::from(inertia));
|
||||
let v = self.p.velocity;
|
||||
for _ in 0..4 {
|
||||
self.st.events.push(ArrowEvent::Particle {
|
||||
@@ -639,7 +696,8 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
self.st.life = 0;
|
||||
}
|
||||
/// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the
|
||||
/// block (piercing is not modelled).
|
||||
/// block. A piercing arrow that hits an entity here goes on from it next
|
||||
/// tick (`finish_entity_hit`); vanilla's loop goes on within the same tick.
|
||||
fn step_move_and_hit(&mut self, from: Vec3, to: Vec3, targets: &[(Who, Aabb)]) -> Hit {
|
||||
let block = self.scene.clip(from, to);
|
||||
let end = block.map_or(to, |b| b.0);
|
||||
@@ -649,10 +707,16 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
.expand_towards(self.p.velocity)
|
||||
.inflate(Vec3::new(1.0, 1.0, 1.0));
|
||||
let mut best: Option<(Who, Vec3, f64)> = None;
|
||||
// ThrownTrident.findHitEntity: none once it has dealt damage.
|
||||
let targets = if self.st.dealt_damage { &[] } else { targets };
|
||||
for (who, b) in targets {
|
||||
if Some(*who) == self.p.owner && !self.p.left_owner || !b.intersects(area) {
|
||||
continue;
|
||||
}
|
||||
// canHitEntity: not an entity a piercing arrow has gone through.
|
||||
if self.st.pierced.contains(who) {
|
||||
continue;
|
||||
}
|
||||
if let Some(at) = self.entity_hit(b, from, end, margin) {
|
||||
// Sorted by the distance to the entity's position (the
|
||||
// stable sort keeps the first of equals).
|
||||
@@ -667,6 +731,15 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
self.p.position = next;
|
||||
self.apply_effects_from_blocks(from, next);
|
||||
if let Some((who, at, _)) = best {
|
||||
// AbstractArrow.onHitEntity: past pierce level + 1 entities the
|
||||
// arrow is discarded before it hurts the next one.
|
||||
if self.st.pierce > 0 {
|
||||
if self.st.pierced.len() > usize::from(self.st.pierce) {
|
||||
self.st.removed = true;
|
||||
return Hit::None;
|
||||
}
|
||||
self.st.pierced.push(who);
|
||||
}
|
||||
return Hit::Entity { who, at };
|
||||
}
|
||||
let Some((at, pos, face)) = block else {
|
||||
@@ -706,12 +779,33 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
/// `playSound(soundEvent, 1, 1.2 / (random * 0.2 + 0.9))`.
|
||||
fn hit_sound(&mut self) {
|
||||
let pitch = 1.2 / (self.st.random.next_float() * 0.2 + 0.9);
|
||||
let sound = if self.trident() {
|
||||
ArrowSound::TridentHitGround
|
||||
} else {
|
||||
ArrowSound::HitGround
|
||||
};
|
||||
self.st.events.push(ArrowEvent::Sound {
|
||||
sound: ArrowSound::HitGround,
|
||||
sound,
|
||||
volume: 1.0,
|
||||
pitch,
|
||||
});
|
||||
}
|
||||
/// `ThrownTrident.onHitEntity` after the hurt, whether it landed or not:
|
||||
/// the hit sound, then `deflect(REVERSE, ..., (0.02, 0.2, 0.02))`. The
|
||||
/// trident keeps flying (it has dealt its damage) and falls.
|
||||
fn trident_bounce(&mut self) {
|
||||
self.st.dealt_damage = true;
|
||||
self.st.events.push(ArrowEvent::Sound {
|
||||
sound: ArrowSound::TridentHit,
|
||||
volume: 1.0,
|
||||
pitch: 1.0,
|
||||
});
|
||||
// ProjectileDeflection.REVERSE.
|
||||
let rotation = 170.0 + self.st.random.next_float() * 20.0;
|
||||
let v = self.p.velocity;
|
||||
self.p.velocity = Vec3::new(v.x * (-0.02 * 0.5), v.y * (-0.2 * 0.5), v.z * (-0.02 * 0.5));
|
||||
self.p.yaw += rotation;
|
||||
}
|
||||
/// `onHitEntity` when the entity took no damage: `deflect(REVERSE, 0.2)`,
|
||||
/// a half turn give or take ten degrees at a tenth of the speed; one
|
||||
/// left almost still drops its pickup (ALLOWED) and is discarded.
|
||||
@@ -752,6 +846,7 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
self.st.shake = 7;
|
||||
self.p.set_critical(false);
|
||||
self.st.pierce = 0;
|
||||
self.st.pierced.clear();
|
||||
}
|
||||
/// `Projectile.mayInteract`: a mob's arrows need `mob_griefing`.
|
||||
fn may_interact(&self) -> bool {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Biome colours from the pinned biome definitions and colormaps
|
||||
//! (`Biome.getGrassColor`/`getFoliageColor`/..., `BlockTintSources`).
|
||||
use crate::Result;
|
||||
use horizon_core::{noise::SimplexNoise, random::LegacyRandom};
|
||||
use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
|
||||
@@ -28,8 +28,13 @@ pub struct BiomeInfo {
|
||||
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
|
||||
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
|
||||
pub creature_probability: f32,
|
||||
/// The `gameplay/increased_fire_burnout` attribute (jungles, swamps, peaks,
|
||||
/// mushroom fields).
|
||||
pub increased_fire_burnout: bool,
|
||||
pub name: String,
|
||||
pub temperature: f32,
|
||||
/// `temperature_modifier: frozen` (the frozen oceans).
|
||||
pub frozen: bool,
|
||||
pub downfall: f32,
|
||||
pub precipitation: bool,
|
||||
pub grass: u32,
|
||||
@@ -42,6 +47,26 @@ pub struct BiomeInfo {
|
||||
pub fog: Option<u32>,
|
||||
pub water_fog: u32,
|
||||
}
|
||||
impl BiomeInfo {
|
||||
/// `Biome.getHeightAdjustedTemperature`: the frozen modifier, then cooler
|
||||
/// with height above `sea_level + 17`.
|
||||
pub fn temperature_at(&self, pos: BlockPos, sea_level: i32) -> f32 {
|
||||
use horizon_world::worldgen::biome_temperature::{
|
||||
BiomeTemperatureSettings, TemperatureModifier,
|
||||
};
|
||||
BiomeTemperatureSettings {
|
||||
base_temperature: self.temperature,
|
||||
modifier: if self.frozen {
|
||||
TemperatureModifier::Frozen
|
||||
} else {
|
||||
TemperatureModifier::None
|
||||
},
|
||||
has_precipitation: self.precipitation,
|
||||
downfall: self.downfall,
|
||||
}
|
||||
.height_adjusted(pos, sea_level)
|
||||
}
|
||||
}
|
||||
pub struct Biomes {
|
||||
pub list: Vec<BiomeInfo>,
|
||||
pub index: HashMap<String, u16>,
|
||||
@@ -128,7 +153,11 @@ impl Biomes {
|
||||
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
|
||||
.as_f64()
|
||||
.unwrap_or(0.1) as f32,
|
||||
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
|
||||
.as_bool()
|
||||
.unwrap_or(false),
|
||||
temperature,
|
||||
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
|
||||
downfall,
|
||||
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
|
||||
grass: color(&effects["grass_color"])
|
||||
|
||||
@@ -648,6 +648,61 @@ impl Conduit {
|
||||
}
|
||||
}
|
||||
|
||||
/// What `ConduitBlockEntity.serverTick`'s 40-tick check decided.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct ConduitCheck {
|
||||
/// The shape changed: `CONDUIT_ACTIVATE` (true) or `CONDUIT_DEACTIVATE`.
|
||||
pub toggled: Option<bool>,
|
||||
/// The frame blocks counted, while the conduit is active: `applyEffects`
|
||||
/// and `updateAndAttackTarget` follow (the attack needs 42 or more).
|
||||
pub frames: Option<usize>,
|
||||
}
|
||||
|
||||
/// `ConduitBlockEntity` (server side): what `serverTick` keeps between ticks.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ConduitServer {
|
||||
shape: Conduit,
|
||||
/// `destroyTarget`: the mob id of the enemy being struck.
|
||||
pub target: Option<u32>,
|
||||
next_ambient_sound: i64,
|
||||
}
|
||||
|
||||
impl ConduitServer {
|
||||
/// The `gameTime % 40` part of `serverTick`: the shape is checked again.
|
||||
pub fn check(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
game_time: i64,
|
||||
is_water: impl Fn([i32; 3]) -> bool,
|
||||
is_frame: impl Fn([i32; 3]) -> bool,
|
||||
) -> Option<ConduitCheck> {
|
||||
if game_time % 40 != 0 {
|
||||
return None;
|
||||
}
|
||||
let active = self.shape.update_shape(pos, &is_water, &is_frame);
|
||||
let toggled = (active != self.shape.is_active).then_some(active);
|
||||
self.shape.is_active = active;
|
||||
Some(ConduitCheck {
|
||||
toggled,
|
||||
frames: active.then_some(self.shape.effect_blocks.len()),
|
||||
})
|
||||
}
|
||||
/// The sounds of an active conduit at the end of `serverTick`: whether
|
||||
/// `CONDUIT_AMBIENT` (every 80 ticks) and `CONDUIT_AMBIENT_SHORT` (every
|
||||
/// 60 to 99 ticks) play.
|
||||
pub fn ambient(&mut self, game_time: i64, random: &mut impl RandomSource) -> (bool, bool) {
|
||||
if !self.shape.is_active {
|
||||
return (false, false);
|
||||
}
|
||||
let short = game_time > self.next_ambient_sound;
|
||||
if short {
|
||||
self.next_ambient_sound =
|
||||
game_time + 60 + i64::from(random.next_int_bound(40).unwrap_or(0));
|
||||
}
|
||||
(game_time % 80 == 0, short)
|
||||
}
|
||||
}
|
||||
|
||||
/// `SkullBlockEntity` animation (client ticker of dragon and piglin heads).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Skull {
|
||||
@@ -832,6 +887,8 @@ pub struct BlockEntityAnims {
|
||||
pub gateways: FxHashMap<[i32; 3], EndGateway>,
|
||||
/// The integrated server's copies of the gateways (`portalTick`).
|
||||
pub gateway_servers: FxHashMap<[i32; 3], EndGateway>,
|
||||
/// The integrated server's copies of the conduits (`serverTick`).
|
||||
pub conduit_servers: FxHashMap<[i32; 3], ConduitServer>,
|
||||
/// The client level's random (conduit particles).
|
||||
pub random: LegacyRandom,
|
||||
/// [`Ticker`] by block state id (see [`BlockEntityAnims::classify`]).
|
||||
@@ -854,6 +911,7 @@ impl Default for BlockEntityAnims {
|
||||
pots: FxHashMap::default(),
|
||||
gateways: FxHashMap::default(),
|
||||
gateway_servers: FxHashMap::default(),
|
||||
conduit_servers: FxHashMap::default(),
|
||||
random: LegacyRandom::new(0x6c6576656c),
|
||||
kinds: Vec::new(),
|
||||
present: Vec::new(),
|
||||
@@ -909,6 +967,7 @@ impl BlockEntityAnims {
|
||||
self.pots.retain(|p, _| keep(*p));
|
||||
self.gateways.retain(|p, _| keep(*p));
|
||||
self.gateway_servers.retain(|p, _| keep(*p));
|
||||
self.conduit_servers.retain(|p, _| keep(*p));
|
||||
}
|
||||
/// `getOpenNess(partial)` of the chest or ender chest at `pos`.
|
||||
pub fn lid(&self, pos: [i32; 3], partial: f32) -> f32 {
|
||||
|
||||
@@ -90,7 +90,7 @@ pub const MAP_COLORS: [u32; 62] = [
|
||||
14200723, 8365974,
|
||||
];
|
||||
/// The 12 edges of the unit cube (outline data predating the export).
|
||||
fn cube_edges() -> Vec<[f64; 6]> {
|
||||
pub fn cube_edges() -> Vec<[f64; 6]> {
|
||||
let mut edges = Vec::new();
|
||||
for a in 0..3 {
|
||||
for (u, v) in [(0., 0.), (1., 0.), (0., 1.), (1., 1.)] {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! `BundleContents.Mutable`: what a bundle takes in and gives back, by
|
||||
//! weight. The menus (`menu`, `lan::menu`) run `BundleItem`'s click
|
||||
//! overrides on it.
|
||||
use crate::item_data::BundleSelected;
|
||||
use crate::items::{ItemStack, Items};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
@@ -97,6 +98,8 @@ pub fn can_be_in_bundle(stack: &ItemStack) -> bool {
|
||||
pub struct Contents {
|
||||
items: Vec<ItemStack>,
|
||||
weight: Weight,
|
||||
/// `selectedItem`: an index into `items`, -1 for none.
|
||||
selected: i32,
|
||||
}
|
||||
|
||||
impl Contents {
|
||||
@@ -104,9 +107,11 @@ impl Contents {
|
||||
pub fn of(items: &Items, stack: &ItemStack) -> Option<Self> {
|
||||
let inside = contents_of(items, stack)?.to_vec();
|
||||
let weight = contents_weight(items, &inside);
|
||||
let selected = stack.data().map_or(-1, |d| d.bundle_selected.0);
|
||||
Some(Self {
|
||||
items: inside,
|
||||
weight,
|
||||
selected,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -154,18 +159,26 @@ impl Contents {
|
||||
amount
|
||||
}
|
||||
|
||||
/// `removeOne`: the first stack (Horizon keeps no selected item).
|
||||
/// `removeOne`: the selected stack, else the first; nothing is selected after.
|
||||
pub fn remove_one(&mut self, items: &Items) -> Option<ItemStack> {
|
||||
if self.items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let stack = self.items.remove(0);
|
||||
let at =
|
||||
usize::try_from(self.selected).map_or(0, |i| if i < self.items.len() { i } else { 0 });
|
||||
let stack = self.items.remove(at);
|
||||
self.weight = self
|
||||
.weight
|
||||
.sub(item_weight(items, &stack).times(stack.count));
|
||||
self.selected = -1;
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
/// `getSelectedItemIndex`.
|
||||
pub fn selected(&self) -> i32 {
|
||||
self.selected
|
||||
}
|
||||
|
||||
/// The stacks inside, in display order (the first is the next out).
|
||||
pub fn items(&self) -> &[ItemStack] {
|
||||
&self.items
|
||||
@@ -200,11 +213,28 @@ impl Contents {
|
||||
|
||||
/// `toImmutable`, written back into the bundle's component.
|
||||
pub fn store(self, bundle: &mut ItemStack) {
|
||||
bundle.data_mut().bundle_contents = self.items;
|
||||
let data = bundle.data_mut();
|
||||
data.bundle_contents = self.items;
|
||||
data.bundle_selected = BundleSelected(self.selected);
|
||||
bundle.tidy();
|
||||
}
|
||||
}
|
||||
|
||||
/// `BundleItem.toggleSelectedItem`: select `index`, or nothing when it is the
|
||||
/// selected one already or outside the contents.
|
||||
pub fn toggle_selected(bundle: &mut ItemStack, index: i32) {
|
||||
let Some(data) = bundle.extra.as_deref_mut() else {
|
||||
return;
|
||||
};
|
||||
let inside = (0..data.bundle_contents.len() as i32).contains(&index);
|
||||
let selected = if data.bundle_selected.0 != index && inside {
|
||||
index
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
data.bundle_selected = BundleSelected(selected);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Weight;
|
||||
@@ -232,8 +262,10 @@ pub struct TooltipLayout {
|
||||
/// The component's own width (96) and height, the text's excluded.
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
/// Each slot's top-left and the stack in it, in reading order.
|
||||
/// Each slot's top-left and the stack in it, in drawing order.
|
||||
pub slots: Vec<([f32; 2], ItemStack)>,
|
||||
/// The position in `slots` of the selected item, which is drawn highlighted.
|
||||
pub selected: Option<usize>,
|
||||
/// The `+N` of the items past the grid: its top-left and the count.
|
||||
pub overflow: Option<([f32; 2], u32)>,
|
||||
/// The progress bar's top-left, its fill's width out of 94, and whether
|
||||
@@ -266,6 +298,7 @@ pub fn tooltip_layout(
|
||||
width: 96.0,
|
||||
height: description_height + 13.0 + 8.0,
|
||||
slots: Vec::new(),
|
||||
selected: None,
|
||||
overflow: None,
|
||||
bar: [x + offset, y + description_height + 4.0],
|
||||
fill: 0.0,
|
||||
@@ -282,6 +315,7 @@ pub fn tooltip_layout(
|
||||
let x_start = x + offset + 96.0;
|
||||
let y_start = y + grid_height;
|
||||
let mut slots = Vec::with_capacity(shown.len());
|
||||
let mut selected = None;
|
||||
let mut overflow = None;
|
||||
// Rows fill bottom-up and columns right-to-left, as vanilla draws them.
|
||||
let mut slot = 0;
|
||||
@@ -290,8 +324,13 @@ pub fn tooltip_layout(
|
||||
let at = [x_start - column as f32 * 24.0, y_start - row as f32 * 24.0];
|
||||
if hidden > 0 && row == 1 && column == 1 {
|
||||
overflow = Some((at, hidden));
|
||||
} else if let Some(stack) = shown.get(slot) {
|
||||
slots.push((at, stack.clone()));
|
||||
} else if slot < shown.len() {
|
||||
// `extractSlot`: the cell of slot number n holds item `size - n`.
|
||||
let index = shown.len() - 1 - slot;
|
||||
if usize::try_from(contents.selected()) == Ok(index) {
|
||||
selected = Some(slots.len());
|
||||
}
|
||||
slots.push((at, shown[index].clone()));
|
||||
slot += 1;
|
||||
}
|
||||
}
|
||||
@@ -301,6 +340,7 @@ pub fn tooltip_layout(
|
||||
width: 96.0,
|
||||
height: grid_height + 13.0 + 8.0,
|
||||
slots,
|
||||
selected,
|
||||
overflow,
|
||||
bar: [x + offset, y + grid_height + 4.0],
|
||||
// `Mth.mulAndTruncate(weight, 94)`, kept within the bar.
|
||||
@@ -349,8 +389,8 @@ mod tooltip_tests {
|
||||
assert_eq!(layout.slots.len(), 12);
|
||||
assert_eq!(layout.overflow, None);
|
||||
assert_eq!(layout.height, 3.0 * 24.0 + 13.0 + 8.0);
|
||||
// Rows fill bottom-up and columns right-to-left: the first item is in
|
||||
// the bottom-right cell, the twelfth in the top-left.
|
||||
// Rows fill bottom-up and columns right-to-left: the twelfth item is in
|
||||
// the bottom-right cell, the first in the top-left.
|
||||
assert_eq!(layout.slots[0].0, [96.0 - 24.0, 3.0 * 24.0 - 24.0]);
|
||||
assert_eq!(&*layout.slots[0].1.item, "minecraft:stone");
|
||||
assert_eq!(layout.slots[11].0, [0.0, 0.0]);
|
||||
|
||||
@@ -161,6 +161,9 @@ pub struct Chunk {
|
||||
/// Structure starts reaching into this chunk (`structures.References`):
|
||||
/// structure id and the start's chunk.
|
||||
pub structure_refs: Vec<(Arc<str>, (i32, i32))>,
|
||||
/// The starts generation made in this chunk (`structures.starts`), saved
|
||||
/// with the state their placement has reached by then.
|
||||
pub structure_starts: Vec<Arc<horizon_world::worldgen::structure::start::StructureStart>>,
|
||||
}
|
||||
impl Chunk {
|
||||
pub fn empty(x: i32, z: i32) -> Self {
|
||||
@@ -178,6 +181,7 @@ impl Chunk {
|
||||
block_entities: Vec::new(),
|
||||
block_names: Vec::new(),
|
||||
structure_refs: Vec::new(),
|
||||
structure_starts: Vec::new(),
|
||||
postprocess: Vec::new(),
|
||||
saved: None,
|
||||
entities: Vec::new(),
|
||||
|
||||
@@ -98,6 +98,9 @@ pub enum BlockEntity {
|
||||
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).
|
||||
@@ -363,6 +366,9 @@ impl BlockEntity {
|
||||
if is_skull(name) {
|
||||
return Some(Self::Skull(Skull::default()));
|
||||
}
|
||||
if name == "minecraft:daylight_detector" {
|
||||
return Some(Self::DaylightDetector);
|
||||
}
|
||||
if name == "minecraft:beacon" {
|
||||
return Some(Self::Beacon(Box::new(Beacon {
|
||||
last_check_y: i32::MIN / 2,
|
||||
@@ -412,6 +418,7 @@ impl BlockEntity {
|
||||
Self::Beehive(_) => "minecraft:beehive",
|
||||
Self::CommandBlock(_) => "minecraft:command_block",
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
Self::DaylightDetector => "minecraft:daylight_detector",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
}
|
||||
}
|
||||
@@ -429,7 +436,8 @@ impl BlockEntity {
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_)
|
||||
| Self::Skull(_) => Vec::new(),
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => Vec::new(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
Self::Sculk(_) => Vec::new(),
|
||||
Self::Banner(_) => Vec::new(),
|
||||
@@ -448,7 +456,8 @@ impl BlockEntity {
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_)
|
||||
| Self::Skull(_) => &[],
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
@@ -471,7 +480,8 @@ impl BlockEntity {
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_)
|
||||
| Self::Skull(_) => &mut [],
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
//! (`creative-inventory.json`), a 9x5 scrolling grid and the search tab.
|
||||
use crate::{Result, items::ItemStack, ui::EditBox};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Tab {
|
||||
@@ -40,6 +45,17 @@ pub struct Creative {
|
||||
/// The search tab's stacks, in its display order.
|
||||
search_items: Vec<ItemStack>,
|
||||
search_index: Vec<SearchEntry>,
|
||||
/// ItemPickerMenu.items: what the grid shows, filled when the tab or the
|
||||
/// search changes (selectTab, refreshSearchResults), not on every frame.
|
||||
shown: Mutex<Option<Shown>>,
|
||||
}
|
||||
/// The grid's stacks for one tab, search text and language.
|
||||
struct Shown {
|
||||
tab: usize,
|
||||
search: String,
|
||||
/// A display name, so a language change refills the search results.
|
||||
language: String,
|
||||
items: Arc<[ItemStack]>,
|
||||
}
|
||||
/// Display names from the pinned en_us language file.
|
||||
#[derive(Default)]
|
||||
@@ -291,6 +307,7 @@ impl Creative {
|
||||
},
|
||||
search_items,
|
||||
search_index,
|
||||
shown: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
/// Where a stack comes in the creative tabs (the search tab lists them
|
||||
@@ -303,6 +320,22 @@ impl Creative {
|
||||
.position(|s| s.same_item_same_components(stack))
|
||||
.or_else(|| items.iter().position(|s| s.item == stack.item))
|
||||
}
|
||||
/// The tabs without their stacks: what the screen draws of them each frame.
|
||||
pub fn tab_headers(&self) -> Vec<Tab> {
|
||||
self.tabs
|
||||
.iter()
|
||||
.map(|t| Tab {
|
||||
id: t.id.clone(),
|
||||
title: t.title.clone(),
|
||||
icon: t.icon.clone(),
|
||||
items: Vec::new(),
|
||||
top: t.top,
|
||||
column: t.column,
|
||||
search: t.search,
|
||||
inventory: t.inventory,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn tab(&self) -> &Tab {
|
||||
&self.tabs[self.selected.min(self.tabs.len() - 1)]
|
||||
}
|
||||
@@ -384,17 +417,44 @@ impl Creative {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Items shown by the selected tab (the search tab filters by `search`).
|
||||
pub fn items(&self, names: &Names) -> Vec<ItemStack> {
|
||||
let tab = self.tab();
|
||||
if tab.search {
|
||||
return self
|
||||
.search_results(&self.search.value, names)
|
||||
/// Items shown by the selected tab (the search tab filters by `search`),
|
||||
/// worked out again only when the tab, the search or the language changed.
|
||||
pub fn items(&self, names: &Names) -> Arc<[ItemStack]> {
|
||||
let tab = self.selected.min(self.tabs.len() - 1);
|
||||
let search = if self.tab().search {
|
||||
self.search.value.as_str()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let language = names.display_get("itemGroup.search").unwrap_or_default();
|
||||
let mut shown = self.shown.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(s) = shown.as_ref()
|
||||
&& s.tab == tab
|
||||
&& s.search == search
|
||||
&& s.language == language
|
||||
{
|
||||
return s.items.clone();
|
||||
}
|
||||
let items: Arc<[ItemStack]> = if self.tab().search {
|
||||
self.search_results(search, names)
|
||||
.into_iter()
|
||||
.map(|i| self.search_items[i].clone())
|
||||
.collect();
|
||||
}
|
||||
tab.items.clone()
|
||||
.collect()
|
||||
} else {
|
||||
self.tab().items.as_slice().into()
|
||||
};
|
||||
*shown = Some(Shown {
|
||||
tab,
|
||||
search: search.to_owned(),
|
||||
language,
|
||||
items: items.clone(),
|
||||
});
|
||||
items
|
||||
}
|
||||
/// Drop the shown items, so the next `items` works them out again: a
|
||||
/// reload can rename items without changing the Search label.
|
||||
pub fn forget_items(&self) {
|
||||
*self.shown.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
pub fn rows(&self, names: &Names) -> usize {
|
||||
self.items(names).len().div_ceil(9)
|
||||
|
||||
@@ -545,6 +545,32 @@ impl Enchantments {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `EnchantmentHelper.forEachModifier` as an item's tooltip lists them: the
|
||||
/// `minecraft:attributes` effects of `stack`'s enchantments as rows of
|
||||
/// (attribute, id, amount, operation, slot group), one per slot group the
|
||||
/// enchantment lists.
|
||||
pub fn modifier_rows(&self, stack: &ItemStack) -> Vec<(String, String, f64, String, String)> {
|
||||
let mut rows = Vec::new();
|
||||
for (id, level) in stack.enchantments() {
|
||||
let Some(e) = self.get(id) else { continue };
|
||||
for a in e.effects["minecraft:attributes"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
for slot in &e.slots {
|
||||
rows.push((
|
||||
a["attribute"].as_str().unwrap_or("").to_owned(),
|
||||
a["id"].as_str().unwrap_or("").to_owned(),
|
||||
f64::from(level_value(&a["amount"], *level)),
|
||||
a["operation"].as_str().unwrap_or("add_value").to_owned(),
|
||||
slot.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
/// Sum of `add_value` amounts of one attribute from enchantments on `stack`.
|
||||
pub fn attribute_add(&self, stack: Option<&ItemStack>, attribute: &str) -> f64 {
|
||||
stack.map_or(0.0, |s| {
|
||||
|
||||
@@ -95,6 +95,15 @@ impl ItemEntity {
|
||||
self.health -= fire_damage as i32;
|
||||
}
|
||||
}
|
||||
// CactusBlock.entityInside: `entity.hurt(cactus, 1.0)`. An item has no
|
||||
// hurt cooldown, so it loses 1 health every tick it touches a cactus
|
||||
// (5 health: gone in 5 ticks). Fire resistance does not help;
|
||||
// `ItemStack.canBeHurtBy` only skips it for a damage_resistant stack.
|
||||
if crate::motion::environment::cactus_contact(world, registry, self.bounds())
|
||||
&& !items.resists_cactus(&self.stack)
|
||||
{
|
||||
self.health -= 1;
|
||||
}
|
||||
ItemEnvironmentTick {
|
||||
alive: self.health > 0,
|
||||
lava_hurt,
|
||||
@@ -385,8 +394,29 @@ pub struct ExperienceOrb {
|
||||
pub value: u32,
|
||||
pub age: u32,
|
||||
pub on_ground: bool,
|
||||
/// `ExperienceOrb.count`: orbs merged into this one; each pickup gives
|
||||
/// `value` once and takes one off.
|
||||
pub count: u32,
|
||||
/// `Entity.tickCount`, which paces `ExperienceOrb.scanForMerges`.
|
||||
pub ticks: u32,
|
||||
}
|
||||
impl ExperienceOrb {
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
let h = 0.25;
|
||||
Aabb::new(
|
||||
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
|
||||
Vec3::new(
|
||||
self.position.x + h,
|
||||
self.position.y + 0.5,
|
||||
self.position.z + h,
|
||||
),
|
||||
)
|
||||
}
|
||||
/// `ExperienceOrb.canMerge(orb, id, value)` for an orb that is not removed:
|
||||
/// the same value and ids a multiple of 40 apart.
|
||||
pub fn can_merge(&self, id: u32, value: u32) -> bool {
|
||||
self.value == value && (self.id as i32).wrapping_sub(id as i32) % 40 == 0
|
||||
}
|
||||
/// ExperienceOrb.getExperienceValue: the largest orb size not above `value`.
|
||||
pub fn split(value: u32) -> u32 {
|
||||
[2477, 1237, 617, 307, 149, 73, 37, 17, 7, 3, 1]
|
||||
@@ -416,7 +446,9 @@ impl ExperienceOrb {
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
player: Option<[f64; 3]>,
|
||||
next_float: &mut dyn FnMut() -> f32,
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
let block = world.block(
|
||||
self.position.x.floor() as i32,
|
||||
@@ -432,6 +464,14 @@ impl ExperienceOrb {
|
||||
} else {
|
||||
self.velocity.y -= 0.03;
|
||||
}
|
||||
if facts.get(block).fluid == Fluid::Lava {
|
||||
// ExperienceOrb.tick: lava throws the orb back out.
|
||||
self.velocity = Vec3::new(
|
||||
f64::from((next_float() - next_float()) * 0.2),
|
||||
f64::from(0.2f32),
|
||||
f64::from((next_float() - next_float()) * 0.2),
|
||||
);
|
||||
}
|
||||
if let Some(p) = player {
|
||||
let d = [
|
||||
(p[0] - self.position.x) / 8.0,
|
||||
@@ -449,15 +489,7 @@ impl ExperienceOrb {
|
||||
);
|
||||
}
|
||||
}
|
||||
let h = 0.25;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
|
||||
Vec3::new(
|
||||
self.position.x + h,
|
||||
self.position.y + 0.5,
|
||||
self.position.z + h,
|
||||
),
|
||||
);
|
||||
let bounds = self.bounds();
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
@@ -494,6 +526,31 @@ impl ExperienceOrb {
|
||||
self.age < 6000
|
||||
}
|
||||
}
|
||||
/// `ExperienceOrb.scanForMerges`, run by each orb on `tickCount % 20 == 1`:
|
||||
/// it absorbs the orbs that `canMerge` within 0.5 blocks of its box,
|
||||
/// adding their counts and keeping the smaller age.
|
||||
pub fn merge_orbs(orbs: &mut Vec<ExperienceOrb>) {
|
||||
let mut removed = vec![false; orbs.len()];
|
||||
for i in 0..orbs.len() {
|
||||
if removed[i] || orbs[i].ticks % 20 != 1 {
|
||||
continue;
|
||||
}
|
||||
let area = orbs[i].bounds().inflate(Vec3::new(0.5, 0.5, 0.5));
|
||||
for j in 0..orbs.len() {
|
||||
if j != i
|
||||
&& !removed[j]
|
||||
&& orbs[j].can_merge(orbs[i].id, orbs[i].value)
|
||||
&& orbs[j].bounds().intersects(area)
|
||||
{
|
||||
orbs[i].count += orbs[j].count;
|
||||
orbs[i].age = orbs[i].age.min(orbs[j].age);
|
||||
removed[j] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut removed = removed.into_iter();
|
||||
orbs.retain(|_| !removed.next().unwrap_or(false));
|
||||
}
|
||||
/// Player experience (Player.giveExperiencePoints / getXpNeededForNextLevel).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Experience {
|
||||
|
||||
@@ -166,12 +166,23 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
if d >= end { return 1.0; }
|
||||
return (d - start) / (end - start);
|
||||
}
|
||||
// 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);
|
||||
// Undo stage_light's 16-bit quantization before adding local texels. Otherwise
|
||||
// different stages can fall on opposite sides of a nearest-sampling boundary.
|
||||
let origin = round(packed_origin * size);
|
||||
let extent = max(round(sprite_size * size), vec2(1.0));
|
||||
let local = min(floor(fract(uv) * extent), extent - vec2(1.0));
|
||||
return (origin + local + vec2(0.5)) / size;
|
||||
}
|
||||
// CRUMBLING_UV_END
|
||||
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
var uv = in.uv;
|
||||
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
|
||||
if in.overlay == 11u || in.overlay == 12u {
|
||||
// Each crack's stage sprite origin comes in its light bytes (breaking::stage_light).
|
||||
uv = in.wrap + fract(in.uv) * globals.crumbling_sprite.zw;
|
||||
uv = crumbling_uv(in.uv, in.wrap, globals.crumbling_sprite.zw, textureDimensions(atlas));
|
||||
}
|
||||
var color = textureSample(atlas, atlas_sampler, uv) * select(in.back_color, in.color, front);
|
||||
if in.overlay == 9u || in.overlay == 10u {
|
||||
|
||||
@@ -180,7 +180,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
("Silent", m.silent),
|
||||
("NoGravity", m.no_gravity),
|
||||
("Glowing", m.glowing),
|
||||
("Invulnerable", m.invulnerable_flag),
|
||||
] {
|
||||
if on {
|
||||
map.insert(key.into(), Tag::Byte(1));
|
||||
@@ -188,6 +187,26 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.remove(&horizon_world::nbt::JavaString::from(key));
|
||||
}
|
||||
}
|
||||
map.insert(
|
||||
"Invulnerable".into(),
|
||||
Tag::Byte(i8::from(m.invulnerable_flag)),
|
||||
);
|
||||
map.insert(
|
||||
"LeftHanded".into(),
|
||||
Tag::Byte(i8::from(m.extra.left_handed)),
|
||||
);
|
||||
// Keys of older formats that structure templates still carry.
|
||||
for key in [
|
||||
"HandItems",
|
||||
"ArmorItems",
|
||||
"HandDropChances",
|
||||
"ArmorDropChances",
|
||||
"Dimension",
|
||||
"HurtByTimestamp",
|
||||
"Leashed",
|
||||
] {
|
||||
map.remove(&horizon_world::nbt::JavaString::from(key));
|
||||
}
|
||||
if m.ticks_frozen > 0 {
|
||||
map.insert("TicksFrozen".into(), Tag::Int(m.ticks_frozen));
|
||||
} else {
|
||||
@@ -197,7 +216,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
("AbsorptionAmount", Tag::Float(0)),
|
||||
("FallFlying", Tag::Byte(0)),
|
||||
("CanPickUpLoot", Tag::Byte(i8::from(m.can_pick_up_loot()))),
|
||||
("LeftHanded", Tag::Byte(0)),
|
||||
("current_impulse_context_reset_grace_time", Tag::Int(0)),
|
||||
(
|
||||
"Brain",
|
||||
Tag::compound([("memories", Tag::Compound(Compound::new()))]),
|
||||
@@ -209,7 +228,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
// Only breedable animals have an age; a loaded creature that was saved without
|
||||
// one (a frog) stays without.
|
||||
let aged = raw.is_none_or(|r| r.get("Age").is_some());
|
||||
if m.kind.category == "creature" && aged {
|
||||
if (m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin) && aged {
|
||||
map.insert("Age".into(), Tag::Int(m.extra.age));
|
||||
map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked)));
|
||||
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
|
||||
@@ -260,11 +279,28 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
.or_insert_with(|| string("minecraft:classic"));
|
||||
}
|
||||
"minecraft:piglin" | "minecraft:piglin_brute" | "minecraft:hoglin" => {
|
||||
// Only Piglin.addAdditionalSaveData writes CannotHunt.
|
||||
if id == "minecraft:piglin_brute" {
|
||||
map.remove(&"CannotHunt".into());
|
||||
}
|
||||
map.insert(
|
||||
"IsImmuneToZombification".into(),
|
||||
Tag::Byte(i8::from(m.extra.flag2)),
|
||||
);
|
||||
map.insert("TimeInOverworld".into(), Tag::Int(m.extra.counter2));
|
||||
if id == "minecraft:piglin" {
|
||||
// InventoryCarrier.writeInventoryToTag.
|
||||
map.insert(
|
||||
"Inventory".into(),
|
||||
Tag::List(
|
||||
m.extra
|
||||
.inventory
|
||||
.iter()
|
||||
.map(crate::save::item_to_nbt)
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
"minecraft:horse"
|
||||
| "minecraft:donkey"
|
||||
@@ -357,6 +393,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
"minecraft:bat" => {
|
||||
map.insert("BatFlags".into(), Tag::Byte(i8::from(m.extra.flag)));
|
||||
}
|
||||
"minecraft:turtle" => {
|
||||
map.insert("has_egg".into(), Tag::Byte(i8::from(m.extra.flag)));
|
||||
if let Some(home) = m.extra.home {
|
||||
map.insert("home_pos".into(), Tag::IntArray(home.to_vec()));
|
||||
}
|
||||
}
|
||||
"minecraft:creaking" => {
|
||||
// home_pos: the heart it is bound to.
|
||||
if let Some(home) = m.extra.home {
|
||||
@@ -479,10 +521,8 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
)])
|
||||
});
|
||||
}
|
||||
"minecraft:zombie" => {
|
||||
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
|
||||
"minecraft:zombie" | "minecraft:husk" | "minecraft:drowned" => {
|
||||
for (key, value) in [
|
||||
("CanBreakDoors", Tag::Byte(0)),
|
||||
("DrownedConversionTime", Tag::Int(-1)),
|
||||
("InWaterTime", Tag::Int(-1)),
|
||||
] {
|
||||
@@ -507,6 +547,28 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Zombie.addAdditionalSaveData: CanBreakDoors.
|
||||
if crate::mobs::doors::is_zombie(m.species()) {
|
||||
map.insert(
|
||||
"CanBreakDoors".into(),
|
||||
Tag::Byte(i8::from(m.extra.can_break_doors)),
|
||||
);
|
||||
}
|
||||
if matches!(
|
||||
id,
|
||||
"minecraft:zombie"
|
||||
| "minecraft:husk"
|
||||
| "minecraft:drowned"
|
||||
| "minecraft:zombie_villager"
|
||||
| "minecraft:zombified_piglin"
|
||||
| "minecraft:piglin"
|
||||
| "minecraft:zoglin"
|
||||
) {
|
||||
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
|
||||
}
|
||||
if let Some(a) = m.extra.attributes.as_deref() {
|
||||
put_attributes(map, m, a);
|
||||
}
|
||||
crate::mobs::raider::write_nbt(m, map);
|
||||
crate::mobs::riders::write_nbt(m, map);
|
||||
// LivingEntity equipment: what the mob holds now.
|
||||
@@ -589,6 +651,89 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// `AttributeMap.pack` for the attributes with a base or permanent modifier
|
||||
/// finalizeSpawn set, over the saved list.
|
||||
fn put_attributes(map: &mut Compound, m: &Mob, a: &crate::mobs::finalize::Attributes) {
|
||||
use crate::mobs::finalize::{self, Attr, Op};
|
||||
let mut list = match map.get(&"attributes".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for attr in Attr::ALL {
|
||||
let modifiers: Vec<Tag> = a
|
||||
.modifiers
|
||||
.iter()
|
||||
.filter(|x| x.attr == attr)
|
||||
.map(|x| {
|
||||
let op = match x.op {
|
||||
Op::AddValue => "add_value",
|
||||
Op::AddMultipliedBase => "add_multiplied_base",
|
||||
Op::AddMultipliedTotal => "add_multiplied_total",
|
||||
};
|
||||
Tag::compound([
|
||||
("id", string(x.id)),
|
||||
("amount", Tag::Double(x.amount.to_bits())),
|
||||
("operation", string(op)),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
if modifiers.is_empty() && !a.bases.iter().any(|(b, _)| *b == attr) {
|
||||
continue;
|
||||
}
|
||||
let id = format!("minecraft:{}", attr.name());
|
||||
let mut entry = vec![
|
||||
("id", string(&id)),
|
||||
("base", Tag::Double(finalize::base_value(m, attr).to_bits())),
|
||||
];
|
||||
if !modifiers.is_empty() {
|
||||
entry.push(("modifiers", Tag::List(modifiers)));
|
||||
}
|
||||
let entry = Tag::compound(entry);
|
||||
match list
|
||||
.iter_mut()
|
||||
.find(|t| text(t.get("id")).as_deref() == Some(id.as_str()))
|
||||
{
|
||||
Some(t) => *t = entry,
|
||||
None => list.push(entry),
|
||||
}
|
||||
}
|
||||
map.insert("attributes".into(), Tag::List(list));
|
||||
}
|
||||
/// `Brain.Packed`: the memories finalizeSpawn set, over the saved ones.
|
||||
pub fn put_brain(map: &mut Compound, m: &Mob, dimension: &str) {
|
||||
if m.extra.memories.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut memories = match map.get(&"Brain".into()).and_then(|b| b.get("memories")) {
|
||||
Some(Tag::Compound(c)) => c.clone(),
|
||||
_ => Compound::new(),
|
||||
};
|
||||
for &(name, value, ttl) in &m.extra.memories {
|
||||
let value = match name {
|
||||
"hunted_recently" => Tag::Byte(value as i8),
|
||||
"home" => match m.extra.home {
|
||||
Some(p) => Tag::compound([
|
||||
("dimension", string(dimension)),
|
||||
("pos", Tag::IntArray(p.to_vec())),
|
||||
]),
|
||||
None => continue,
|
||||
},
|
||||
_ => Tag::Int(value as i32),
|
||||
};
|
||||
let mut memory = vec![("value", value)];
|
||||
if ttl != i64::MAX {
|
||||
memory.push(("ttl", Tag::Long(ttl)));
|
||||
}
|
||||
memories.insert(
|
||||
format!("minecraft:{name}").as_str().into(),
|
||||
Tag::compound(memory),
|
||||
);
|
||||
}
|
||||
map.insert(
|
||||
"Brain".into(),
|
||||
Tag::compound([("memories", Tag::Compound(memories))]),
|
||||
);
|
||||
}
|
||||
/// `CustomName`: a text component saved as a string or a `{text: ..}` compound.
|
||||
fn custom_name(tag: &Tag) -> Option<String> {
|
||||
match tag.get("CustomName")? {
|
||||
@@ -608,6 +753,22 @@ pub fn name_text(tag: &Tag) -> Text {
|
||||
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 {
|
||||
return None;
|
||||
};
|
||||
let range = attrs
|
||||
.iter()
|
||||
.find(|a| text(a.get("id")).as_deref() == Some("minecraft:follow_range"))?;
|
||||
let Some(Tag::List(modifiers)) = range.get("modifiers") else {
|
||||
return None;
|
||||
};
|
||||
let bonus = modifiers
|
||||
.iter()
|
||||
.find(|x| text(x.get("id")).as_deref() == Some("minecraft:random_spawn_bonus"))?;
|
||||
float(bonus.get("amount"))
|
||||
}
|
||||
pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -> Option<Mob> {
|
||||
let kind = types.get(&self::kind(tag)?)?.clone();
|
||||
let position = position(tag)?;
|
||||
@@ -633,6 +794,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(bonus) = spawn_bonus(tag) {
|
||||
use crate::mobs::finalize::{Attr, Op, add_modifier};
|
||||
let id = "minecraft:random_spawn_bonus";
|
||||
add_modifier(&mut m, Attr::FollowRange, id, bonus, Op::AddMultipliedBase);
|
||||
}
|
||||
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
|
||||
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
|
||||
// What it holds (equipment.mainhand / offhand), when saved.
|
||||
@@ -674,7 +840,8 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
|
||||
m.custom_name = custom_name(tag);
|
||||
m.custom_name_text = tag.get("CustomName").map(name_text);
|
||||
if m.kind.category == "creature" {
|
||||
m.extra.left_handed = flag(tag, "LeftHanded");
|
||||
if m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin {
|
||||
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
|
||||
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
|
||||
m.extra.age_locked = flag(tag, "AgeLocked");
|
||||
@@ -706,6 +873,16 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
) {
|
||||
m.extra.flag2 = flag(tag, "IsImmuneToZombification");
|
||||
m.extra.counter2 = number(tag.get("TimeInOverworld")).unwrap_or(0) as i32;
|
||||
if m.species() == crate::mobs::Species::Piglin
|
||||
&& let Some(Tag::List(items)) = tag.get("Inventory")
|
||||
{
|
||||
// InventoryCarrier.readInventoryFromTag (eight slots).
|
||||
m.extra.inventory = items
|
||||
.iter()
|
||||
.filter_map(crate::save::item_from_nbt)
|
||||
.take(8)
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
m.species(),
|
||||
@@ -847,6 +1024,18 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
{
|
||||
crate::mobs::creaking::set_transient(&mut m, [home[0], home[1], home[2]]);
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Turtle {
|
||||
// Turtle.readAdditionalSaveData: `home_pos` (else where it stands) and `has_egg`.
|
||||
m.extra.flag = flag(tag, "has_egg");
|
||||
m.extra.home = Some(match tag.get("home_pos") {
|
||||
Some(Tag::IntArray(home)) if home.len() == 3 => [home[0], home[1], home[2]],
|
||||
_ => [
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
],
|
||||
});
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Wither {
|
||||
m.extra.counter = number(tag.get("Invul")).unwrap_or(0) as i32;
|
||||
}
|
||||
@@ -925,6 +1114,10 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
if !crate::mobs::finalize::ageable(m.species()) && flag(tag, "IsBaby") {
|
||||
crate::mobs::finalize::set_baby(&mut m, true);
|
||||
}
|
||||
// Zombie.readAdditionalSaveData: setCanBreakDoors.
|
||||
if crate::mobs::doors::is_zombie(m.species()) {
|
||||
crate::mobs::doors::set_can_break_doors(&mut m, flag(tag, "CanBreakDoors"));
|
||||
}
|
||||
crate::mobs::raider::read_nbt(&mut m, tag);
|
||||
crate::mobs::riders::read_nbt(&mut m, tag);
|
||||
// A mob saved mid-death finishes dying; one saved dead is dropped.
|
||||
@@ -1043,18 +1236,18 @@ pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
|
||||
("Health", Tag::Short(5)),
|
||||
("Age", Tag::Short(o.age.min(i16::MAX as u32) as i16)),
|
||||
("Value", Tag::Short(o.value.min(i16::MAX as u32) as i16)),
|
||||
("Count", Tag::Int(1)),
|
||||
("Count", Tag::Int(o.count.min(i32::MAX as u32) as i32)),
|
||||
] {
|
||||
put(&mut tag, key, value);
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// One Horizon orb per counted orb (vanilla merges orbs of equal value).
|
||||
/// One orb with its merged `Count`.
|
||||
pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
|
||||
let Some(position) = position(tag) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let count = number(tag.get("Count")).unwrap_or(1).clamp(1, 64) as usize;
|
||||
let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32;
|
||||
let orb = ExperienceOrb {
|
||||
id: 0,
|
||||
position,
|
||||
@@ -1063,8 +1256,10 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
|
||||
value: number(tag.get("Value")).unwrap_or(1).max(1) as u32,
|
||||
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
|
||||
on_ground: flag(tag, "OnGround"),
|
||||
count,
|
||||
ticks: 0,
|
||||
};
|
||||
vec![orb; count]
|
||||
vec![orb]
|
||||
}
|
||||
|
||||
/// `Projectile` + `AbstractArrow.addAdditionalSaveData` of a live arrow;
|
||||
@@ -1913,7 +2108,7 @@ mod tests {
|
||||
assert_eq!(number(tag.get("Health")), Some(5));
|
||||
}
|
||||
#[test]
|
||||
fn orbs_expand_their_count() {
|
||||
fn orbs_keep_their_count() {
|
||||
let o = ExperienceOrb {
|
||||
id: 0,
|
||||
position: Vec3::new(1.0, 2.0, 3.0),
|
||||
@@ -1922,12 +2117,14 @@ mod tests {
|
||||
value: 7,
|
||||
age: 3,
|
||||
on_ground: false,
|
||||
count: 3,
|
||||
ticks: 0,
|
||||
};
|
||||
let mut tag = orb_to_nbt(&o, [0; 4]);
|
||||
put(&mut tag, "Count", Tag::Int(3));
|
||||
let tag = orb_to_nbt(&o, [0; 4]);
|
||||
let back = orbs_from_nbt(&tag);
|
||||
assert_eq!(back.len(), 3);
|
||||
assert_eq!(back.len(), 1);
|
||||
assert_eq!(back[0].value, 7);
|
||||
assert_eq!(back[0].count, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1992,12 +2189,39 @@ fn restore_axolotl_memories(m: &mut Mob, tag: &Tag) {
|
||||
}
|
||||
}
|
||||
const AXOLOTL_VARIANTS: [&str; 5] = ["lucy", "wild", "gold", "cyan", "blue"];
|
||||
/// `Salmon.Variant` names, by id.
|
||||
const SALMON_SIZES: [&str; 3] = ["small", "medium", "large"];
|
||||
/// `TropicalFish.Pattern` names in enum order: the six small patterns, then
|
||||
/// the six large ones (the order of `extra.variant`).
|
||||
const TROPICAL_PATTERNS: [&str; 12] = [
|
||||
"kob",
|
||||
"sunstreak",
|
||||
"snooper",
|
||||
"dasher",
|
||||
"brinely",
|
||||
"spotty",
|
||||
"flopper",
|
||||
"stripey",
|
||||
"glitter",
|
||||
"blockfish",
|
||||
"betty",
|
||||
"clayfish",
|
||||
];
|
||||
/// `Bucketable.saveDefaultDataToBucketTag`, with the age an axolotl and a
|
||||
/// tadpole add (`saveToBucketTag`).
|
||||
pub fn bucket_data(m: &Mob) -> Tag {
|
||||
let mut data = Tag::compound([
|
||||
("Age", Tag::Int(m.extra.age)),
|
||||
("AgeLocked", Tag::Byte(i8::from(m.extra.age_locked))),
|
||||
("Health", Tag::Float(m.health.to_bits())),
|
||||
]);
|
||||
let mut data = Tag::compound([("Health", Tag::Float(m.health.to_bits()))]);
|
||||
if matches!(
|
||||
m.species(),
|
||||
crate::mobs::Species::Axolotl | crate::mobs::Species::Tadpole
|
||||
) {
|
||||
put(&mut data, "Age", Tag::Int(m.extra.age));
|
||||
put(
|
||||
&mut data,
|
||||
"AgeLocked",
|
||||
Tag::Byte(i8::from(m.extra.age_locked)),
|
||||
);
|
||||
}
|
||||
for (key, on) in [
|
||||
("NoAI", m.no_ai),
|
||||
("Silent", m.silent),
|
||||
@@ -2027,6 +2251,29 @@ pub fn bucket_components(m: &Mob) -> crate::item_data::StackData {
|
||||
.unwrap_or(&"lucy");
|
||||
data.set_other("minecraft:axolotl/variant", Some(string(variant)));
|
||||
}
|
||||
// Salmon.saveToBucketTag: the size.
|
||||
if m.species() == crate::mobs::Species::Salmon {
|
||||
let size = SALMON_SIZES
|
||||
.get(usize::from(m.extra.variant))
|
||||
.unwrap_or(&"medium");
|
||||
data.set_other("minecraft:salmon/size", Some(string(size)));
|
||||
}
|
||||
// TropicalFish.saveToBucketTag: the pattern and both colours.
|
||||
if m.species() == crate::mobs::Species::TropicalFish {
|
||||
let dye = |i: u8| crate::mobs::render::DYE_NAMES[usize::from(i) & 15];
|
||||
let pattern = TROPICAL_PATTERNS
|
||||
.get(usize::from(m.extra.variant))
|
||||
.unwrap_or(&"kob");
|
||||
data.set_other("minecraft:tropical_fish/pattern", Some(string(pattern)));
|
||||
data.set_other(
|
||||
"minecraft:tropical_fish/base_color",
|
||||
Some(string(dye(m.extra.variant2))),
|
||||
);
|
||||
data.set_other(
|
||||
"minecraft:tropical_fish/pattern_color",
|
||||
Some(string(dye(m.extra.variant3))),
|
||||
);
|
||||
}
|
||||
data
|
||||
}
|
||||
pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) {
|
||||
@@ -2077,6 +2324,39 @@ pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) {
|
||||
{
|
||||
m.extra.variant = id as u8;
|
||||
}
|
||||
// Salmon.applyImplicitComponents: the size.
|
||||
if m.species() == crate::mobs::Species::Salmon
|
||||
&& let Some(name) = text(components.other("minecraft:salmon/size"))
|
||||
&& let Some(id) = SALMON_SIZES
|
||||
.iter()
|
||||
.position(|&v| v == name.trim_start_matches("minecraft:"))
|
||||
{
|
||||
m.extra.variant = id as u8;
|
||||
}
|
||||
// TropicalFish.applyImplicitComponent: the pattern and both colours.
|
||||
if m.species() == crate::mobs::Species::TropicalFish {
|
||||
let name = |key| text(components.other(key));
|
||||
let dye = |key| {
|
||||
name(key).and_then(|n| {
|
||||
crate::mobs::render::DYE_NAMES
|
||||
.iter()
|
||||
.position(|&v| v == n.trim_start_matches("minecraft:"))
|
||||
})
|
||||
};
|
||||
if let Some(id) = name("minecraft:tropical_fish/pattern").and_then(|n| {
|
||||
TROPICAL_PATTERNS
|
||||
.iter()
|
||||
.position(|&v| v == n.trim_start_matches("minecraft:"))
|
||||
}) {
|
||||
m.extra.variant = id as u8;
|
||||
}
|
||||
if let Some(id) = dye("minecraft:tropical_fish/base_color") {
|
||||
m.extra.variant2 = id as u8;
|
||||
}
|
||||
if let Some(id) = dye("minecraft:tropical_fish/pattern_color") {
|
||||
m.extra.variant3 = id as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -35,6 +35,18 @@ impl Damage {
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
/// A block that starts to fall: anvils hurt, and a brushable block
|
||||
/// breaks where it lands (BrushableBlock.tick: disableDrop).
|
||||
pub fn falling(block: &str) -> Self {
|
||||
if block.ends_with("anvil") {
|
||||
Self::anvil()
|
||||
} else {
|
||||
Self {
|
||||
cancel_drop: block.starts_with("minecraft:suspicious_"),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn from_nbt(tag: &Tag, anvil: bool) -> Self {
|
||||
Self {
|
||||
hurt_entities: number(tag.get("HurtEntities")).map_or(anvil, |v| v != 0),
|
||||
@@ -181,6 +193,14 @@ pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn only_brushable_blocks_break_where_they_land() {
|
||||
assert!(Damage::falling("minecraft:suspicious_gravel").cancel_drop);
|
||||
assert!(Damage::falling("minecraft:suspicious_sand").cancel_drop);
|
||||
assert!(!Damage::falling("minecraft:gravel").cancel_drop);
|
||||
assert!(Damage::falling("minecraft:chipped_anvil").hurt_entities);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impact_uses_ceil_distance_capped_damage_and_positive_damage_wear() {
|
||||
let damage = Damage::anvil();
|
||||
|
||||
+806
-149
File diff suppressed because it is too large.
Load diff
@@ -453,6 +453,11 @@ impl Game<'_> {
|
||||
}
|
||||
// ArmorStand.swapItem.
|
||||
match held {
|
||||
// A player with infinite materials puts a copy of one on an empty
|
||||
// slot and keeps the stack.
|
||||
Some(h) if self.creative && current.is_none() => {
|
||||
self.armor_stands[i].equipment[slot] = Some(ItemStack { count: 1, ..h });
|
||||
}
|
||||
Some(h) if h.count > 1 => {
|
||||
if current.is_some() {
|
||||
return true;
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
//! [`Game::block_anim`] and [`Game::chest_lid`].
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
block_entity_anim::{BellEntity, EndGateway, Shulker, ShulkerStatus, Ticker},
|
||||
block_entity_anim::{
|
||||
BellEntity, ConduitServer, ConduitTarget, EndGateway, Shulker, ShulkerStatus, Ticker,
|
||||
},
|
||||
block_entity_render::BlockAnim,
|
||||
collisions::{SceneCollisions, no_collision_empty},
|
||||
containers::BlockEntity,
|
||||
effects::EffectInstance,
|
||||
mobs::{Damage, DamageKind},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
save::number,
|
||||
skins,
|
||||
@@ -16,6 +20,7 @@ use horizon_client::{
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
@@ -218,9 +223,12 @@ impl Game<'_> {
|
||||
/// The client tickers of the loaded animated block entities, once per
|
||||
/// client tick (`ClientLevel.tickBlockEntities`).
|
||||
pub(super) fn tick_block_entity_anims(&mut self) {
|
||||
// A server has no window, but its gateways and conduits still run
|
||||
// their server tickers; a client of one only animates what it is sent.
|
||||
if self.remote.is_none() {
|
||||
self.tick_conduit_servers();
|
||||
}
|
||||
let Some(r) = &self.renderer else {
|
||||
// A server has no window, but its gateways still run their
|
||||
// server ticker.
|
||||
if self.remote.is_none() {
|
||||
self.tick_gateway_servers();
|
||||
}
|
||||
@@ -337,6 +345,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Ticker::Conduit => {
|
||||
let target = self.conduit_target(pos);
|
||||
let world = &self.world;
|
||||
let facts = self.res.facts();
|
||||
let registry = self.pack.assets.registry;
|
||||
@@ -356,7 +365,7 @@ impl Game<'_> {
|
||||
time,
|
||||
is_water,
|
||||
is_frame,
|
||||
None,
|
||||
target,
|
||||
self.particles.random(),
|
||||
);
|
||||
let nautilus = ParticleOptions::simple(ParticleType::Nautilus);
|
||||
@@ -383,6 +392,248 @@ impl Game<'_> {
|
||||
}
|
||||
self.be_anims.present = positions;
|
||||
}
|
||||
/// `ConduitBlockEntity.serverTick` of every conduit in the chunks that
|
||||
/// tick blocks, with or without a renderer. The conduits are looked for
|
||||
/// when the shape is checked, every 40 ticks (they have no block entity
|
||||
/// of their own here).
|
||||
fn tick_conduit_servers(&mut self) {
|
||||
if self.world.game_time % 40 == 0 {
|
||||
self.find_conduits();
|
||||
}
|
||||
let mut positions: Vec<[i32; 3]> = self.be_anims.conduit_servers.keys().copied().collect();
|
||||
positions.sort_unstable();
|
||||
for pos in positions {
|
||||
self.tick_conduit_server(pos);
|
||||
}
|
||||
}
|
||||
/// Sets `be_anims.conduit_servers` to the conduits in the
|
||||
/// chunks that tick blocks, keeping the state of those already known.
|
||||
fn find_conduits(&mut self) {
|
||||
let levels = self.tickets.levels();
|
||||
let kinds = &self.be_anims.kinds;
|
||||
let is_conduit = |id: u32| matches!(kinds.get(id as usize), Some(Some(Ticker::Conduit)));
|
||||
let mut found = Vec::new();
|
||||
for (&(cx, cz), chunk) in &self.world.chunks {
|
||||
if !levels.block_ticking_at(cx * 16, cz * 16) {
|
||||
continue;
|
||||
}
|
||||
for (si, section) in chunk.sections.iter().enumerate() {
|
||||
if !section.blocks.maybe_has(is_conduit) {
|
||||
continue;
|
||||
}
|
||||
for i in 0..4096 {
|
||||
if is_conduit(section.get(i)) {
|
||||
found.push([
|
||||
cx * 16 + (i & 15) as i32,
|
||||
horizon_client::chunk::MIN_Y + si as i32 * 16 + (i >> 8) as i32,
|
||||
cz * 16 + ((i >> 4) & 15) as i32,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut old = std::mem::take(&mut self.be_anims.conduit_servers);
|
||||
for pos in found {
|
||||
let known = old.remove(&pos).unwrap_or_default();
|
||||
self.be_anims.conduit_servers.insert(pos, known);
|
||||
}
|
||||
}
|
||||
/// `ConduitBlockEntity.serverTick` of the conduit at `pos`, in a chunk
|
||||
/// that ticks blocks: the shape check every 40 ticks with its sounds, the
|
||||
/// power for players in range, the hurt for an enemy, then the ambient
|
||||
/// sounds.
|
||||
fn tick_conduit_server(&mut self, pos: [i32; 3]) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]).0;
|
||||
if self.be_anims.ticker(state) != Some(Ticker::Conduit) {
|
||||
// Broken since the chunks were searched.
|
||||
self.be_anims.conduit_servers.remove(&pos);
|
||||
return;
|
||||
}
|
||||
if !self.tickets.levels().block_ticking_at(pos[0], pos[2])
|
||||
|| !self.border.contains(f64::from(pos[0]), f64::from(pos[2]))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let time = self.world.game_time;
|
||||
let mut conduit = self
|
||||
.be_anims
|
||||
.conduit_servers
|
||||
.remove(&pos)
|
||||
.unwrap_or_default();
|
||||
let check = {
|
||||
let world = &self.world;
|
||||
let facts = self.res.facts();
|
||||
let registry = self.pack.assets.registry;
|
||||
let frames = self
|
||||
.res
|
||||
.items()
|
||||
.block_tags
|
||||
.get("minecraft:conduit_effect_block");
|
||||
// Level.isWaterAt: the fluid at the position is water.
|
||||
let is_water = |p: [i32; 3]| {
|
||||
facts.get(world.block(p[0], p[1], p[2])).fluid
|
||||
== horizon_client::blocks::Fluid::Water
|
||||
};
|
||||
let is_frame = |p: [i32; 3]| {
|
||||
registry
|
||||
.state(world.block(p[0], p[1], p[2]))
|
||||
.is_some_and(|s| frames.is_some_and(|f| f.contains(&s.name)))
|
||||
};
|
||||
conduit.check(pos, time, is_water, is_frame)
|
||||
};
|
||||
let centre = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
if let Some(check) = check {
|
||||
if let Some(active) = check.toggled {
|
||||
let sound = if active {
|
||||
"minecraft:block.conduit.activate"
|
||||
} else {
|
||||
"minecraft:block.conduit.deactivate"
|
||||
};
|
||||
self.audio.play(sound, Some(centre), 1.0, 1.0);
|
||||
}
|
||||
if let Some(frames) = check.frames {
|
||||
self.conduit_apply_effects(pos, frames);
|
||||
self.conduit_attack(&mut conduit, pos, frames >= 42);
|
||||
}
|
||||
}
|
||||
let (ambient, short) = conduit.ambient(time, &mut self.world.random);
|
||||
if ambient {
|
||||
self.audio
|
||||
.play("minecraft:block.conduit.ambient", Some(centre), 1.0, 1.0);
|
||||
}
|
||||
if short {
|
||||
self.audio.play(
|
||||
"minecraft:block.conduit.ambient_short",
|
||||
Some(centre),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
self.be_anims.conduit_servers.insert(pos, conduit);
|
||||
}
|
||||
/// `ConduitBlockEntity.applyEffects`: conduit power for every player
|
||||
/// in water or rain within `frames / 7 * 16` blocks.
|
||||
fn conduit_apply_effects(&mut self, pos: [i32; 3], frames: usize) {
|
||||
let range = (frames / 7 * 16) as i64;
|
||||
self.for_each_player(|g| {
|
||||
let Some(p) = g.player.as_ref().filter(|p| !p.health.dead_or_dying()) else {
|
||||
return;
|
||||
};
|
||||
if g.spectator() {
|
||||
return;
|
||||
}
|
||||
let at = p.position;
|
||||
let in_water = p.fluids.was_touching_water;
|
||||
let top = at.y + f64::from(p.dimensions.height);
|
||||
// BlockPos.closerThan: the squared distance under range squared.
|
||||
let d = [at.x, at.y, at.z]
|
||||
.map(|v| v.floor() as i64)
|
||||
.iter()
|
||||
.zip(pos)
|
||||
.map(|(a, b)| (a - i64::from(b)).pow(2))
|
||||
.sum::<i64>();
|
||||
if d >= range * range || !(in_water || g.in_rain(at, top)) {
|
||||
return;
|
||||
}
|
||||
let power = EffectInstance {
|
||||
ambient: true,
|
||||
..EffectInstance::new("conduit_power", 260, 0)
|
||||
};
|
||||
g.potions.effects.add(power);
|
||||
});
|
||||
}
|
||||
/// `Entity.isInRain`: rain at the block position or at the top of the box.
|
||||
fn in_rain(&mut self, at: Vec3, top: f64) -> bool {
|
||||
let p = BlockPos::new(
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
);
|
||||
let up = BlockPos::new(p.x, top.floor() as i32, p.z);
|
||||
self.with_level(|b, level, _| b.is_raining_at(level, p) || b.is_raining_at(level, up))
|
||||
}
|
||||
/// `ConduitBlockEntity.updateAndAttackTarget`: while the conduit hunts
|
||||
/// (42 frame blocks) its target stays until it dies or leaves the 8
|
||||
/// blocks, else a random enemy in water or rain is chosen; the target
|
||||
/// takes 4 magic damage.
|
||||
fn conduit_attack(&mut self, conduit: &mut ConduitServer, pos: [i32; 3], hunting: bool) {
|
||||
conduit.target = match conduit.target {
|
||||
_ if !hunting => None,
|
||||
None => self.conduit_new_target(pos),
|
||||
Some(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && !m.removed)
|
||||
.filter(|m| {
|
||||
let at = [m.position.x, m.position.y, m.position.z].map(|v| v.floor() as i64);
|
||||
m.alive()
|
||||
&& at
|
||||
.iter()
|
||||
.zip(pos)
|
||||
.map(|(a, b)| (a - i64::from(b)).pow(2))
|
||||
.sum::<i64>()
|
||||
< 64
|
||||
})
|
||||
.map(|m| m.id),
|
||||
};
|
||||
let Some(m) = conduit
|
||||
.target
|
||||
.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
m.damage(4.0, Damage::of(DamageKind::Magic));
|
||||
self.audio
|
||||
.play("minecraft:block.conduit.attack.target", Some(at), 1.0, 1.0);
|
||||
}
|
||||
/// `ConduitBlockEntity.selectNewTarget`: one of the enemies in water or
|
||||
/// rain whose box meets the conduit's block inflated by 8.
|
||||
fn conduit_new_target(&mut self, pos: [i32; 3]) -> Option<u32> {
|
||||
let [x, y, z] = pos.map(f64::from);
|
||||
let area = Aabb::new(
|
||||
Vec3::new(x - 8.0, y - 8.0, z - 8.0),
|
||||
Vec3::new(x + 9.0, y + 9.0, z + 9.0),
|
||||
);
|
||||
let near: Vec<(u32, bool, Vec3, f64)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.hostile() && !m.removed && m.bounds().intersects(area))
|
||||
.map(|m| {
|
||||
let top = m.position.y + f64::from(m.height());
|
||||
(m.id, m.fluids.in_water, m.position, top)
|
||||
})
|
||||
.collect();
|
||||
let candidates: Vec<u32> = near
|
||||
.into_iter()
|
||||
.filter(|&(_, in_water, at, top)| in_water || self.in_rain(at, top))
|
||||
.map(|(id, ..)| id)
|
||||
.collect();
|
||||
if candidates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let pick = self
|
||||
.world
|
||||
.random
|
||||
.next_int_bound(candidates.len() as i32)
|
||||
.unwrap_or(0);
|
||||
Some(candidates[pick as usize])
|
||||
}
|
||||
/// The conduit's `destroyTarget` as the client draws it.
|
||||
fn conduit_target(&self, pos: [i32; 3]) -> Option<ConduitTarget> {
|
||||
let id = self.be_anims.conduit_servers.get(&pos)?.target?;
|
||||
let m = self.mobs.iter().find(|m| m.id == id && !m.removed)?;
|
||||
Some(ConduitTarget {
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
eye_y: m.eye_y(),
|
||||
width: m.width(),
|
||||
height: m.height(),
|
||||
})
|
||||
}
|
||||
/// `TheEndGatewayBlockEntity.portalTick` (the server ticker) of the
|
||||
/// gateways a server keeps a copy of, in block-ticking chunks
|
||||
/// (`Level.tickBlockEntities`): the cooldown an entry started runs out,
|
||||
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
block_entities::{Pos, Ticking},
|
||||
containers::{BlockEntity, FurnaceKind, HOPPER_SLOTS},
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -56,6 +57,7 @@ impl Game<'_> {
|
||||
Some(BlockEntity::Beehive(_)) => self.tick_beehive(pos),
|
||||
Some(BlockEntity::Furnace(_)) => self.tick_furnace(pos)?,
|
||||
Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?,
|
||||
Some(BlockEntity::DaylightDetector) => self.tick_daylight_detector(pos),
|
||||
Some(BlockEntity::Campfire(_)) => self.tick_campfire(pos)?,
|
||||
_ => {}
|
||||
}
|
||||
@@ -94,6 +96,7 @@ impl Game<'_> {
|
||||
| "minecraft:beehive"
|
||||
| "minecraft:bee_nest"
|
||||
| "minecraft:brewing_stand"
|
||||
| "minecraft:daylight_detector"
|
||||
// Lit, cookTick; unlit, cooldownTick.
|
||||
| "minecraft:campfire"
|
||||
| "minecraft:soul_campfire" => Ticking::Yes,
|
||||
@@ -117,6 +120,16 @@ impl Game<'_> {
|
||||
let levels = self.tickets.levels();
|
||||
move |x, z| levels.block_ticking_at(x, z)
|
||||
}
|
||||
/// `DaylightDetectorBlock.tickEntity` of the detector at `pos`.
|
||||
fn tick_daylight_detector(&mut self, pos: Pos) {
|
||||
// It updates every 20 game ticks (the behaviour checks this too).
|
||||
if self.world.game_time % 20 != 0 {
|
||||
return;
|
||||
}
|
||||
let at = BlockPos::new(pos.0, pos.1, pos.2);
|
||||
self.with_level(|b, level, ticks| b.tick_daylight_detector(level, ticks, at));
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
/// `AbstractFurnaceBlockEntity.serverTick` of the furnace at `pos`, and
|
||||
/// its block's `lit` following it.
|
||||
fn tick_furnace(&mut self, pos: Pos) -> Result<()> {
|
||||
|
||||
@@ -8,7 +8,7 @@ use horizon_client::{
|
||||
containers::{BlockEntity, Lectern},
|
||||
first_person::Hand,
|
||||
hud::{HudBatch, rgba},
|
||||
item_data::WrittenBook,
|
||||
item_data::{StackData, WrittenBook},
|
||||
items::ItemStack,
|
||||
menu::{Kind, Menu},
|
||||
text::Text,
|
||||
@@ -59,14 +59,86 @@ pub(super) enum BookScreen {
|
||||
page_edit: ui::EditBox,
|
||||
},
|
||||
View {
|
||||
pages: Vec<String>,
|
||||
pages: Vec<Text>,
|
||||
page: usize,
|
||||
lectern: Option<(i32, i32, i32)>,
|
||||
},
|
||||
}
|
||||
/// The pages a book screen reads: a written book's components (so their
|
||||
/// click events stay), else a writable book's text.
|
||||
fn view_pages(data: Option<&StackData>) -> Vec<Text> {
|
||||
let Some(data) = data else { return Vec::new() };
|
||||
let Some(w) = &data.written else {
|
||||
return data.writable_pages.iter().map(Text::literal).collect();
|
||||
};
|
||||
let tagged = w.page_tags.len() == w.pages.len();
|
||||
w.pages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, page)| match tagged.then(|| &w.page_tags[i]) {
|
||||
Some(tag @ horizon_world::nbt::Tag::Compound(_)) => {
|
||||
horizon_client::entity_nbt::name_text(tag)
|
||||
}
|
||||
_ => Text::literal(page),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
impl Game<'_> {
|
||||
fn book_lines(&self, page: &str) -> Vec<Text> {
|
||||
self.gui.atlas.font.split(&Text::literal(page), 114.0)
|
||||
self.page_lines(&Text::literal(page))
|
||||
}
|
||||
fn page_lines(&self, page: &Text) -> Vec<Text> {
|
||||
self.gui.atlas.font.split(page, 114.0)
|
||||
}
|
||||
/// `BookViewScreen.mouseClicked`: the click event of the text under the
|
||||
/// cursor on the page.
|
||||
fn book_link_at(&self, page: &Text, left: f32) -> Option<horizon_client::text::ClickEvent> {
|
||||
let (mx, my) = self.cursor;
|
||||
let row = ((my - 32.0) / 9.0).floor();
|
||||
if row < 0.0 || row >= LINES as f32 {
|
||||
return None;
|
||||
}
|
||||
let lines = self.page_lines(page);
|
||||
let line = lines.get(row as usize)?.resolve_legacy();
|
||||
let mut x = mx - (left + 36.0);
|
||||
if x < 0.0 {
|
||||
return None;
|
||||
}
|
||||
for (c, style) in line.chars() {
|
||||
x -= self.gui.atlas.font.advance(c, style);
|
||||
if x < 0.0 {
|
||||
return style.click.clone();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `BookViewScreen.handleClickEvent`: a page link turns to its page
|
||||
/// (`forcePage`), a web link goes through the confirm-link flow.
|
||||
fn book_follow_link(
|
||||
&mut self,
|
||||
link: horizon_client::text::ClickEvent,
|
||||
count: usize,
|
||||
lectern: Option<(i32, i32, i32)>,
|
||||
) {
|
||||
use horizon_client::text::ClickEvent;
|
||||
match link {
|
||||
ClickEvent::ChangePage(target) => {
|
||||
let target = (target - 1).clamp(0, count.saturating_sub(1) as i32);
|
||||
let Some(BookScreen::View { page, .. }) = self.book.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Some(key) = lectern {
|
||||
// LecternScreen.forcePage: the menu's button 100 + page.
|
||||
if target != *page as i32 {
|
||||
self.lectern_button(key, 100 + target);
|
||||
}
|
||||
} else if target != *page as i32 {
|
||||
*page = target as usize;
|
||||
}
|
||||
}
|
||||
ClickEvent::OpenUrl(url) => self.click_url(url),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `WritableBookItem.use` / `WrittenBookItem.use` (`Player.openItemGui`).
|
||||
/// A server's player writes in its own client's editor
|
||||
@@ -115,11 +187,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
"minecraft:written_book" => BookScreen::View {
|
||||
pages: stack
|
||||
.data()
|
||||
.and_then(|d| d.written.as_ref())
|
||||
.map(|w| w.pages.clone())
|
||||
.unwrap_or_default(),
|
||||
pages: view_pages(stack.data()),
|
||||
page: 0,
|
||||
lectern: None,
|
||||
},
|
||||
@@ -215,6 +283,7 @@ impl Game<'_> {
|
||||
author,
|
||||
generation: 0,
|
||||
pages,
|
||||
page_tags: Vec::new(),
|
||||
});
|
||||
*stack = written;
|
||||
} else {
|
||||
@@ -685,11 +754,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
};
|
||||
let Some(book) = &l.book else { return false };
|
||||
let data = book.data();
|
||||
let pages = data
|
||||
.and_then(|d| d.written.as_ref().map(|w| w.pages.clone()))
|
||||
.or_else(|| data.map(|d| d.writable_pages.clone()))
|
||||
.unwrap_or_default();
|
||||
let pages = view_pages(book.data());
|
||||
let page = (l.page.max(0) as usize).min(pages.len().saturating_sub(1));
|
||||
self.stats.custom("interact_with_lectern", 1);
|
||||
self.open_book(BookScreen::View {
|
||||
@@ -811,11 +876,7 @@ impl Game<'_> {
|
||||
page,
|
||||
}) = self.book.as_mut()
|
||||
{
|
||||
let data = book.as_ref().and_then(ItemStack::data);
|
||||
*pages = data
|
||||
.and_then(|d| d.written.as_ref().map(|w| w.pages.clone()))
|
||||
.or_else(|| data.map(|d| d.writable_pages.clone()))
|
||||
.unwrap_or_default();
|
||||
*pages = view_pages(book.as_ref().and_then(ItemStack::data));
|
||||
*page = (*page).min(pages.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
@@ -955,7 +1016,10 @@ impl Game<'_> {
|
||||
lectern,
|
||||
}) => {
|
||||
let (page, count, lectern) = (*page, pages.len(), *lectern);
|
||||
if page + 1 < count && inside(left + 116.0, 159.0, 23.0, 13.0) {
|
||||
// The text's click event first, then the buttons.
|
||||
if let Some(link) = pages.get(page).and_then(|t| self.book_link_at(t, left)) {
|
||||
self.book_follow_link(link, count, lectern);
|
||||
} else if page + 1 < count && inside(left + 116.0, 159.0, 23.0, 13.0) {
|
||||
self.turn_page(true);
|
||||
} else if page > 0 && inside(left + 43.0, 159.0, 23.0, 13.0) {
|
||||
self.turn_page(false);
|
||||
@@ -1195,7 +1259,7 @@ impl Game<'_> {
|
||||
} => {
|
||||
indicator(batch, *page, pages.len());
|
||||
if let Some(text) = pages.get(*page) {
|
||||
for (i, line) in self.book_lines(text).iter().take(LINES).enumerate() {
|
||||
for (i, line) in self.page_lines(text).iter().take(LINES).enumerate() {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
line,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! `BundleMouseActions`: the wheel over a bundle in a container screen selects
|
||||
//! one of the items it shows, which a click that takes one out of it then takes
|
||||
//! (`BundleContents.Mutable.removeOne`). Moving off the bundle clears the
|
||||
//! selection. On a server the selection is sent to it (`ServerboundSelectBundleItemPacket`).
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
bundle::{Contents, toggle_selected},
|
||||
mouse::ScrollWheelHandler,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct BundleMouse {
|
||||
wheel: ScrollWheelHandler,
|
||||
/// The slot under the cursor at the last tick (`AbstractContainerScreen.hoveredSlot`).
|
||||
hovered: Option<usize>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A slot a bundle can be selected in: the player's inventory, the off hand and a
|
||||
/// container's own slots. A server only knows these in the inventory and chest-like menus.
|
||||
fn bundle_slot(&mut self, slot: usize) -> bool {
|
||||
(slot < 36 || slot == 504 || (300..500).contains(&slot))
|
||||
&& (self.remote.is_none() || self.remote_storage_open())
|
||||
&& (slot < 36 || slot == 504 || self.slots().contains(&slot))
|
||||
}
|
||||
|
||||
/// `BundleMouseActions.onMouseScrolled`: the wheel over a bundle moves the selection
|
||||
/// through the items its tooltip shows. True when the bundle took the wheel.
|
||||
pub(super) fn bundle_scroll(&mut self, x: f64, y: f64) -> bool {
|
||||
let Some(slot) = self.inventory_target().0 else {
|
||||
return false;
|
||||
};
|
||||
if !self.bundle_slot(slot) {
|
||||
return false;
|
||||
}
|
||||
let stack = self.slot_mut(slot).clone();
|
||||
let items = self.res.items();
|
||||
let Some(bundle) = stack.filter(|s| items.get(&s.item).is_some_and(|i| i.bundle)) else {
|
||||
return false;
|
||||
};
|
||||
let shown = Contents::of(items, &bundle).map_or(0, |c| c.shown_count());
|
||||
if shown == 0 {
|
||||
return false;
|
||||
}
|
||||
let (wheel_x, wheel_y) = self.bundle_mouse.wheel.on_mouse_scroll(x, y);
|
||||
let wheel = if wheel_y == 0 { -wheel_x } else { wheel_y };
|
||||
if wheel != 0 {
|
||||
let selected = bundle.data().map_or(-1, |d| d.bundle_selected.0);
|
||||
let next = ScrollWheelHandler::next_selection(f64::from(wheel), selected, shown as i32);
|
||||
if next != selected {
|
||||
self.select_bundle(slot, next);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `BundleMouseActions.toggleSelectedBundleItem`: the stack here and, on a server, there.
|
||||
fn select_bundle(&mut self, slot: usize, index: i32) {
|
||||
if let Some(stack) = self.slot_mut(slot) {
|
||||
toggle_selected(stack, index);
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
let (_, menu) = self.remote_menu();
|
||||
if let Some(menu_slot) = Self::menu_slot_index(&menu, slot) {
|
||||
self.send(S::SelectBundleItem(play::SelectBundleItem {
|
||||
slot: menu_slot as i32,
|
||||
index,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `BundleMouseActions.unselectedBundleItem` for the bundle in `slot`, if it has a selection.
|
||||
pub(super) fn unselect_bundle(&mut self, slot: usize) {
|
||||
if !self.bundle_slot(slot) {
|
||||
return;
|
||||
}
|
||||
let selected = self
|
||||
.slot_mut(slot)
|
||||
.as_ref()
|
||||
.and_then(|s| s.data())
|
||||
.is_some_and(|d| d.bundle_selected.0 >= 0);
|
||||
if selected {
|
||||
self.select_bundle(slot, -1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractContainerScreen.onStopHovering` (and `onClose`): leaving a slot, or the
|
||||
/// screen, clears the bundle's selection.
|
||||
pub(super) fn tick_bundle_hover(&mut self) {
|
||||
let now = if self.screen == Screen::Inventory {
|
||||
self.inventory_target().0
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(left) = std::mem::replace(&mut self.bundle_mouse.hovered, now)
|
||||
&& Some(left) != now
|
||||
{
|
||||
self.unselect_bundle(left);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! `CampfireBlock.useItemOn`: an item in `#minecraft:douses_campfires` (the
|
||||
//! shovels) put on a lit campfire puts it out.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, animate_tick::make_campfire_smoke, items::ItemStack};
|
||||
|
||||
impl Game<'_> {
|
||||
/// `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(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
held: Option<&ItemStack>,
|
||||
) -> Result<bool> {
|
||||
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),
|
||||
// then the block change game event from the player.
|
||||
if self.lan_acting.is_none() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
self.post_block_game_event("minecraft:block_change", pos, self.sculk_actor(), None);
|
||||
state.properties.insert("lit".into(), "false".into());
|
||||
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);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -695,12 +695,78 @@ impl Game<'_> {
|
||||
Some(ClickEvent::OpenFile(path)) => {
|
||||
super::packs::open_folder(std::path::Path::new(&path));
|
||||
}
|
||||
Some(ClickEvent::OpenUrl(url)) => self.click_url(url),
|
||||
Some(ClickEvent::SuggestCommand(command)) => {
|
||||
if let Some(screen) = self.ui.chat_screen.as_mut() {
|
||||
screen.suggest_command(&m, &c, &command);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
// Screen.defaultHandleClickEvent: a page link means nothing in chat.
|
||||
Some(ClickEvent::ChangePage(_)) | None => {}
|
||||
}
|
||||
}
|
||||
/// `Screen.clickUrlAction`: nothing with Web Links off, else the link
|
||||
/// opens, after the confirmation unless Prompt on Links is off.
|
||||
pub(super) fn click_url(&mut self, url: String) {
|
||||
if !self.settings.chat_links {
|
||||
return;
|
||||
}
|
||||
if self.settings.file.bool("chatLinksPrompt") {
|
||||
self.link_prompt = Some(url);
|
||||
} else {
|
||||
super::packs::open_uri(&url);
|
||||
}
|
||||
}
|
||||
/// `ConfirmLinkScreen` (untrusted): the link and its warning over the
|
||||
/// chat, answered yes to open it.
|
||||
fn link_prompt_layout(
|
||||
&self,
|
||||
url: &str,
|
||||
size: (f32, f32),
|
||||
) -> horizon_client::confirm_screen::Confirm {
|
||||
let warning = Text::translate("chat.link.warning", &[]).with_color(0xFF_CCCC);
|
||||
let message = Text::literal(url)
|
||||
.append(Text::literal("\n"))
|
||||
.append(warning);
|
||||
horizon_client::confirm_screen::Confirm::new(
|
||||
&self.gui.atlas.font,
|
||||
size,
|
||||
&Text::translate("chat.link.confirm", &[]),
|
||||
&message,
|
||||
)
|
||||
}
|
||||
pub(super) fn draw_link_prompt(&self, batch: &mut HudBatch) {
|
||||
let Some(url) = &self.link_prompt else {
|
||||
return;
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
let (yes, no) = (
|
||||
Text::translate("gui.yes", &[]),
|
||||
Text::translate("gui.no", &[]),
|
||||
);
|
||||
self.link_prompt_layout(url, (w, h))
|
||||
.draw(batch, atlas, self.cursor, [&yes, &no]);
|
||||
}
|
||||
/// The prompt's answer: the link opens on yes; either way the chat is back.
|
||||
pub(super) fn answer_link_prompt(&mut self, yes: bool) {
|
||||
if let Some(url) = self.link_prompt.take()
|
||||
&& yes
|
||||
{
|
||||
super::packs::open_uri(&url);
|
||||
}
|
||||
}
|
||||
pub(super) fn link_prompt_click(&mut self) {
|
||||
let Some(url) = &self.link_prompt else {
|
||||
return;
|
||||
};
|
||||
let answer = self
|
||||
.link_prompt_layout(url, self.gui_size())
|
||||
.answer_at(self.cursor);
|
||||
if let Some(yes) = answer {
|
||||
self.audio.play_button_click();
|
||||
self.answer_link_prompt(yes);
|
||||
}
|
||||
}
|
||||
/// Chat lines over the HUD (focused while the chat screen is open).
|
||||
@@ -782,6 +848,7 @@ impl Game<'_> {
|
||||
max_snow_accumulation_height: r.int("max_snow_accumulation_height"),
|
||||
spawn_mobs: r.bool("spawn_mobs"),
|
||||
spawn_monsters: r.bool("spawn_monsters"),
|
||||
fire_spread_radius_around_player: r.int("fire_spread_radius_around_player"),
|
||||
}
|
||||
}
|
||||
/// Apply rules mirrored in other state (natural regeneration, difficulty).
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
//! on the game that has the window, in-frame or following a server, never on
|
||||
//! a server: nothing of them goes over the network.
|
||||
use super::Game;
|
||||
use horizon_client::blocks::RenderShape;
|
||||
use horizon_client::mobs::{MobEvent, ambient};
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_client::motion::{LAVA, WATER};
|
||||
use horizon_client::particles::{ParticleEngine, ParticleOptions, ParticleType};
|
||||
use horizon_client::pose::Pose;
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn tick_client_ambient(&mut self) {
|
||||
@@ -33,5 +37,209 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.eye_particles();
|
||||
self.sprint_dust();
|
||||
self.effect_swirls();
|
||||
}
|
||||
|
||||
/// `Entity.baseTick`'s `spawnSprintParticle` on a client: dust of the
|
||||
/// block under a sprinting player's feet streams back from them every
|
||||
/// tick, for the window's own player and for the other players it sees.
|
||||
fn sprint_dust(&mut self) {
|
||||
// Each runner's feet, movement and supporting block.
|
||||
let mut runners: Vec<(Vec3, Vec3, Option<[i32; 3]>)> = Vec::new();
|
||||
// Entity.canSpawnSprintParticle.
|
||||
if let Some(p) = self.player.as_ref()
|
||||
&& p.sprinting
|
||||
&& !p.spectator
|
||||
&& p.pose != Pose::Crouching
|
||||
&& !p.health.dead_or_dying()
|
||||
&& !p.fluids.was_touching_water
|
||||
&& !p.is_in_lava()
|
||||
&& p.fluid_height(WATER) <= 0.0
|
||||
&& p.fluid_height(LAVA) <= 0.0
|
||||
{
|
||||
let support = p.ground.supporting_block.map(|b| [b.x, b.y, b.z]);
|
||||
runners.push((p.position, p.velocity, support));
|
||||
}
|
||||
let guests = self.lan.iter().flat_map(|host| host.models.values());
|
||||
for m in self.mobs.iter().chain(guests) {
|
||||
// Another player's fluids aren't known here: none where they stand.
|
||||
let feet = [
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
];
|
||||
if m.extra.sprinting
|
||||
&& !m.extra.crouching
|
||||
&& m.alive()
|
||||
&& &*m.kind.id == "minecraft:player"
|
||||
&& self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(feet[0], feet[1], feet[2]))
|
||||
.fluid_level
|
||||
== 0
|
||||
{
|
||||
runners.push((m.position, m.position.subtract(m.previous), None));
|
||||
}
|
||||
}
|
||||
for (position, movement, support) in runners {
|
||||
// Entity.getOnPosLegacy: getOnPos(0.2).
|
||||
let below = (position.y - f64::from(0.2f32)).floor() as i32;
|
||||
let tall = |game: &Self, s: [i32; 3]| {
|
||||
let name = game.block_name(s);
|
||||
[
|
||||
"minecraft:fences",
|
||||
"minecraft:walls",
|
||||
"minecraft:fence_gates",
|
||||
]
|
||||
.iter()
|
||||
.any(|tag| game.block_in_tag(tag, &name))
|
||||
};
|
||||
let at = match support {
|
||||
Some(s) if tall(self, s) => s,
|
||||
Some(s) => [s[0], below, s[2]],
|
||||
None => [position.x.floor() as i32, below, position.z.floor() as i32],
|
||||
};
|
||||
let state = self.world.block(at[0], at[1], at[2]);
|
||||
if self.res.facts().get(state).render_shape == RenderShape::Invisible {
|
||||
continue;
|
||||
}
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
|
||||
continue;
|
||||
};
|
||||
let width = f64::from(0.6f32);
|
||||
let r = self.particles.random();
|
||||
let mut x = position.x + (r.next_double() - 0.5) * width;
|
||||
let mut z = position.z + (r.next_double() - 0.5) * width;
|
||||
// Clamped into the block when the feet are in another column.
|
||||
if position.x.floor() as i32 != at[0] {
|
||||
x = x.clamp(f64::from(at[0]), f64::from(at[0]) + 1.0);
|
||||
}
|
||||
if position.z.floor() as i32 != at[2] {
|
||||
z = z.clamp(f64::from(at[2]), f64::from(at[2]) + 1.0);
|
||||
}
|
||||
self.particles.add(
|
||||
&ParticleOptions::block(ParticleType::Block, sprite, tint),
|
||||
[x, position.y + 0.1, z],
|
||||
[movement.x * -4.0, 1.5, movement.z * -4.0],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.tickEffects` on a client: the swirls around an entity
|
||||
/// with visible effects, from the particles its synched data lists. The
|
||||
/// window's player has its own effects, a server's mobs and players the
|
||||
/// list the server sent, and the mobs and guests of a game in the frame
|
||||
/// theirs.
|
||||
fn effect_swirls(&mut self) {
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let effects = &self.potions.effects.list;
|
||||
let list: Vec<_> = effects
|
||||
.iter()
|
||||
.filter(|e| e.visible)
|
||||
.filter_map(|e| effect_particle(e.id, e.ambient))
|
||||
.collect();
|
||||
let ambient = effects.iter().filter(|e| e.visible).all(|e| e.ambient);
|
||||
let invisible = effects.iter().any(|e| e.id == "invisibility");
|
||||
let size = (
|
||||
f64::from(p.dimensions.width),
|
||||
f64::from(p.dimensions.height),
|
||||
);
|
||||
swirl(
|
||||
&mut self.particles,
|
||||
p.position,
|
||||
size,
|
||||
&list,
|
||||
ambient,
|
||||
invisible,
|
||||
);
|
||||
}
|
||||
let guests = self.lan.iter().flat_map(|host| host.models.values());
|
||||
for m in self.mobs.iter().chain(guests) {
|
||||
if !m.alive() {
|
||||
continue;
|
||||
}
|
||||
let size = (f64::from(m.width()), f64::from(m.height()));
|
||||
let invisible = m.invisible || m.has_effect("minecraft:invisibility");
|
||||
if m.extra.effect_particles.is_empty() {
|
||||
// A mob of this game: its own effects, visible and not ambient.
|
||||
if m.extra.effects.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let list: Vec<_> = m
|
||||
.extra
|
||||
.effects
|
||||
.iter()
|
||||
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
|
||||
.filter_map(|e| effect_particle(e.0, false))
|
||||
.collect();
|
||||
swirl(
|
||||
&mut self.particles,
|
||||
m.position,
|
||||
size,
|
||||
&list,
|
||||
false,
|
||||
invisible,
|
||||
);
|
||||
} else {
|
||||
let list = &m.extra.effect_particles;
|
||||
let ambient = m.extra.effect_ambient;
|
||||
swirl(
|
||||
&mut self.particles,
|
||||
m.position,
|
||||
size,
|
||||
list,
|
||||
ambient,
|
||||
invisible,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `MobEffect.particleFactory`: the omens have particles of their own, any
|
||||
/// other effect an entity effect in its colour, faint when ambient.
|
||||
pub(super) fn effect_particle(effect: &str, ambient: bool) -> Option<ParticleOptions> {
|
||||
Some(match effect.trim_start_matches("minecraft:") {
|
||||
"trial_omen" => ParticleOptions::simple(ParticleType::TrialOmen),
|
||||
"raid_omen" => ParticleOptions::simple(ParticleType::RaidOmen),
|
||||
other => {
|
||||
let color = horizon_client::brewing::effect(other)?.color;
|
||||
let channel = |shift: u32| ((color >> shift) & 255) as f32 / 255.0;
|
||||
// MobEffect.AMBIENT_ALPHA is 38 of 255.
|
||||
let alpha = if ambient { 38.0 / 255.0 } else { 1.0 };
|
||||
ParticleOptions::colored(
|
||||
ParticleType::EntityEffect,
|
||||
[channel(16), channel(8), channel(0), alpha],
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// One tick of `LivingEntity.tickEffects`' client branch: a one in four
|
||||
/// chance (one in fifteen while invisible, a fifth of either when all the
|
||||
/// effects are ambient) of one of the particles at a random spot of the box.
|
||||
fn swirl(
|
||||
particles: &mut ParticleEngine,
|
||||
feet: Vec3,
|
||||
size: (f64, f64),
|
||||
list: &[ParticleOptions],
|
||||
ambient: bool,
|
||||
invisible: bool,
|
||||
) {
|
||||
if list.is_empty() {
|
||||
return;
|
||||
}
|
||||
let bound = if invisible { 15 } else { 4 } * if ambient { 5 } else { 1 };
|
||||
let r = particles.random();
|
||||
let Ok(0) = r.next_int_bound(bound) else {
|
||||
return;
|
||||
};
|
||||
// Util.getRandom, then getRandomX(0.5), getRandomY and getRandomZ.
|
||||
let pick = r.next_int_bound(list.len() as i32).unwrap_or_default() as usize;
|
||||
let x = feet.x + size.0 * (2.0 * r.next_double() - 1.0) * 0.5;
|
||||
let y = feet.y + size.1 * r.next_double();
|
||||
let z = feet.z + size.0 * (2.0 * r.next_double() - 1.0) * 0.5;
|
||||
particles.add(&list[pick], [x, y, z], [1.0; 3]);
|
||||
}
|
||||
@@ -10,6 +10,9 @@ struct Entry {
|
||||
fall_distance: f32,
|
||||
/// The causing entity: its id (for comparisons) and display name.
|
||||
attacker: Option<(u32, Text)>,
|
||||
/// The direct entity's display name when no entity caused the damage
|
||||
/// (`DamageSource.directEntity`: a potion with no thrower, a cloud).
|
||||
direct: Option<Text>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct CombatTracker {
|
||||
@@ -19,6 +22,8 @@ pub(super) struct CombatTracker {
|
||||
/// Attackers of damage dealt this tick, claimed by the matching
|
||||
/// `RecordedDamage` event.
|
||||
pending: Vec<(String, (u32, Text))>,
|
||||
/// `pending` for a direct entity with no causing entity: only its name.
|
||||
pending_direct: Vec<(String, Text)>,
|
||||
/// `LivingEntity.lastHurtByMob` and its timestamp (the kill credit).
|
||||
last_hurt_by: Option<((u32, Text), i64)>,
|
||||
/// The mob the player last hurt (`getLastHurtMob`) and when.
|
||||
@@ -31,6 +36,10 @@ pub(super) struct CombatTracker {
|
||||
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
|
||||
/// fire decides whom the blast may hurt.
|
||||
pub(super) explosion_source: Option<u32>,
|
||||
/// 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.
|
||||
pub(super) charged_exploders: Vec<u32>,
|
||||
/// The player's fall distance before this tick's movement (as of the
|
||||
/// end of the last one).
|
||||
fall_before: f64,
|
||||
@@ -62,6 +71,14 @@ impl Game<'_> {
|
||||
.pending
|
||||
.push((damage_type.to_owned(), (id, name)));
|
||||
}
|
||||
/// The `DamageSource.directEntity` of the next `damage_type` damage when
|
||||
/// nothing caused it (a splash potion nobody threw): the death message
|
||||
/// names it instead (`getLocalizedDeathMessage`). It is no kill credit.
|
||||
pub(super) fn note_direct_entity(&mut self, damage_type: &str, name: Text) {
|
||||
self.combat
|
||||
.pending_direct
|
||||
.push((damage_type.to_owned(), name));
|
||||
}
|
||||
/// `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]) {
|
||||
@@ -90,6 +107,15 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.position(|(t, _)| *t == name)
|
||||
.map(|i| self.combat.pending.remove(i).1);
|
||||
let direct = match attacker {
|
||||
Some(_) => None,
|
||||
None => self
|
||||
.combat
|
||||
.pending_direct
|
||||
.iter()
|
||||
.position(|(t, _)| *t == name)
|
||||
.map(|i| self.combat.pending_direct.remove(i).1),
|
||||
};
|
||||
let (fall_location, fall_distance) = self.fall_location();
|
||||
let now = self.world.game_time;
|
||||
if let Some(a) = &attacker {
|
||||
@@ -107,10 +133,12 @@ impl Game<'_> {
|
||||
fall_location,
|
||||
fall_distance,
|
||||
attacker,
|
||||
direct,
|
||||
});
|
||||
self.combat.last_damage = now;
|
||||
}
|
||||
self.combat.pending.clear();
|
||||
self.combat.pending_direct.clear();
|
||||
self.combat.fall_before = self.player.as_ref().map_or(0.0, |p| p.fall_distance);
|
||||
}
|
||||
/// `FallLocation.getCurrentFallLocation` and the fall distance so far.
|
||||
@@ -220,6 +248,7 @@ impl Game<'_> {
|
||||
let entries = std::mem::take(&mut self.combat.entries);
|
||||
let killing = entries.last().expect("checked above");
|
||||
let killer = killing.attacker.clone();
|
||||
let direct = killing.direct.clone();
|
||||
if def.1 == "fall_variants"
|
||||
&& let Some(knock) = knock.and_then(|i| entries.get(i))
|
||||
{
|
||||
@@ -244,15 +273,22 @@ impl Game<'_> {
|
||||
.append(Text::literal("]"));
|
||||
return Text::translate(&format!("{key}.message"), &[me, link]);
|
||||
}
|
||||
// `getLocalizedDeathMessage`: the causing entity's name, or the
|
||||
// direct entity's when nothing caused the damage.
|
||||
if let Some((_, name)) = killer {
|
||||
return Text::translate(&key, &[me, name]);
|
||||
}
|
||||
if let Some(name) = direct {
|
||||
return Text::translate(&key, &[me, name]);
|
||||
}
|
||||
// getKillCredit: the last mob that hurt the player in the past 100 ticks.
|
||||
if let Some(((_, name), at)) = &self.combat.last_hurt_by
|
||||
&& self.world.game_time - at <= 100
|
||||
{
|
||||
return Text::translate(&format!("{key}.player"), &[me, name.clone()]);
|
||||
}
|
||||
// No entity at all: the one-argument message, as vanilla sends it (a
|
||||
// template that names a killer then shows as written).
|
||||
Text::translate(&key, &[me])
|
||||
}
|
||||
}
|
||||
@@ -541,8 +541,7 @@ impl Game<'_> {
|
||||
.enumerate()
|
||||
// The kinds /data can write (entity_nbt).
|
||||
.filter(|(_, p)| {
|
||||
p.arrow.is_some()
|
||||
|| matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
|
||||
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
|
||||
})
|
||||
.map(|(i, _)| Target::Projectile(i)),
|
||||
);
|
||||
@@ -1697,6 +1696,8 @@ impl Game<'_> {
|
||||
value: 0,
|
||||
age: 0,
|
||||
on_ground: false,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
});
|
||||
name = Text::translate("entity.minecraft.experience_orb", &[]);
|
||||
}
|
||||
@@ -1885,11 +1886,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((_, new)) = horizon_client::save::decode_block_entity(&tag) else {
|
||||
let Some((_, mut new)) = horizon_client::save::decode_block_entity(&tag) else {
|
||||
return false;
|
||||
};
|
||||
let rolled = self.take_loot_table(key, &tag, &mut new);
|
||||
let after = horizon_client::save::encode_block_entity(key, &new, &name);
|
||||
if after == before {
|
||||
if after == before && !rolled {
|
||||
return false;
|
||||
}
|
||||
self.world.block_entities.insert(key, new);
|
||||
@@ -1897,6 +1899,79 @@ impl Game<'_> {
|
||||
let _ = self.world.set(pos[0], pos[1], pos[2], state);
|
||||
true
|
||||
}
|
||||
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
|
||||
/// container (`setblock`, `data`): an empty container with a `LootTable`
|
||||
/// is rolled now, as a loaded chunk's are (`save::roll_loaded_loot`), and
|
||||
/// the roll is kept with its chunk, so that the container saves, and
|
||||
/// `data get` shows, its table and seed until its items change. A tag
|
||||
/// without one drops what the container had. True when it had a table.
|
||||
pub(super) fn take_loot_table(
|
||||
&mut self,
|
||||
key: (i32, i32, i32),
|
||||
tag: &horizon_world::nbt::Tag,
|
||||
entity: &mut horizon_client::containers::BlockEntity,
|
||||
) -> bool {
|
||||
use horizon_client::{containers::BlockEntity, save, structure_blocks};
|
||||
let table = save::text(tag.get("LootTable"));
|
||||
let rolled = match (table, entity) {
|
||||
(Some(table), BlockEntity::Chest(slots)) if slots.iter().all(Option::is_none) => {
|
||||
let loot = structure_blocks::loot_data(&self.options.resources);
|
||||
let at = Vec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
|
||||
let biome = self.biome_name_at(at).unwrap_or_default();
|
||||
Some(structure_blocks::roll_container(
|
||||
slots,
|
||||
loot.as_deref(),
|
||||
table,
|
||||
structure_blocks::loot_seed(tag),
|
||||
key,
|
||||
&biome,
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some(chunk) = self.world.chunks.get_mut(&(key.0 >> 4, key.2 >> 4)) else {
|
||||
return false;
|
||||
};
|
||||
let held = |c: &horizon_client::chunk::Chunk| {
|
||||
c.saved
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.rolled_loot.iter().any(|r| r.pos == key))
|
||||
};
|
||||
if rolled.is_none() && !held(chunk) {
|
||||
return false;
|
||||
}
|
||||
let chunk = std::sync::Arc::make_mut(chunk);
|
||||
let mut extras = chunk
|
||||
.saved
|
||||
.take()
|
||||
.map(std::sync::Arc::unwrap_or_clone)
|
||||
.unwrap_or_default();
|
||||
extras.rolled_loot.retain(|r| r.pos != key);
|
||||
let taken = rolled.is_some();
|
||||
extras.rolled_loot.extend(rolled);
|
||||
chunk.saved = Some(std::sync::Arc::new(extras));
|
||||
taken
|
||||
}
|
||||
/// The loot table and seed a container still holding its roll saves
|
||||
/// (`RolledLoot::restore`), in place of its items.
|
||||
pub(super) fn restore_loot_table(
|
||||
&self,
|
||||
key: (i32, i32, i32),
|
||||
tag: &mut horizon_world::nbt::Tag,
|
||||
) {
|
||||
let horizon_world::nbt::Tag::Compound(map) = tag else {
|
||||
return;
|
||||
};
|
||||
let rolled = self
|
||||
.world
|
||||
.chunks
|
||||
.get(&(key.0 >> 4, key.2 >> 4))
|
||||
.and_then(|c| c.saved.as_ref())
|
||||
.and_then(|s| s.rolled_loot.iter().find(|r| r.pos == key));
|
||||
if let Some(r) = rolled {
|
||||
r.restore(map);
|
||||
}
|
||||
}
|
||||
fn cmd_fill(&mut self, ctx: &Context) -> R<i32> {
|
||||
let a = self.block_pos_arg(ctx, "from")?;
|
||||
let b = self.block_pos_arg(ctx, "to")?;
|
||||
@@ -2593,38 +2668,7 @@ impl Game<'_> {
|
||||
/// `PoiTypes`: the point of interest type of a block state.
|
||||
fn poi_type(&self, state: horizon_world::registry::BlockStateId) -> Option<&'static str> {
|
||||
let s = self.pack.assets.registry.state(state)?;
|
||||
let name = s.name.strip_prefix("minecraft:")?;
|
||||
Some(match name {
|
||||
"blast_furnace" => "minecraft:armorer",
|
||||
"smoker" => "minecraft:butcher",
|
||||
"cartography_table" => "minecraft:cartographer",
|
||||
"brewing_stand" => "minecraft:cleric",
|
||||
"composter" => "minecraft:farmer",
|
||||
"barrel" => "minecraft:fisherman",
|
||||
"fletching_table" => "minecraft:fletcher",
|
||||
"cauldron" | "lava_cauldron" | "water_cauldron" | "powder_snow_cauldron" => {
|
||||
"minecraft:leatherworker"
|
||||
}
|
||||
"lectern" => "minecraft:librarian",
|
||||
"stonecutter" => "minecraft:mason",
|
||||
"loom" => "minecraft:shepherd",
|
||||
"smithing_table" => "minecraft:toolsmith",
|
||||
"grindstone" => "minecraft:weaponsmith",
|
||||
"bell" => "minecraft:meeting",
|
||||
"beehive" => "minecraft:beehive",
|
||||
"bee_nest" => "minecraft:bee_nest",
|
||||
"nether_portal" => "minecraft:nether_portal",
|
||||
"lodestone" => "minecraft:lodestone",
|
||||
"test_instance_block" => "minecraft:test_instance",
|
||||
n if n.ends_with("_bed") => {
|
||||
if s.properties.get("part").map(String::as_str) != Some("head") {
|
||||
return None;
|
||||
}
|
||||
"minecraft:home"
|
||||
}
|
||||
n if n.ends_with("lightning_rod") => "minecraft:lightning_rod",
|
||||
_ => return None,
|
||||
})
|
||||
horizon_client::save::poi_type(s).map(|(kind, _)| kind)
|
||||
}
|
||||
/// `LocateCommand.locatePoi`: `PoiManager.findClosestWithType` within
|
||||
/// 256 blocks of the source, over the loaded chunks.
|
||||
|
||||
@@ -286,6 +286,7 @@ impl Game<'_> {
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.remove(&"keepPacked".into());
|
||||
}
|
||||
self.restore_loot_table(key, &mut tag);
|
||||
Ok(tag)
|
||||
}
|
||||
DataRef::Storage(id) => Ok(self.score.storage.get(id)),
|
||||
@@ -296,7 +297,8 @@ impl Game<'_> {
|
||||
DataRef::Entity(t) => self.apply_entity_nbt(*t, tag),
|
||||
DataRef::Block(p) => {
|
||||
let key = (p[0], p[1], p[2]);
|
||||
if let Some((_, new)) = save::decode_block_entity(&tag) {
|
||||
if let Some((_, mut new)) = save::decode_block_entity(&tag) {
|
||||
self.take_loot_table(key, &tag, &mut new);
|
||||
self.world.block_entities.insert(key, new);
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
// Redraw what shows the block entity (signs, banners).
|
||||
|
||||
@@ -91,7 +91,12 @@ impl Game<'_> {
|
||||
}
|
||||
Target::Mob(id) => match self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
Some(m) => {
|
||||
m.add_effect(namespaced(effect.id), duration, amplifier);
|
||||
let name = namespaced(effect.id);
|
||||
m.add_effect(name, duration, amplifier);
|
||||
m.extra.hidden_effects.retain(|h| *h != name);
|
||||
if !particles {
|
||||
m.extra.hidden_effects.push(name);
|
||||
}
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
@@ -154,6 +159,10 @@ impl Game<'_> {
|
||||
m.extra
|
||||
.effects
|
||||
.retain(|x| e.is_some_and(|e| x.0 != namespaced(e.id)));
|
||||
let left = &m.extra.effects;
|
||||
m.extra
|
||||
.hidden_effects
|
||||
.retain(|h| left.iter().any(|x| x.0 == *h));
|
||||
m.extra.effects.len() != before
|
||||
}
|
||||
None => false,
|
||||
|
||||
@@ -145,9 +145,42 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map_or(0.0, |m| m.eye_y() - m.position.y),
|
||||
_ => 0.0,
|
||||
// ArmorStand.getDefaultDimensions: a marker's are empty, a small
|
||||
// stand's are BABY_DIMENSIONS, whose eye height is 0.9875F.
|
||||
Target::Stand(id) => match self.armor_stands.iter().find(|a| a.id == id) {
|
||||
Some(a) if a.marker => 0.0,
|
||||
Some(a) if a.small => f64::from(0.9875f32),
|
||||
_ => self.type_eye_height(&self.target_type(t)),
|
||||
},
|
||||
// Entity.getEyeHeight: the type's dimensions.
|
||||
_ => self.type_eye_height(&self.target_type(t)),
|
||||
}
|
||||
}
|
||||
/// `EntityType.getDimensions().eyeHeight()` from `entity-types.json`,
|
||||
/// read once: a float, widened as `Entity.getEyeY` does.
|
||||
fn type_eye_height(&self, ty: &str) -> f64 {
|
||||
static EYE_HEIGHTS: std::sync::OnceLock<std::collections::HashMap<String, f64>> =
|
||||
std::sync::OnceLock::new();
|
||||
EYE_HEIGHTS
|
||||
.get_or_init(|| {
|
||||
std::fs::read(self.options.resources.join("entity-types.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
|
||||
.and_then(|v| {
|
||||
v.as_object().map(|o| {
|
||||
o.iter()
|
||||
.filter_map(|(k, t)| {
|
||||
Some((k.clone(), f64::from(t["eye_height"].as_f64()? as f32)))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.get(ty)
|
||||
.copied()
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
/// EntityAnchorArgument.Anchor.apply.
|
||||
pub(super) fn anchor_position(&self, s: &Source) -> [f64; 3] {
|
||||
match s.entity {
|
||||
@@ -467,9 +500,10 @@ impl Game<'_> {
|
||||
"positioned" if c.has("as") => targets(self)?
|
||||
.into_iter()
|
||||
.filter_map(|t| {
|
||||
// withPosition keeps the anchor (only `positioned <pos>`
|
||||
// resets it to the feet).
|
||||
Some(Source {
|
||||
pos: self.target_position(t)?,
|
||||
eyes: false,
|
||||
..s
|
||||
})
|
||||
})
|
||||
@@ -606,6 +640,72 @@ impl Game<'_> {
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
// Leashable.getLeashHolder: a fence knot is not an entity here.
|
||||
Some("leasher") => match entity {
|
||||
Target::Mob(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.and_then(|m| m.extra.leash.map(|holder| (holder, m.extra.leash_player)))
|
||||
.and_then(|(holder, player)| match holder {
|
||||
// The player who tied it (the host's when unrecorded).
|
||||
horizon_client::mobs::leash::Holder::Player => {
|
||||
let uuid = player.or(self.level.player_uuid)?;
|
||||
self.target_by_uuid(horizon_client::lan::playerdata::ints_uuid(uuid))
|
||||
}
|
||||
horizon_client::mobs::leash::Holder::Mob(i) => Some(Target::Mob(i)),
|
||||
horizon_client::mobs::leash::Holder::Knot(_) => None,
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
// TraceableEntity.getOwner: who shot a projectile, who threw an
|
||||
// item, who summoned a vex.
|
||||
Some("origin") => match entity {
|
||||
Target::Projectile(i) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.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))
|
||||
if self.lan_acting == Some(conn) =>
|
||||
{
|
||||
Some(Target::Player)
|
||||
}
|
||||
(horizon_client::mobs::Who::Player, Some(conn)) => {
|
||||
Some(Target::Guest(conn))
|
||||
}
|
||||
(horizon_client::mobs::Who::Player, None) => Some(Target::Player),
|
||||
(horizon_client::mobs::Who::Mob(id), _) => Some(Target::Mob(id)),
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
Target::Item(id) => self
|
||||
.dropped
|
||||
.iter()
|
||||
.find(|d| d.id == id)
|
||||
.and_then(|d| d.thrower)
|
||||
.and_then(|words| {
|
||||
let uuid = words
|
||||
.iter()
|
||||
.fold(0u128, |all, w| all << 32 | u128::from(*w as u32));
|
||||
self.target_by_uuid(uuid)
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
Target::Mob(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && m.kind.id == "minecraft:vex")
|
||||
.map(|m| m.extra.counter2 as u32)
|
||||
.filter(|owner| *owner != 0)
|
||||
.map(Target::Mob)
|
||||
.into_iter()
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
Some("vehicle") => self.ride_vehicle(entity).into_iter().collect(),
|
||||
Some("controller") | Some("passengers") => match entity {
|
||||
Target::Mob(id) if self.mobs.iter().any(|m| m.id == id && m.rider.is_some()) => {
|
||||
|
||||
@@ -244,7 +244,11 @@ impl Game<'_> {
|
||||
/// hand them the next jobs as soon as each one is done, rather than once
|
||||
/// a tick.
|
||||
pub(in crate::game) fn pump_pregen(&mut self, until: Instant) {
|
||||
while self.pregen_runs_here() {
|
||||
// Force-loaded chunks too: decorating in vanilla's order, a chunk
|
||||
// waits for its neighbours far more often than once a tick allows.
|
||||
while self.pregen_runs_here()
|
||||
|| (!self.streaming.forced.is_empty() && self.streaming.busy())
|
||||
{
|
||||
let now = Instant::now();
|
||||
if now >= until {
|
||||
break;
|
||||
|
||||
@@ -120,6 +120,9 @@ impl Game<'_> {
|
||||
tracker.track(&holder(chunk_text(x, z)), i32::from(changed));
|
||||
}
|
||||
}
|
||||
if add {
|
||||
self.streaming.rank_forced(cx0, cz0, cx1, cz1);
|
||||
}
|
||||
let (lo, hi) = (chunk_text(cx0, cz0), chunk_text(cx1, cz1));
|
||||
let verb = if add { "added" } else { "removed" };
|
||||
let (message, total) = tracker.message(
|
||||
|
||||
@@ -14,10 +14,11 @@ impl Game<'_> {
|
||||
serde_json::from_str(json).map_err(|e| err("argument.component.invalid", &[&e]))?;
|
||||
// NbtContents.resolve: the path's values in the entity, block or
|
||||
// storage data, rich-styled (or plain, or interpreted), ", "-joined.
|
||||
// ScoreContents.resolve: the holder's score in the objective.
|
||||
let nbt_values = std::cell::RefCell::new(Vec::new());
|
||||
{
|
||||
let mut collect = |o: &serde_json::Map<String, serde_json::Value>| {
|
||||
if o.contains_key("nbt") {
|
||||
if o.contains_key("nbt") || o.contains_key("score") {
|
||||
nbt_values.borrow_mut().push(o.clone());
|
||||
}
|
||||
};
|
||||
@@ -40,7 +41,11 @@ impl Game<'_> {
|
||||
}
|
||||
let mut resolved: Vec<(serde_json::Map<String, serde_json::Value>, Text)> = Vec::new();
|
||||
for o in nbt_values.into_inner() {
|
||||
let t = self.resolve_nbt_component(&o);
|
||||
let t = if o.contains_key("score") {
|
||||
self.resolve_score_component(&o)?
|
||||
} else {
|
||||
self.resolve_nbt_component(&o)
|
||||
};
|
||||
resolved.push((o, t));
|
||||
}
|
||||
let mut resolve = |raw: &str| -> Text {
|
||||
@@ -73,6 +78,54 @@ impl Game<'_> {
|
||||
},
|
||||
))
|
||||
}
|
||||
/// The text of a `score` component (ScoreContents.resolve): the named
|
||||
/// holder's score, `*` being the player the command runs for.
|
||||
fn resolve_score_component(
|
||||
&mut self,
|
||||
o: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> R<Text> {
|
||||
let score = o.get("score");
|
||||
let field = |k: &str| score.and_then(|s| s.get(k)).and_then(|v| v.as_str());
|
||||
let (name, objective) = (
|
||||
field("name").unwrap_or(""),
|
||||
field("objective").unwrap_or(""),
|
||||
);
|
||||
// findTargetName: a selector that matches one entity is that entity,
|
||||
// one that matches none is just the name.
|
||||
let mut reader = horizon_client::command::Reader::new(name);
|
||||
let ty = horizon_client::command::args::ArgType::Entity {
|
||||
single: false,
|
||||
players: false,
|
||||
};
|
||||
let mut holder = name.to_owned();
|
||||
if let Ok(Arg::Entity(sel)) = ty.parse(&mut reader, &self.ui.registries)
|
||||
&& !reader.can_read()
|
||||
{
|
||||
let found = self.select(&sel);
|
||||
if found.len() > 1 {
|
||||
return Err(err("argument.entity.toomany", &[]));
|
||||
}
|
||||
if let Some(t) = found.first().and_then(|t| self.holder_name(*t)) {
|
||||
holder = t;
|
||||
}
|
||||
}
|
||||
if holder == "*" {
|
||||
holder = self.acting_player_name().to_owned();
|
||||
}
|
||||
let board = &self.score.board;
|
||||
let (Some(objective), Some(score)) =
|
||||
(board.objective(objective), board.score(&holder, objective))
|
||||
else {
|
||||
return Ok(Text::empty());
|
||||
};
|
||||
// numberFormatOrDefault(StyledFormat.NO_STYLE).
|
||||
let format = (score.number_format.clone())
|
||||
.or_else(|| objective.number_format.clone())
|
||||
.unwrap_or(horizon_client::scoreboard::NumberFormat::Styled(
|
||||
serde_json::json!({}),
|
||||
));
|
||||
Ok(format.format(score.value))
|
||||
}
|
||||
/// The text of an `nbt` component.
|
||||
fn resolve_nbt_component(&mut self, o: &serde_json::Map<String, serde_json::Value>) -> Text {
|
||||
use horizon_world::nbt::Tag;
|
||||
@@ -136,28 +189,40 @@ impl Game<'_> {
|
||||
}
|
||||
out
|
||||
}
|
||||
fn player_targets(&mut self, ctx: &Context) -> R<usize> {
|
||||
// Only the local player can receive messages and titles.
|
||||
/// The players the command reaches: the window's own and the LAN guests.
|
||||
fn player_targets(&mut self, ctx: &Context) -> R<Vec<Target>> {
|
||||
let targets = self.entities(ctx, "targets", true)?;
|
||||
Ok(targets.iter().filter(|t| **t == Target::Player).count())
|
||||
Ok(targets
|
||||
.into_iter()
|
||||
.filter(|t| matches!(t, Target::Player | Target::Guest(_)))
|
||||
.collect())
|
||||
}
|
||||
pub(super) fn cmd_tellraw(&mut self, ctx: &Context) -> R<i32> {
|
||||
let n = self.player_targets(ctx)?;
|
||||
let players = self.player_targets(ctx)?;
|
||||
let message = self.component_arg(ctx, "message")?;
|
||||
if n > 0 {
|
||||
self.system_message(message);
|
||||
// Each player's chat (a guest's through the connection).
|
||||
for &t in &players {
|
||||
self.for_player(t, |g| g.system_message(message.clone()));
|
||||
}
|
||||
Ok(n as i32)
|
||||
Ok(players.len() as i32)
|
||||
}
|
||||
pub(super) fn cmd_title(&mut self, ctx: &Context) -> R<i32> {
|
||||
let n = self.player_targets(ctx)?;
|
||||
let name = self.target_name(Target::Player);
|
||||
let players = self.player_targets(ctx)?;
|
||||
let n = players.len();
|
||||
let name = players
|
||||
.first()
|
||||
.map_or_else(Text::empty, |t| self.target_name(*t));
|
||||
let single = n == 1;
|
||||
let key = if ctx.has("clear") {
|
||||
self.titles_or_packet(Titles::clear, C::ClearTitles(play::Bool1 { value: false }));
|
||||
self.titles_or_packet(
|
||||
&players,
|
||||
Titles::clear,
|
||||
C::ClearTitles(play::Bool1 { value: false }),
|
||||
);
|
||||
"cleared"
|
||||
} else if ctx.has("reset") {
|
||||
self.titles_or_packet(
|
||||
&players,
|
||||
|t| {
|
||||
t.clear();
|
||||
t.reset_times();
|
||||
@@ -172,6 +237,7 @@ impl Game<'_> {
|
||||
};
|
||||
let (fade_in, stay, fade_out) = (get("fadeIn"), get("stay"), get("fadeOut"));
|
||||
self.titles_or_packet(
|
||||
&players,
|
||||
|t| t.set_times(fade_in, stay, fade_out),
|
||||
C::SetTitlesAnimation(play::TitlesAnimation {
|
||||
fade_in,
|
||||
@@ -186,13 +252,17 @@ impl Game<'_> {
|
||||
text: component_nbt(&text.component()),
|
||||
};
|
||||
if ctx.has("subtitle") {
|
||||
self.titles_or_packet(|t| t.set_subtitle(text), C::SetSubtitleText(sent));
|
||||
self.titles_or_packet(&players, |t| t.set_subtitle(text), C::SetSubtitleText(sent));
|
||||
"show.subtitle"
|
||||
} else if ctx.has("actionbar") {
|
||||
self.titles_or_packet(|t| t.set_overlay(text, false), C::SetActionBarText(sent));
|
||||
self.titles_or_packet(
|
||||
&players,
|
||||
|t| t.set_overlay(text, false),
|
||||
C::SetActionBarText(sent),
|
||||
);
|
||||
"show.actionbar"
|
||||
} else {
|
||||
self.titles_or_packet(|t| t.set_title(text), C::SetTitleText(sent));
|
||||
self.titles_or_packet(&players, |t| t.set_title(text), C::SetTitleText(sent));
|
||||
"show.title"
|
||||
}
|
||||
};
|
||||
@@ -207,13 +277,27 @@ impl Game<'_> {
|
||||
self.feedback(message);
|
||||
Ok(n as i32)
|
||||
}
|
||||
/// The player the title is for: the guest acting now is sent the packet
|
||||
/// (TitleCommand's `connection.send`), the window's own player shows it.
|
||||
fn titles_or_packet(&mut self, local: impl FnOnce(&mut Titles), packet: C) {
|
||||
if self.lan_acting.is_some() {
|
||||
self.guest_packets.push(packet);
|
||||
} else {
|
||||
local(&mut self.titles);
|
||||
/// TitleCommand's `connection.send` for each player: a guest is sent the
|
||||
/// packet, the window's own player shows it.
|
||||
fn titles_or_packet(&mut self, players: &[Target], local: impl FnOnce(&mut Titles), packet: C) {
|
||||
let mut local = Some(local);
|
||||
for &t in players {
|
||||
match t {
|
||||
Target::Player if self.lan_acting.is_some() => {
|
||||
self.guest_packets.push(packet.clone());
|
||||
}
|
||||
Target::Player => {
|
||||
if let Some(local) = local.take() {
|
||||
local(&mut self.titles);
|
||||
}
|
||||
}
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,26 +49,12 @@ impl Game<'_> {
|
||||
let mut tracker = Tracker::default();
|
||||
for &t in &targets {
|
||||
let h = holder(self.target_name(t));
|
||||
// ServerRecipeBook.addRecipes/removeRecipes count recipe book
|
||||
// displays (a transmute recipe has one per material count).
|
||||
let mut n = 0;
|
||||
// ServerPlayer.awardRecipes / resetRecipes: the displays changed.
|
||||
let index = &self.res.stations().recipe_index;
|
||||
n += if give {
|
||||
let (count, unlocked) = self.level.recipe_book.add_recipes(index, &recipes);
|
||||
self.recipe_unlocked(&unlocked);
|
||||
count as i32
|
||||
} else {
|
||||
let removed = self.level.recipe_book.remove_recipes(index, &recipes);
|
||||
let count = removed.len() as i32;
|
||||
// A network player's client drops them from its book.
|
||||
if self.lan_acting.is_some() && !removed.is_empty() {
|
||||
self.guest_packets.push(C::RecipeBookRemove(
|
||||
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
|
||||
));
|
||||
}
|
||||
count
|
||||
};
|
||||
// ServerPlayer.awardRecipes / resetRecipes on the target's own
|
||||
// ServerRecipeBook: a guest's is swapped in for the call, and the
|
||||
// packets go to its client.
|
||||
let n = self
|
||||
.for_player(t, |g| g.change_recipes(give, &recipes))
|
||||
.unwrap_or(0);
|
||||
tracker.track(&h, n);
|
||||
}
|
||||
let verb = if give { "give" } else { "take" };
|
||||
@@ -92,6 +78,27 @@ impl Game<'_> {
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// ServerRecipeBook.addRecipes/removeRecipes for the player in place: how
|
||||
/// many recipe book displays changed (a transmute recipe has one per
|
||||
/// material count).
|
||||
fn change_recipes(&mut self, give: bool, recipes: &[String]) -> i32 {
|
||||
let index = &self.res.stations().recipe_index;
|
||||
if give {
|
||||
let (count, unlocked) = self.level.recipe_book.add_recipes(index, recipes);
|
||||
self.recipe_unlocked(&unlocked);
|
||||
return count as i32;
|
||||
}
|
||||
let removed = self.level.recipe_book.remove_recipes(index, recipes);
|
||||
let count = removed.len() as i32;
|
||||
// A network player's client drops them from its book.
|
||||
if self.lan_acting.is_some() && !removed.is_empty() {
|
||||
self.guest_packets.push(C::RecipeBookRemove(
|
||||
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
|
||||
));
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
// ---- /damage ----------------------------------------------------------------
|
||||
|
||||
fn damage_kind(ty: &str) -> DamageKind {
|
||||
@@ -108,6 +115,7 @@ impl Game<'_> {
|
||||
"in_fire" | "campfire" | "hot_floor" => DamageKind::InFire,
|
||||
"lava" => DamageKind::Lava,
|
||||
"drown" => DamageKind::Drown,
|
||||
"dry_out" => DamageKind::DryOut,
|
||||
"magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic,
|
||||
"sting" => DamageKind::Sting,
|
||||
"cactus" | "sweet_berry_bush" => DamageKind::Cactus,
|
||||
@@ -133,27 +141,37 @@ impl Game<'_> {
|
||||
Some(Arg::Entity(_)) => self.entities(ctx, "entity", false)?.first().copied(),
|
||||
_ => None,
|
||||
};
|
||||
let cause = match ctx.get("cause") {
|
||||
Some(Arg::Entity(_)) => self.entities(ctx, "cause", false)?.first().copied(),
|
||||
_ => None,
|
||||
};
|
||||
let from = match ctx.get("location") {
|
||||
Some(Arg::Coords(_)) => self.position_arg(ctx, "location"),
|
||||
_ => by.and_then(|t| self.target_position(t)),
|
||||
};
|
||||
let hurt = match target {
|
||||
// The causing mob is the one the hit is credited to and thorns
|
||||
// answer; the source position gives the knockback its direction.
|
||||
Target::Player => {
|
||||
if self.creative || self.spectator() {
|
||||
// `DamageSource(type, cause, by)`: the death message names the
|
||||
// causing entity, else the direct one. A mob is noted by
|
||||
// `hurt_player_from`, a player here.
|
||||
if let Some(t @ (Target::Player | Target::Guest(_))) = cause.or(by) {
|
||||
let name = self.target_name(t);
|
||||
self.note_direct_entity(&ty, name);
|
||||
}
|
||||
if self.spectator() {
|
||||
false
|
||||
} else if let Some(player) = self.player.as_mut() {
|
||||
match self.motion.health.damage.id(&ty) {
|
||||
Some(id) => player
|
||||
.hurt(&self.motion.health, self.world.health_rules, id, amount)
|
||||
.unwrap_or(false),
|
||||
None => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
let attacker = match cause.or(by) {
|
||||
Some(Target::Mob(i)) => Some(Some(i)),
|
||||
_ => Some(None),
|
||||
};
|
||||
self.hurt_player_from(amount, from, &ty, attacker)
|
||||
}
|
||||
}
|
||||
Target::Mob(id) => {
|
||||
let attacker = by.and_then(|t| match t {
|
||||
let attacker = cause.or(by).and_then(|t| match t {
|
||||
Target::Player => Some(Who::Player),
|
||||
Target::Mob(i) => Some(Who::Mob(i)),
|
||||
_ => None,
|
||||
@@ -294,9 +312,18 @@ impl Game<'_> {
|
||||
float("minVolume", 0.0),
|
||||
);
|
||||
let range = if volume > 1.0 { 16.0 * volume } else { 16.0 };
|
||||
// PlaySoundCommand: one seed for every player.
|
||||
let seed = self.world.random.next_long();
|
||||
let here = self.dim.id();
|
||||
let mut tracker = Tracker::default();
|
||||
for t in targets {
|
||||
if t != Target::Player {
|
||||
if !matches!(t, Target::Player | Target::Guest(_)) {
|
||||
continue;
|
||||
}
|
||||
// Only players in the source's level hear it.
|
||||
if let Target::Guest(conn) = t
|
||||
&& self.command_guest(conn).is_none_or(|g| g.dimension != here)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(p) = self.target_position(t) else {
|
||||
@@ -320,8 +347,26 @@ impl Game<'_> {
|
||||
} else {
|
||||
(pos, volume)
|
||||
};
|
||||
self.audio.play_on(source, &sound, Some(at), v, pitch);
|
||||
tracker.mark(&holder(Text::literal(self.acting_player_name())));
|
||||
// The connection is sent the sound; the window's own player
|
||||
// plays it.
|
||||
let guest = match t {
|
||||
Target::Guest(conn) => Some(conn),
|
||||
_ => self.lan_acting,
|
||||
};
|
||||
match guest.and_then(|conn| Some((conn, self.lan.as_ref()?))) {
|
||||
Some((conn, host)) => {
|
||||
let packet = host
|
||||
.hub
|
||||
.sound_packet(&sound, at, v, pitch, source as i32, seed);
|
||||
if t == Target::Player {
|
||||
self.guest_packets.push(packet);
|
||||
} else {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
}
|
||||
None => self.audio.play_on(source, &sound, Some(at), v, pitch),
|
||||
}
|
||||
tracker.mark(&holder(self.target_name(t)));
|
||||
}
|
||||
let name = Text::literal(sound.clone());
|
||||
let (message, total) = tracker.message(
|
||||
@@ -598,8 +643,10 @@ impl Game<'_> {
|
||||
let reached = reached.len() as i32;
|
||||
if !local {
|
||||
} else if count <= 0 {
|
||||
// No count: one particle with the delta as velocity.
|
||||
self.particles.create(&options, pos, delta);
|
||||
// handleParticleEvent, count 0: one particle, the speed times
|
||||
// the delta as velocity (both sent as floats, multiplied as floats).
|
||||
self.particles
|
||||
.create(&options, pos, delta.map(|d| f64::from(speed * d as f32)));
|
||||
} else {
|
||||
for _ in 0..count {
|
||||
let r = self.particles.random();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use super::{Game, commands::compute::ComputeContext};
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
mobs::villager,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
@@ -86,6 +87,82 @@ impl Game<'_> {
|
||||
self.composter_add(pos, &layers);
|
||||
(stack.count > 0).then_some(stack)
|
||||
}
|
||||
/// `WorkAtComposter.compostItems` for the villager at `mob`: a full
|
||||
/// composter is emptied, then up to 20 wheat or beetroot seeds go in
|
||||
/// (keeping 10 of each kind) until the composter reaches level 7. Level
|
||||
/// event 1500 plays once, at the end.
|
||||
pub(super) fn villager_compost_items(&mut self, mob: usize, pos: [i32; 3]) {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let Some(mut before) = self.composter_level(pos) else {
|
||||
return;
|
||||
};
|
||||
if before == 8 {
|
||||
self.with_level(|b, level, ticks| b.composter_extract(level, ticks, at));
|
||||
let _ = self.process_level_events();
|
||||
before = 0;
|
||||
}
|
||||
let mut remaining = 20;
|
||||
let mut amounts: Vec<(std::sync::Arc<str>, i32)> = Vec::new();
|
||||
let slots = villager::ext(&self.mobs[mob]).map_or(0, |e| e.inventory.len());
|
||||
'slots: for slot in (0..slots).rev() {
|
||||
if remaining <= 0 {
|
||||
break;
|
||||
}
|
||||
let stack = villager::ext_mut(&mut self.mobs[mob]).inventory[slot].clone();
|
||||
if !matches!(
|
||||
&*stack.item,
|
||||
"minecraft:wheat_seeds" | "minecraft:beetroot_seeds"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let known = amounts.iter().position(|(item, _)| *item == stack.item);
|
||||
let total = known.map_or(0, |k| amounts[k].1) + stack.count as i32;
|
||||
match known {
|
||||
Some(k) => amounts[k].1 = total,
|
||||
None => amounts.push((stack.item.clone(), total)),
|
||||
}
|
||||
let uses = (total - 10).min(remaining).min(stack.count as i32);
|
||||
if uses <= 0 {
|
||||
continue;
|
||||
}
|
||||
remaining -= uses;
|
||||
for _ in 0..uses {
|
||||
self.villager_insert_item(mob, slot, &stack, pos);
|
||||
if self.composter_level(pos) == Some(7) {
|
||||
break 'slots;
|
||||
}
|
||||
}
|
||||
}
|
||||
let after = self.composter_level(pos).unwrap_or(before);
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.composter_fill_effects(level, ticks, at, after != before)
|
||||
});
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
/// `ComposterBlock.insertItem` with a villager's stack: below level 7 a
|
||||
/// compostable item is used up and may add a layer.
|
||||
fn villager_insert_item(&mut self, mob: usize, slot: usize, stack: &ItemStack, pos: [i32; 3]) {
|
||||
if self.composter_level(pos).is_none_or(|level| level >= 7) {
|
||||
return;
|
||||
}
|
||||
let Some(layers) = self.compostable(stack) else {
|
||||
return;
|
||||
};
|
||||
let context = ComputeContext {
|
||||
block: Some(pos),
|
||||
target: None,
|
||||
};
|
||||
let layers = self.provider_int(&layers, &context, 0).unwrap_or(0);
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.composter_layer(level, ticks, at, layers));
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
let _ = self.process_level_events();
|
||||
let inventory = &mut villager::ext_mut(&mut self.mobs[mob]).inventory;
|
||||
inventory[slot].count -= 1;
|
||||
if inventory[slot].count == 0 {
|
||||
inventory.remove(slot);
|
||||
}
|
||||
}
|
||||
/// `ComposterBlock.handleFill` (level event 1500) on the client: the
|
||||
/// fill sound and ten composter particles above the compost.
|
||||
pub(super) fn composter_fill_event(&mut self, pos: BlockPos, success: bool) {
|
||||
|
||||
@@ -628,6 +628,9 @@ impl Game<'_> {
|
||||
// The port, or the game rules' search and number boxes.
|
||||
Screen::WorldOptions if text == "\\b" => self.world_options_text(None),
|
||||
Screen::WorldOptions => self.world_options_text(Some(text)),
|
||||
Screen::Inventory if self.mod_browser_typing() => {
|
||||
self.mod_browser_text((text != "\\b").then_some(text), text == "\\b")
|
||||
}
|
||||
Screen::Inventory if self.recipe_search_focused() => {
|
||||
self.recipe_text(Some(text), false)
|
||||
}
|
||||
|
||||
@@ -218,8 +218,9 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
/// `CreakingHeartBlock.playerWillDestroy`: the heart's creaking twitches to
|
||||
/// death and a natural heart drops experience.
|
||||
pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3]) {
|
||||
/// death and a natural heart drops experience. `onExplosionHit` does the
|
||||
/// same for the creaking (`by_player` false), without the experience.
|
||||
pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3], by_player: bool) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
if !matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
@@ -242,7 +243,7 @@ impl Game<'_> {
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("natural").cloned())
|
||||
.is_some_and(|v| v == "true");
|
||||
if natural && !self.creative && !self.spectator() {
|
||||
if by_player && natural && !self.creative && !self.spectator() {
|
||||
let xp = 20 + self.world.random.next_int_bound(5).unwrap_or(0) as u32;
|
||||
self.spawn_orbs(
|
||||
[
|
||||
|
||||
@@ -18,6 +18,7 @@ use horizon_client::{
|
||||
spawning::{self, BiomeFacts},
|
||||
},
|
||||
projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3, traverse_blocks},
|
||||
@@ -193,6 +194,7 @@ impl Game<'_> {
|
||||
velocity: p.velocity,
|
||||
yaw: self.yaw,
|
||||
hero: self.potions.effects.get("hero_of_the_village").is_some(),
|
||||
effects: self.potions.effects.list.iter().map(|e| e.id).collect(),
|
||||
uuid: self.level.player_uuid.unwrap_or_default(),
|
||||
allies: std::sync::Arc::new(self.player_allies()),
|
||||
remote: false,
|
||||
@@ -429,6 +431,12 @@ impl Game<'_> {
|
||||
),
|
||||
}
|
||||
}
|
||||
/// What the generator finalizes this level's structure mobs with.
|
||||
pub(super) fn share_spawn_conditions(&self) {
|
||||
if let Some(terrain) = self.end.locator() {
|
||||
terrain.set_spawn_conditions(self.difficulty_id() as u8, self.world.day_time);
|
||||
}
|
||||
}
|
||||
/// `ServerLevel.getCurrentDifficultyAt`.
|
||||
pub(super) fn local_difficulty(&self, at: [f64; 3]) -> spawning::LocalDifficulty {
|
||||
use horizon_world::food::Difficulty;
|
||||
@@ -497,6 +505,8 @@ impl Game<'_> {
|
||||
self.died.insert(m.id);
|
||||
self.mob_died(&m);
|
||||
}
|
||||
// Their victims have been looted: no charged creeper is waiting any more.
|
||||
self.combat.charged_exploders.clear();
|
||||
let uuid = self.level.player_uuid;
|
||||
let mut snapshot: Vec<Seen> = self.mobs.iter().map(|m| seen(m, uuid)).collect();
|
||||
let mut events = Vec::new();
|
||||
@@ -523,6 +533,8 @@ impl Game<'_> {
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut guest_events = Vec::new();
|
||||
// The mobs whose bodies go this tick: (id, feet, width, height).
|
||||
let mut gone = Vec::new();
|
||||
let level_random;
|
||||
{
|
||||
// The level random, lent to the mobs (brain behaviours draw from
|
||||
@@ -595,6 +607,11 @@ impl Game<'_> {
|
||||
let from = events.len();
|
||||
let kept = m.tick(&w, &mut scratch, &mut events);
|
||||
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
|
||||
// LivingEntity.tickDeath: after twenty ticks the body is removed.
|
||||
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
|
||||
let (width, height) = (f64::from(m.width()), f64::from(m.height()));
|
||||
gone.push((m.id, m.position, width, height));
|
||||
}
|
||||
if let Some(conn) = seen_guest {
|
||||
// ShulkerBullet.finalTarget is the guest's entity: the
|
||||
// bullet homes on it while the projectiles tick, where
|
||||
@@ -653,6 +670,12 @@ impl Game<'_> {
|
||||
}
|
||||
self.mobs.append(&mut mobs);
|
||||
self.died = died;
|
||||
// ... and entity event 60 goes out as it is: the poof.
|
||||
for (id, at, width, height) in gone {
|
||||
let at = [at.x, at.y, at.z];
|
||||
self.lan_entity_event(id as i32, 60, at);
|
||||
self.death_poof(at, width, height);
|
||||
}
|
||||
self.sync_riders();
|
||||
self.tick_skeleton_traps();
|
||||
self.tick_apparition();
|
||||
@@ -679,6 +702,22 @@ impl Game<'_> {
|
||||
self.handle_mob_events(queued)?;
|
||||
Ok(())
|
||||
}
|
||||
/// `LivingEntity.makePoofParticles` (entity event 60) about a mob's box.
|
||||
pub(super) fn death_poof(&mut self, at: [f64; 3], width: f64, height: f64) {
|
||||
let options = horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::Poof,
|
||||
);
|
||||
for _ in 0..20 {
|
||||
let r = self.particles.level_random();
|
||||
let xa = r.next_gaussian() * 0.02;
|
||||
let ya = r.next_gaussian() * 0.02;
|
||||
let za = r.next_gaussian() * 0.02;
|
||||
let x = at[0] + width * (2.0 * r.next_double() - 1.0) - xa * 10.0;
|
||||
let y = at[1] + height * r.next_double() - ya * 10.0;
|
||||
let z = at[2] + width * (2.0 * r.next_double() - 1.0) - za * 10.0;
|
||||
self.particles.add(&options, [x, y, z], [xa, ya, za]);
|
||||
}
|
||||
}
|
||||
/// LivingEntity.die / dropAllDeathLoot.
|
||||
fn mob_died(&mut self, m: &Mob) {
|
||||
let killer = self.last_hurt_by_player.remove(&m.id);
|
||||
@@ -779,7 +818,17 @@ impl Game<'_> {
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|o| o.id == id)
|
||||
.map(|o| drops::loot_entity(o, None)),
|
||||
.map(|o| drops::loot_entity(o, None))
|
||||
// A charged creeper's blast kills after the creeper left the list.
|
||||
.or_else(|| {
|
||||
self.combat
|
||||
.charged_exploders
|
||||
.contains(&id)
|
||||
.then(|| LootEntity {
|
||||
powered: true,
|
||||
..LootEntity::new("minecraft:creeper")
|
||||
})
|
||||
}),
|
||||
None => None,
|
||||
};
|
||||
let direct = match d.kind {
|
||||
@@ -815,12 +864,12 @@ impl Game<'_> {
|
||||
world_seed: self.level.seed,
|
||||
};
|
||||
let (items, skulls) = drops::die(m, &death, &mut self.rand_sequences);
|
||||
if skulls
|
||||
&& let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker)
|
||||
&& let Some(creeper) = self.mobs.iter_mut().find(|o| o.id == id)
|
||||
{
|
||||
if skulls && let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker) {
|
||||
// Creeper.droppedSkulls: one head per charged explosion.
|
||||
creeper.extra.charged = false;
|
||||
self.combat.charged_exploders.retain(|c| *c != id);
|
||||
if let Some(creeper) = self.mobs.iter_mut().find(|o| o.id == id) {
|
||||
creeper.extra.charged = false;
|
||||
}
|
||||
}
|
||||
let raider = horizon_client::mobs::raider::is_raider(m.species());
|
||||
for d in items {
|
||||
@@ -838,9 +887,51 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.dropFromGiftLootTable`: the gift table from its random
|
||||
/// sequence with the mob as `this` (a chicken's egg depends on its
|
||||
/// variant); the loot data of the game's resources decides, and without it
|
||||
/// the items' table engine (no entity) rolls it.
|
||||
fn drop_gift_loot(&mut self, mob: u32, table: &'static str, at: [f64; 3]) {
|
||||
use horizon_client::mobs::drops;
|
||||
use horizon_world::loot::EntityLootParams;
|
||||
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
|
||||
let this = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == mob)
|
||||
.map(|m| drops::loot_entity(m, None));
|
||||
let stacks = match (loot, this) {
|
||||
(Some(loot), Some(this)) if loot.has_table(table) => {
|
||||
let params = EntityLootParams {
|
||||
this,
|
||||
..EntityLootParams::default()
|
||||
};
|
||||
let seed = self.level.seed;
|
||||
match loot.random_sequence(table) {
|
||||
Some(id) => {
|
||||
let random = self.rand_sequences.get(id, seed);
|
||||
loot.roll_entity(table, random, ¶ms)
|
||||
.iter()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(from_loot)
|
||||
.collect()
|
||||
}
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
_ => self
|
||||
.res
|
||||
.items()
|
||||
.loot_table(table, false, None, &mut self.world.random),
|
||||
};
|
||||
for d in stacks {
|
||||
self.spawn_item(d, at, [0.0, 0.2, 0.0], 10);
|
||||
}
|
||||
}
|
||||
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
|
||||
for e in events {
|
||||
match e {
|
||||
MobEvent::SquidCycle { mob, at } => self.lan_entity_event(mob as i32, 19, at),
|
||||
MobEvent::RabbitHop { mob, at } => self.lan_entity_event(mob as i32, 1, at),
|
||||
MobEvent::Sound(name, at, volume, pitch)
|
||||
| MobEvent::LocalSound(name, at, volume, pitch) => {
|
||||
@@ -902,16 +993,24 @@ impl Game<'_> {
|
||||
owner,
|
||||
} => self.add_fangs(at, yaw, delay, owner),
|
||||
MobEvent::Shoot(..) => {}
|
||||
MobEvent::ExplosionSource { mob, charged } => {
|
||||
self.combat.explosion_source = Some(mob);
|
||||
if charged {
|
||||
self.combat.charged_exploders.push(mob);
|
||||
}
|
||||
}
|
||||
MobEvent::Explode(at, radius) => {
|
||||
// The exploding mob causes the damage (Creeper.explodeCreeper).
|
||||
let source_mob = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
(m.position.x - at[0]).powi(2) + (m.position.z - at[2]).powi(2) < 1.0
|
||||
})
|
||||
.map(|m| m.id)
|
||||
.next();
|
||||
let source_mob = self.combat.explosion_source.or_else(|| {
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
(m.position.x - at[0]).powi(2) + (m.position.z - at[2]).powi(2)
|
||||
< 1.0
|
||||
})
|
||||
.map(|m| m.id)
|
||||
.next()
|
||||
});
|
||||
if let Some(id) = source_mob {
|
||||
self.note_attacker("minecraft:explosion", id);
|
||||
}
|
||||
@@ -942,6 +1041,9 @@ impl Game<'_> {
|
||||
volume,
|
||||
pitch,
|
||||
);
|
||||
} else {
|
||||
// Left for the end of the tick.
|
||||
t.outbox.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1012,6 +1114,11 @@ impl Game<'_> {
|
||||
self.next_entity += 1;
|
||||
p.id = self.next_entity;
|
||||
let (owner, at) = (p.owner, [p.position.x, p.position.y, p.position.z]);
|
||||
// LlamaSpit.recreateFromPacket: the spit's burst on the client.
|
||||
if p.kind == ProjectileKind::LlamaSpit {
|
||||
let v = p.velocity;
|
||||
self.particles.llama_spit_burst(at, [v.x, v.y, v.z]);
|
||||
}
|
||||
self.creatures.projectiles.push(p);
|
||||
self.projectile_event("minecraft:projectile_shoot", owner, at);
|
||||
}
|
||||
@@ -1044,6 +1151,40 @@ impl Game<'_> {
|
||||
self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open));
|
||||
self.process_level_events()?;
|
||||
}
|
||||
MobEvent::BreakDoor { pos } => {
|
||||
// BreakDoorGoal.tick: removeBlock, then level events 1021
|
||||
// and 2001 with the state that is there now.
|
||||
let air = self.world.air;
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, air, 3));
|
||||
self.process_level_events()?;
|
||||
self.level_event_at(1021, pos, 0);
|
||||
let left = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.level_event_at(2001, pos, left.0 as i32);
|
||||
}
|
||||
MobEvent::DestroyProgress { mob, pos, progress } => {
|
||||
self.destroy_block_progress(mob as i32, pos, progress);
|
||||
// ServerLevel.destroyBlockProgress: every player nearby sees it.
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub.broadcast_near(
|
||||
[f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])],
|
||||
64.0,
|
||||
&horizon_protocol::packets::play::Clientbound::BlockDestruction(
|
||||
horizon_protocol::packets::play::BlockDestruction {
|
||||
id: mob as i32,
|
||||
pos,
|
||||
progress: if (0..10).contains(&progress) {
|
||||
progress as u8
|
||||
} else {
|
||||
255
|
||||
},
|
||||
},
|
||||
),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
MobEvent::LevelEvent { id, pos, data } => self.level_event_at(id, pos, data),
|
||||
MobEvent::SetBlockName { pos, name, effect } => {
|
||||
if effect {
|
||||
let old = self.world.block(pos[0], pos[1], pos[2]);
|
||||
@@ -1127,7 +1268,7 @@ impl Game<'_> {
|
||||
self.particles.add(&options, at, velocity);
|
||||
}
|
||||
}
|
||||
MobEvent::Experience { at, amount } => self.spawn_orbs(at, amount),
|
||||
MobEvent::Experience { at, amount } => self.spawn_breeding_orb(at, amount),
|
||||
MobEvent::AxolotlSupportPlayer => {
|
||||
let previous = self
|
||||
.potions
|
||||
@@ -1189,6 +1330,10 @@ impl Game<'_> {
|
||||
self.mob_vibration_particle(from, to, ticks)
|
||||
}
|
||||
MobEvent::DigParticles { at } => self.warden_dig_particles(at),
|
||||
MobEvent::BlockBreakEffect { pos, block } => {
|
||||
let state = self.world.block(block[0], block[1], block[2]);
|
||||
self.destroy_particles(pos, state);
|
||||
}
|
||||
MobEvent::Darkness { at, radius } => self.apply_darkness(at, radius),
|
||||
MobEvent::CreakingHurt {
|
||||
home,
|
||||
@@ -1199,12 +1344,14 @@ impl Game<'_> {
|
||||
self.creaking_tear_down(center, spread)
|
||||
}
|
||||
MobEvent::CureZombieVillager { mob } => self.cure_zombie_villager(mob),
|
||||
MobEvent::Reinforcements { mob, target } => self.call_reinforcements(mob, target),
|
||||
MobEvent::VillagerMeet { mob, other } => self.villager_meet(mob, other),
|
||||
MobEvent::VillagerBirth {
|
||||
parent,
|
||||
partner,
|
||||
child,
|
||||
} => self.villager_birth(parent, partner, child),
|
||||
MobEvent::WorkAtComposter { mob, pos } => self.villager_work_at_composter(mob, pos),
|
||||
MobEvent::GolemCheck { mob, needed } => self.villager_golem_check(mob, needed),
|
||||
MobEvent::Firework { at, color, flight } => {
|
||||
self.villager_firework(at, color, flight)
|
||||
@@ -1225,19 +1372,51 @@ impl Game<'_> {
|
||||
m.ai.aggressive |= aggressive;
|
||||
}
|
||||
}
|
||||
MobEvent::GiftLoot { table, at } => {
|
||||
let drops =
|
||||
self.res
|
||||
.items()
|
||||
.loot_table(table, false, None, &mut self.world.random);
|
||||
for d in drops {
|
||||
self.spawn_item(d, at, [0.0, 0.2, 0.0], 10);
|
||||
MobEvent::GiftLoot { mob, table, at } => {
|
||||
// Turtle.ageBoundaryReached: only with mob loot on.
|
||||
if table == "minecraft:gameplay/turtle_grow" && !self.ui.rules.bool("mob_drops")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
self.drop_gift_loot(mob, table, at);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `TurtleEggBlock.randomTick` at the last hatch stage: one baby turtle
|
||||
/// per egg, a day from grown, with the egg as its home (the sound, the
|
||||
/// crumbs and the block's removal came from the level).
|
||||
pub(super) fn hatch_turtles(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
) {
|
||||
let eggs = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("eggs"))
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.unwrap_or(1);
|
||||
for i in 0..eggs {
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.3 + f64::from(i) * 0.2,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.3,
|
||||
];
|
||||
if let Some(id) = self.spawn_mob("minecraft:turtle", at, true)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
m.extra.age = -24000;
|
||||
m.extra.home = Some(pos);
|
||||
m.yaw = 0.0;
|
||||
m.body_yaw = 0.0;
|
||||
m.head_yaw = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn apply_setup(&mut self, id: u32, setup: &SpawnSetup) {
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
|
||||
return;
|
||||
@@ -1271,6 +1450,13 @@ impl Game<'_> {
|
||||
m.extra.egg_time = life;
|
||||
}
|
||||
}
|
||||
/// `Animal.finalizeSpawnChildFromBreeding`'s experience orb: only when the
|
||||
/// `mob_drops` game rule is on.
|
||||
fn spawn_breeding_orb(&mut self, at: [f64; 3], amount: u32) {
|
||||
if self.ui.rules.bool("mob_drops") {
|
||||
self.spawn_orbs(at, amount);
|
||||
}
|
||||
}
|
||||
/// Animal.spawnChildFromBreeding
|
||||
fn breed(
|
||||
&mut self,
|
||||
@@ -1293,6 +1479,8 @@ impl Game<'_> {
|
||||
.unwrap_or_else(|| parent.clone());
|
||||
let mut child = parent.clone();
|
||||
child.baby = true;
|
||||
// TurtleBreedGoal.breed triggers it without a child.
|
||||
let turtle = p.species() == Species::Turtle;
|
||||
if matches!(
|
||||
(
|
||||
p.species(),
|
||||
@@ -1305,7 +1493,7 @@ impl Game<'_> {
|
||||
) {
|
||||
child.kind = "minecraft:mule".into();
|
||||
}
|
||||
self.trigger_bred(&parent, &partner_view, Some(&child));
|
||||
self.trigger_bred(&parent, &partner_view, (!turtle).then_some(&child));
|
||||
}
|
||||
if let Some(q) = self.mobs.iter_mut().find(|m| m.id == partner) {
|
||||
q.extra.age = 6000;
|
||||
@@ -1317,6 +1505,19 @@ impl Game<'_> {
|
||||
self.lay_sniffer_egg(&p);
|
||||
return;
|
||||
}
|
||||
// TurtleBreedGoal.breed: the goal gave the mother her egg; only the
|
||||
// experience is left, and only with mob loot on.
|
||||
if p.species() == Species::Turtle {
|
||||
// One `new ExperienceOrb` of 1 to 7 from the mother's random (not
|
||||
// split up as `ExperienceOrb.award` does).
|
||||
if self.ui.rules.bool("mob_drops")
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == parent)
|
||||
{
|
||||
let xp = 1 + m.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
self.add_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let coin = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
|
||||
let coin2 = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
|
||||
// A horse and a donkey have a mule.
|
||||
@@ -1364,7 +1565,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.world.random = r;
|
||||
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp);
|
||||
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
return;
|
||||
}
|
||||
if p.species() == Species::Cat {
|
||||
@@ -1385,7 +1586,7 @@ impl Game<'_> {
|
||||
};
|
||||
}
|
||||
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp);
|
||||
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
return;
|
||||
}
|
||||
if p.species() == Species::Wolf {
|
||||
@@ -1407,7 +1608,7 @@ impl Game<'_> {
|
||||
};
|
||||
}
|
||||
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp);
|
||||
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
return;
|
||||
}
|
||||
// Nautilus.getBreedOffspring: a tame parent's baby is tame too.
|
||||
@@ -1423,7 +1624,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
|
||||
self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp);
|
||||
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
|
||||
}
|
||||
/// `MobEffectUtil.addEffectToPlayersAround` for an elder guardian:
|
||||
/// mining fatigue III for five minutes unless a stronger or longer one
|
||||
@@ -1480,11 +1681,34 @@ impl Game<'_> {
|
||||
pub(super) fn mob_hits_player_kind(&mut self, amount: f32, from: [f64; 3], kind: &str) {
|
||||
self.mob_hits_player(amount, from, kind);
|
||||
}
|
||||
/// Damage whose causing mob is known (`cause`, or none): not guessed
|
||||
/// from the mob nearest to `from`.
|
||||
pub(super) fn mob_hits_player_caused(
|
||||
&mut self,
|
||||
amount: f32,
|
||||
from: [f64; 3],
|
||||
kind: &str,
|
||||
cause: Option<u32>,
|
||||
) {
|
||||
self.hurt_player_from(amount, Some(from), kind, Some(cause));
|
||||
}
|
||||
pub(super) fn mob_hits_player(
|
||||
&mut self,
|
||||
amount: f32,
|
||||
from: [f64; 3],
|
||||
damage_type: &str,
|
||||
) -> bool {
|
||||
self.hurt_player_from(amount, Some(from), damage_type, None)
|
||||
}
|
||||
/// `hurtServer` on the player. `from` is the source's position (none: no
|
||||
/// direction), `known`: the causing mob when it is given (`/damage`),
|
||||
/// else the melee attacker is the mob nearest to `from`.
|
||||
pub(super) fn hurt_player_from(
|
||||
&mut self,
|
||||
amount: f32,
|
||||
from: Option<[f64; 3]>,
|
||||
damage_type: &str,
|
||||
known: Option<Option<u32>>,
|
||||
) -> bool {
|
||||
// ServerPlayer.canHarmPlayer refused another player's projectile.
|
||||
if self.creative || self.combat.refuse_player_hit {
|
||||
@@ -1497,37 +1721,44 @@ impl Game<'_> {
|
||||
.damage_tags
|
||||
.get("minecraft:is_projectile")
|
||||
.is_some_and(|t| t.contains(damage_type));
|
||||
let blocking = self.player_blocking(Some(from), damage_type, false);
|
||||
let blocking = self.player_blocking(from, damage_type, false);
|
||||
let raised = blocking.is_some();
|
||||
// Melee: the attacker stands at `from` (another player's hit has no mob behind it).
|
||||
let attacker = (!projectile && damage_type != "minecraft:player_attack")
|
||||
.then(|| {
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.min_by(|a, b| {
|
||||
let d = |m: &horizon_client::mobs::Mob| {
|
||||
(m.position.x - from[0]).powi(2) + (m.position.z - from[2]).powi(2)
|
||||
};
|
||||
d(a).total_cmp(&d(b))
|
||||
})
|
||||
.map(|m| m.id)
|
||||
})
|
||||
.flatten();
|
||||
let attacker = match known {
|
||||
Some(known) => known,
|
||||
None => from
|
||||
.filter(|_| !projectile && damage_type != "minecraft:player_attack")
|
||||
.and_then(|from| {
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.min_by(|a, b| {
|
||||
let d = |m: &horizon_client::mobs::Mob| {
|
||||
(m.position.x - from[0]).powi(2) + (m.position.z - from[2]).powi(2)
|
||||
};
|
||||
d(a).total_cmp(&d(b))
|
||||
})
|
||||
.map(|m| m.id)
|
||||
}),
|
||||
};
|
||||
// The causing entity for the death message.
|
||||
let owner = self.combat.projectile_owner.take();
|
||||
if let Some(a) = attacker.or(owner) {
|
||||
self.note_attacker(damage_type, a);
|
||||
}
|
||||
// DamageSource.scalesWithDifficulty: a mob (or its projectile's
|
||||
// owner) caused the hit, not a player.
|
||||
let living_cause = attacker.or(owner).is_some();
|
||||
let mut outcome = horizon_client::health::BlockOutcome::default();
|
||||
if let Some(player) = self.player.as_mut()
|
||||
&& let Some(id) = self.motion.health.damage.id(damage_type)
|
||||
&& let Ok(o) = player.hurt_blocking(
|
||||
&& let Ok(o) = player.hurt_caused_by(
|
||||
&self.motion.health,
|
||||
self.world.health_rules,
|
||||
id,
|
||||
amount,
|
||||
blocking,
|
||||
living_cause,
|
||||
)
|
||||
&& {
|
||||
outcome = o;
|
||||
@@ -1537,7 +1768,10 @@ impl Game<'_> {
|
||||
// LivingEntity.hurtServer's dealDefaultKnockback: not inside the
|
||||
// hurt cooldown (a stronger hit there only adds its difference).
|
||||
if o.knockback {
|
||||
let (xd, zd) = (from[0] - player.position.x, from[2] - player.position.z);
|
||||
// No source position: a random direction.
|
||||
let (xd, zd) = from.map_or((0.0, 0.0), |from| {
|
||||
(from[0] - player.position.x, from[2] - player.position.z)
|
||||
});
|
||||
self.knock_back_player(f64::from(0.4_f32), xd, zd);
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& let Some(r) = p.hurt_log.last_mut()
|
||||
@@ -1545,8 +1779,10 @@ impl Game<'_> {
|
||||
r.knockback = Some([p.velocity.x, p.velocity.y, p.velocity.z]);
|
||||
}
|
||||
}
|
||||
self.hurt_armor(amount);
|
||||
self.thorns(from);
|
||||
// The thorns hurt the entity that caused the hit, if there is one.
|
||||
if let Some(a) = attacker.or(owner) {
|
||||
self.thorns(a);
|
||||
}
|
||||
}
|
||||
if raised {
|
||||
self.after_block(outcome, amount, attacker, projectile);
|
||||
@@ -1873,6 +2109,33 @@ impl Game<'_> {
|
||||
None => self.projectile_hit(p, hit),
|
||||
}
|
||||
}
|
||||
/// What an arrow or a thrown trident hits an entity of type `victim`
|
||||
/// for (`AbstractArrow.onHitEntity`, `ThrownTrident.onHitEntity`): a
|
||||
/// trident's 8 through `EnchantmentHelper.modifyDamage` of the trident
|
||||
/// (Impaling's `minecraft:damage` on `#sensitive_to_impaling`).
|
||||
fn shot_damage(&mut self, shot: &mut Projectile, victim: Option<&str>) -> f32 {
|
||||
if !matches!(shot.kind, ProjectileKind::Trident) {
|
||||
return arrow_damage(shot, &mut self.world.random) + shot.bonus.damage;
|
||||
}
|
||||
let Some(trident) = shot.arrow.as_ref().map(|a| a.pickup_item.clone()) else {
|
||||
return 8.0;
|
||||
};
|
||||
let ctx = horizon_client::enchant::EffectContext {
|
||||
this: victim,
|
||||
attacker: (shot.owner == Some(Who::Player)).then_some("minecraft:player"),
|
||||
direct_attacker: Some("minecraft:trident"),
|
||||
damage_type: Some("minecraft:trident"),
|
||||
items: Some(self.res.items()),
|
||||
..Default::default()
|
||||
};
|
||||
self.res.stations().enchantments.modify_value(
|
||||
Some(&trident),
|
||||
"minecraft:damage",
|
||||
&ctx,
|
||||
&mut self.world.random,
|
||||
8.0,
|
||||
)
|
||||
}
|
||||
/// What an arrow's tick asked for: its particles and sounds.
|
||||
fn arrow_event(&mut self, at: Vec3, event: horizon_client::arrows::ArrowEvent) {
|
||||
use horizon_client::arrows::{ArrowEvent, ArrowParticle, ArrowSound, BlockReaction};
|
||||
@@ -1901,6 +2164,8 @@ impl Game<'_> {
|
||||
ArrowSound::Splash => "minecraft:entity.generic.splash",
|
||||
ArrowSound::HitGround => "minecraft:entity.arrow.hit",
|
||||
ArrowSound::Extinguish => "minecraft:entity.generic.extinguish_fire",
|
||||
ArrowSound::TridentHitGround => "minecraft:item.trident.hit_ground",
|
||||
ArrowSound::TridentHit => "minecraft:item.trident.hit",
|
||||
};
|
||||
self.audio
|
||||
.play(name, Some([at.x, at.y, at.z]), volume, pitch);
|
||||
@@ -1997,6 +2262,23 @@ impl Game<'_> {
|
||||
self.creatures.projectiles.push(arrow);
|
||||
}
|
||||
}
|
||||
/// `TargetBlock.onProjectileHit`: a target block takes its power from
|
||||
/// where the projectile landed and resets after 20 ticks for an arrow, 8
|
||||
/// for any other projectile. A player's shot counts as a target hit.
|
||||
fn target_block_hit(&mut self, p: &Projectile, pos: [i32; 3], at: Vec3, face: [i32; 3]) {
|
||||
let block = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let arrow = p.arrow.is_some();
|
||||
let location = [at.x, at.y, at.z];
|
||||
let hit = self.with_level(|b, level, ticks| {
|
||||
b.target_projectile_hit(level, ticks, block, location, face, arrow)
|
||||
});
|
||||
if hit.is_some() {
|
||||
let _ = self.process_level_events();
|
||||
if p.owner == Some(Who::Player) {
|
||||
self.stats.custom("target_hit", 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn projectile_hit(&mut self, p: &Projectile, hit: Hit) -> Result<()> {
|
||||
self.combat.projectile_owner = match p.owner {
|
||||
Some(Who::Mob(id)) => Some(id),
|
||||
@@ -2006,6 +2288,13 @@ impl Game<'_> {
|
||||
Hit::Entity { at, .. } | Hit::Block { at, .. } => at,
|
||||
Hit::None => return Ok(()),
|
||||
};
|
||||
// Projectile.onHitBlock: BlockState.onProjectileHit, for every
|
||||
// projectile (a wind charge out of the world's top has no face).
|
||||
if let Hit::Block { pos, face, .. } = hit
|
||||
&& face != [0; 3]
|
||||
{
|
||||
self.target_block_hit(p, pos, at, face);
|
||||
}
|
||||
if let Hit::Entity {
|
||||
who: Who::Mob(id),
|
||||
at,
|
||||
@@ -2026,11 +2315,11 @@ impl Game<'_> {
|
||||
&& id >= super::dragon::SPECIAL
|
||||
{
|
||||
let by_player = p.owner == Some(Who::Player);
|
||||
if let ProjectileKind::Arrow { .. } = p.kind {
|
||||
if p.arrow.is_some() {
|
||||
// AbstractArrow.onHitEntity: spent if the part or crystal took
|
||||
// it, else turned back (a perched dragon sets it alight first).
|
||||
let mut arrow = p.clone();
|
||||
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
|
||||
let amount = self.shot_damage(&mut arrow, None);
|
||||
let outcome = match self.hit_special(id, amount, by_player, true) {
|
||||
super::dragon::Hurt::Landed { .. } => EntityHit::Hurt,
|
||||
super::dragon::Hurt::Refused => EntityHit::Refused,
|
||||
@@ -2051,17 +2340,15 @@ impl Game<'_> {
|
||||
self.hit_special(id, amount, by_player, false);
|
||||
return Ok(());
|
||||
}
|
||||
if let (
|
||||
ProjectileKind::Arrow { .. },
|
||||
Hit::Entity {
|
||||
if p.arrow.is_some()
|
||||
&& let Hit::Entity {
|
||||
who: Who::Mob(id), ..
|
||||
},
|
||||
) = (&p.kind, hit)
|
||||
} = hit
|
||||
&& let Some(i) = self.carts.iter().position(|c| c.id == id)
|
||||
{
|
||||
// AbstractArrow.onHitEntity on a minecart.
|
||||
let mut arrow = p.clone();
|
||||
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
|
||||
let amount = self.shot_damage(&mut arrow, None);
|
||||
let burning = p.arrow.as_ref().is_some_and(|a| a.on_fire());
|
||||
let creative = p.owner == Some(Who::Player) && self.creative;
|
||||
self.arrow_hits_cart(i, amount, p.velocity, burning, creative);
|
||||
@@ -2072,7 +2359,7 @@ impl Game<'_> {
|
||||
// Projectile.onHit: the landing is heard.
|
||||
self.projectile_event("minecraft:projectile_land", p.owner, atp);
|
||||
let source_kind = match p.kind {
|
||||
ProjectileKind::Arrow { .. } => DamageKind::Arrow,
|
||||
ProjectileKind::Arrow { .. } | ProjectileKind::Trident => DamageKind::Arrow,
|
||||
ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } => {
|
||||
DamageKind::Fireball
|
||||
}
|
||||
@@ -2084,6 +2371,40 @@ impl Game<'_> {
|
||||
from: Some([p.previous.x, p.previous.y, p.previous.z]),
|
||||
};
|
||||
match (&p.kind, hit) {
|
||||
// ThrownTrident.onHitEntity: 8 damage (Impaling on aquatic mobs),
|
||||
// dealt whether or not the hurt lands, then it bounces off.
|
||||
(ProjectileKind::Trident, Hit::Entity { who, .. }) => {
|
||||
let mut trident = p.clone();
|
||||
let victim = match who {
|
||||
Who::Mob(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| m.kind.id.clone()),
|
||||
Who::Player => Some("minecraft:player".to_owned()),
|
||||
};
|
||||
let amount = self.shot_damage(&mut trident, victim.as_deref());
|
||||
let hurt = match who {
|
||||
Who::Player => self.mob_hits_player(
|
||||
amount,
|
||||
[p.previous.x, p.previous.y, p.previous.z],
|
||||
"minecraft:trident",
|
||||
),
|
||||
Who::Mob(id) => self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id)
|
||||
.is_some_and(|m| m.damage(amount, source)),
|
||||
};
|
||||
let outcome = if hurt {
|
||||
EntityHit::Hurt
|
||||
} else {
|
||||
EntityHit::Refused
|
||||
};
|
||||
self.finish_arrow_hit(trident, outcome);
|
||||
}
|
||||
// AbstractArrow.onHitBlock plays its sound itself (ArrowEvent).
|
||||
(ProjectileKind::Trident, Hit::Block { .. }) => {}
|
||||
(ProjectileKind::Arrow { effect, .. }, Hit::Entity { who, .. }) => {
|
||||
let mut arrow = p.clone();
|
||||
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
|
||||
@@ -2305,14 +2626,30 @@ impl Game<'_> {
|
||||
if d2 < 16.0 && !self.creative {
|
||||
let scale = 1.0 - d2.sqrt() / 4.0;
|
||||
let t = (f64::from(*ticks) * scale + 0.5) as i32;
|
||||
if let Some(owner) = self.combat.projectile_owner {
|
||||
self.note_attacker("minecraft:indirect_magic", owner);
|
||||
}
|
||||
match *effect {
|
||||
"minecraft:instant_damage" => self.hurt_player_by(
|
||||
"minecraft:indirect_magic",
|
||||
6.0 * (1 << *amplifier) as f32 * scale as f32,
|
||||
),
|
||||
"minecraft:instant_damage" => {
|
||||
// indirectMagic(potion, owner), built where the
|
||||
// hurt happens: the thrower, or the potion
|
||||
// itself when it has none. A status effect
|
||||
// deals no damage, so it records no source.
|
||||
let owner = self
|
||||
.combat
|
||||
.projectile_owner
|
||||
.filter(|id| self.mob_display_name(*id).is_some());
|
||||
match owner {
|
||||
Some(owner) => {
|
||||
self.note_attacker("minecraft:indirect_magic", owner)
|
||||
}
|
||||
None => self.note_direct_entity(
|
||||
"minecraft:indirect_magic",
|
||||
Text::translate("entity.minecraft.splash_potion", &[]),
|
||||
),
|
||||
}
|
||||
self.hurt_player_by(
|
||||
"minecraft:indirect_magic",
|
||||
6.0 * (1 << *amplifier) as f32 * scale as f32,
|
||||
)
|
||||
}
|
||||
e if t > 20 => self.give_player_effect(e, t, *amplifier),
|
||||
_ => {}
|
||||
}
|
||||
@@ -2368,12 +2705,14 @@ impl Game<'_> {
|
||||
// ShulkerBullet.onHitEntity: 4 damage, then levitation for 10 s.
|
||||
(ProjectileKind::ShulkerBullet { .. }, Hit::Entity { who, .. }) => match who {
|
||||
Who::Player => {
|
||||
if !self.creative {
|
||||
self.mob_hits_player(
|
||||
// Only a hit that hurt (`wasHurt`) brings the levitation.
|
||||
if !self.creative
|
||||
&& self.mob_hits_player(
|
||||
4.0,
|
||||
[p.previous.x, p.previous.y, p.previous.z],
|
||||
"minecraft:mob_projectile",
|
||||
);
|
||||
)
|
||||
{
|
||||
self.give_player_effect("minecraft:levitation", 200, 0);
|
||||
}
|
||||
}
|
||||
@@ -2382,6 +2721,8 @@ impl Game<'_> {
|
||||
&& m.damage(4.0, source)
|
||||
{
|
||||
m.extra.levitation = 200;
|
||||
// Shulker.hurtServer: a shulker hit by a bullet.
|
||||
horizon_client::mobs::shulker::hit_by_bullet(m);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2463,21 +2804,22 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.map(|h| h.item.to_string())
|
||||
.unwrap_or_default();
|
||||
// Bucketable.bucketMobPickup: a water bucket scoops up a fish, a
|
||||
// tadpole or an axolotl, with the data its bucket carries.
|
||||
if item == "minecraft:water_bucket"
|
||||
&& self.mobs[index].species() == Species::Axolotl
|
||||
&& self.mobs[index].alive()
|
||||
&& let Some((bucket, sound)) = bucketable(self.mobs[index].species())
|
||||
{
|
||||
let mut filled = ItemStack::new("minecraft:axolotl_bucket", 1);
|
||||
let mut filled = ItemStack::new(bucket, 1);
|
||||
filled.extra = Some(Box::new(horizon_client::entity_nbt::bucket_components(
|
||||
&self.mobs[index],
|
||||
)));
|
||||
self.audio.play(
|
||||
"minecraft:item.bucket.fill_axolotl",
|
||||
Some(self.mobs[index].center()),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
self.audio
|
||||
.play(sound, Some(self.mobs[index].center()), 1.0, 1.0);
|
||||
self.trigger_item("minecraft:filled_bucket", &filled);
|
||||
// Leashable.dropLeash, then discard.
|
||||
let id = self.mobs[index].id;
|
||||
self.break_leash(id, true, false);
|
||||
self.mobs[index].removed = true;
|
||||
if self.creative {
|
||||
if let Some(rest) = self.give(filled) {
|
||||
@@ -2531,6 +2873,19 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
// Creeper.mobInteract: any item in #creeper_igniters, damaged when it
|
||||
// is damageable and used up otherwise.
|
||||
let creeper_igniter = self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:creeper_igniters")
|
||||
.is_some_and(|t| t.contains(&*item));
|
||||
let damageable = self
|
||||
.res
|
||||
.items()
|
||||
.get(&item)
|
||||
.is_some_and(|i| i.max_damage > 0);
|
||||
let m = &mut self.mobs[index];
|
||||
if !m.alive() {
|
||||
return Ok(false);
|
||||
@@ -2546,6 +2901,7 @@ impl Game<'_> {
|
||||
let mut handled = true;
|
||||
let mut drops: Vec<(String, u32)> = Vec::new();
|
||||
let is_food = m.kind.food.contains(&item);
|
||||
let baby_turtle = species == Species::Turtle && m.is_baby();
|
||||
match species {
|
||||
Species::Cow | Species::Mooshroom if item == "minecraft:bucket" && !m.is_baby() => {
|
||||
give = Some("minecraft:milk_bucket");
|
||||
@@ -2771,10 +3127,21 @@ impl Game<'_> {
|
||||
consume = true;
|
||||
sound = Some("entity.iron_golem.repair");
|
||||
}
|
||||
Species::Creeper if item == "minecraft:flint_and_steel" => {
|
||||
Species::Creeper if creeper_igniter => {
|
||||
m.extra.flag = true;
|
||||
sound = Some("item.flintandsteel.use");
|
||||
self.damage_held_tool();
|
||||
// FIRECHARGE_USE for a fire charge, else FLINTANDSTEEL_USE,
|
||||
// at the creeper's feet with a pitch from its own random.
|
||||
let event = if item == "minecraft:fire_charge" {
|
||||
"item.firecharge.use"
|
||||
} else {
|
||||
"item.flintandsteel.use"
|
||||
};
|
||||
eat = Some((event, 1.0, m.random.next_float() * 0.4 + 0.8));
|
||||
if damageable {
|
||||
self.damage_held_tool();
|
||||
} else {
|
||||
consume = true;
|
||||
}
|
||||
}
|
||||
// Parrot.mobInteract: parrot food may tame a wild one (one time in
|
||||
// ten); a parrot never takes it as Animal food (isFood is false).
|
||||
@@ -2824,6 +3191,14 @@ impl Game<'_> {
|
||||
if !handled {
|
||||
return Ok(false);
|
||||
}
|
||||
// Turtle.ageBoundaryReached: food that finishes a baby's growth.
|
||||
if baby_turtle && !self.mobs[index].is_baby() {
|
||||
self.handle_mob_events(vec![MobEvent::GiftLoot {
|
||||
mob: self.mobs[index].id,
|
||||
table: "minecraft:gameplay/turtle_grow",
|
||||
at: feet,
|
||||
}])?;
|
||||
}
|
||||
self.swing_arm();
|
||||
if let Some(s) = sound {
|
||||
let pitch =
|
||||
@@ -3183,6 +3558,30 @@ impl Game<'_> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// ThrownTridentRenderer.
|
||||
if matches!(a.kind, ProjectileKind::Trident) {
|
||||
let Some(model) = self.trident_model.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(r) = atlas.sprites.get("entity/trident/trident").copied() else {
|
||||
continue;
|
||||
};
|
||||
let m = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
|
||||
* glam::Mat4::from_rotation_z((a.pitch + 90.0).to_radians());
|
||||
let poses = horizon_client::mobs::anim::Poses::new(model);
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
m,
|
||||
&poses,
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// WindChargeRenderer.
|
||||
if matches!(
|
||||
a.kind,
|
||||
@@ -3257,6 +3656,26 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Bucketable.getBucketItemStack` and `getPickupSound` of a mob that a water
|
||||
/// bucket picks up.
|
||||
fn bucketable(species: Species) -> Option<(&'static str, &'static str)> {
|
||||
let fish = "minecraft:item.bucket.fill_fish";
|
||||
Some(match species {
|
||||
Species::Cod => ("minecraft:cod_bucket", fish),
|
||||
Species::Salmon => ("minecraft:salmon_bucket", fish),
|
||||
Species::Pufferfish => ("minecraft:pufferfish_bucket", fish),
|
||||
Species::TropicalFish => ("minecraft:tropical_fish_bucket", fish),
|
||||
Species::Tadpole => (
|
||||
"minecraft:tadpole_bucket",
|
||||
"minecraft:item.bucket.fill_tadpole",
|
||||
),
|
||||
Species::Axolotl => (
|
||||
"minecraft:axolotl_bucket",
|
||||
"minecraft:item.bucket.fill_axolotl",
|
||||
),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// Mob.getBaseExperienceReward
|
||||
/// The damage type id of a Horizon damage kind.
|
||||
pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) -> &'static str {
|
||||
@@ -3276,6 +3695,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
|
||||
K::InFire => "minecraft:in_fire",
|
||||
K::Lava => "minecraft:lava",
|
||||
K::Drown => "minecraft:drown",
|
||||
K::DryOut => "minecraft:dry_out",
|
||||
K::Magic => "minecraft:magic",
|
||||
K::Sting => "minecraft:sting",
|
||||
K::Cactus => "minecraft:cactus",
|
||||
@@ -3301,6 +3721,7 @@ fn to_loot(stack: &ItemStack) -> horizon_world::loot::LootStack {
|
||||
map: false,
|
||||
exploration: None,
|
||||
ominous_amplifier: None,
|
||||
stew_effects: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A generated loot stack as a Horizon item stack.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! finds the ammo for bows and crossbows.
|
||||
use super::{Game, shield::OFFHAND_SLOT, view_vector};
|
||||
use horizon_client::{
|
||||
enchant::EffectContext,
|
||||
items::ItemStack,
|
||||
mobs::Who,
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
@@ -152,6 +153,20 @@ impl Game<'_> {
|
||||
};
|
||||
let charged = std::mem::take(&mut bow.data_mut().charged);
|
||||
bow.tidy();
|
||||
let weapon = bow.clone();
|
||||
// AbstractArrow's constructor: `getPiercingCount` of the weapon.
|
||||
let pierce = self
|
||||
.res
|
||||
.stations()
|
||||
.enchantments
|
||||
.modify_value(
|
||||
Some(&weapon),
|
||||
"minecraft:projectile_piercing",
|
||||
&EffectContext::default(),
|
||||
&mut self.world.random,
|
||||
0.0,
|
||||
)
|
||||
.clamp(0.0, 127.0) as u8;
|
||||
let count = charged.len();
|
||||
// getShootingPower: a rocket among them slows every shot.
|
||||
let power = if charged.iter().any(|s| &*s.item == FIREWORK_ROCKET) {
|
||||
@@ -204,6 +219,7 @@ impl Game<'_> {
|
||||
if let Some(a) = arrow.arrow.as_mut() {
|
||||
a.set_item(ammo);
|
||||
a.set_owner(Some(Who::Player));
|
||||
a.pierce = pierce;
|
||||
}
|
||||
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
|
||||
self.creatures.projectiles.push(arrow);
|
||||
|
||||
@@ -654,6 +654,9 @@ impl Game<'_> {
|
||||
// DedicatedServer.initServer ends with saveEverything(false, true, true).
|
||||
self.save_game()?;
|
||||
self.log_saved_levels();
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.hub.entity_range_percent = props.entity_broadcast_range_percentage;
|
||||
}
|
||||
let mut state = ServerState {
|
||||
lists,
|
||||
console,
|
||||
|
||||
@@ -725,6 +725,11 @@ impl Game<'_> {
|
||||
match self.world.block_entities.get_mut(&p) {
|
||||
Some(BlockEntity::Jukebox(j)) => j.record = stack,
|
||||
Some(BlockEntity::Lectern(l)) => l.book = stack,
|
||||
Some(BlockEntity::Campfire(c)) => {
|
||||
if let Some(cell) = c.items.get_mut(slot) {
|
||||
*cell = stack;
|
||||
}
|
||||
}
|
||||
Some(e) => {
|
||||
if let Some(cell) = e.slots_mut().get_mut(slot) {
|
||||
*cell = stack;
|
||||
@@ -1126,14 +1131,18 @@ impl Game<'_> {
|
||||
let (p, v) = (b.position, b.velocity);
|
||||
out.push(line(&kind, [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
|
||||
}
|
||||
// A merged orb prints once per count, as the oracle does: whether orbs
|
||||
// merge depends on entity ids, which differ from vanilla's.
|
||||
for o in &self.orbs {
|
||||
let (p, v) = (o.position, o.velocity);
|
||||
out.push(line(
|
||||
"minecraft:experience_orb",
|
||||
[p.x, p.y, p.z],
|
||||
[v.x, v.y, v.z],
|
||||
"",
|
||||
));
|
||||
for _ in 0..o.count.max(1) {
|
||||
out.push(line(
|
||||
"minecraft:experience_orb",
|
||||
[p.x, p.y, p.z],
|
||||
[v.x, v.y, v.z],
|
||||
"",
|
||||
));
|
||||
}
|
||||
}
|
||||
for c in &self.carts {
|
||||
let (p, v) = (c.position, c.velocity);
|
||||
|
||||
@@ -982,7 +982,7 @@ impl Game<'_> {
|
||||
| "minecraft:lingering_potion" => {
|
||||
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
|
||||
shot.shoot(&mut own, dir, power, uncertainty);
|
||||
self.launch_thrown(one, at, shot.velocity);
|
||||
self.launch_thrown(one, at, shot.velocity, None);
|
||||
}
|
||||
"minecraft:firework_rocket" => {
|
||||
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
|
||||
|
||||
@@ -1560,6 +1560,7 @@ impl Game<'_> {
|
||||
y -= 1.0;
|
||||
}
|
||||
self.breath_clouds.push(Breath {
|
||||
id: 0,
|
||||
at: [x, y.floor() + 1.0, z],
|
||||
radius: 5.0,
|
||||
age: 0,
|
||||
@@ -1686,10 +1687,12 @@ impl Game<'_> {
|
||||
.play("minecraft:entity.ender_dragon.growl", Some(at), 2.5, pitch);
|
||||
}
|
||||
}
|
||||
// checkCrystals: heal 1 every 10 ticks from the nearest crystal within 32.
|
||||
// checkCrystals: heal 1 every 10 ticks from the nearest crystal, found
|
||||
// among those touching the dragon's box grown by 32.
|
||||
let d = self.dragon.as_mut().expect("dragon");
|
||||
if d.nearest_crystal.is_some() && d.ticks % 10 == 0 && d.health < MAX_HEALTH {
|
||||
d.health += 1.0;
|
||||
// LivingEntity.setHealth clamps to the maximum.
|
||||
d.health = (d.health + 1.0).min(MAX_HEALTH);
|
||||
}
|
||||
if d.random.next_int_bound(10).unwrap_or(1) == 0 {
|
||||
let p = d.position;
|
||||
@@ -2022,6 +2025,7 @@ impl Game<'_> {
|
||||
// assignDirectionalMovement: the direction times the acceleration power 0.1.
|
||||
let velocity = Vec3::new(v.x * 0.1, v.y * 0.1, v.z * 0.1);
|
||||
self.dragon_fireballs.push(DragonFireball {
|
||||
id: 0,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity,
|
||||
@@ -2068,6 +2072,7 @@ impl Game<'_> {
|
||||
});
|
||||
if hit_block || hit_player || b.age > 600 {
|
||||
self.breath_clouds.push(Breath {
|
||||
id: 0,
|
||||
at: [next.x, next.y, next.z],
|
||||
radius: 3.0,
|
||||
age: 0,
|
||||
@@ -2152,11 +2157,14 @@ impl Game<'_> {
|
||||
}
|
||||
/// Damage from the dragon, which is its causing entity
|
||||
/// (`DamageSources.mobAttack(dragon)`, `indirectMagic(cloud, dragon)`): the
|
||||
/// death message names it.
|
||||
/// death message names it, or the cloud once the dragon is gone.
|
||||
fn dragon_hurts_player(&mut self, damage_type: &str, amount: f32) {
|
||||
if let Some(id) = self.dragon.as_ref().map(|d| d.id) {
|
||||
let name = Text::translate("entity.minecraft.ender_dragon", &[]);
|
||||
self.note_named_attacker(damage_type, id, name);
|
||||
} else if damage_type == "minecraft:indirect_magic" {
|
||||
let name = Text::translate("entity.minecraft.area_effect_cloud", &[]);
|
||||
self.note_direct_entity(damage_type, name);
|
||||
}
|
||||
self.hurt_player_by(damage_type, amount);
|
||||
}
|
||||
@@ -2197,15 +2205,58 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
pub(super) struct DragonFireball {
|
||||
position: Vec3,
|
||||
previous: Vec3,
|
||||
velocity: Vec3,
|
||||
/// Its entity id once a server's players track it (0 until then).
|
||||
pub(super) id: u32,
|
||||
pub(super) position: Vec3,
|
||||
pub(super) previous: Vec3,
|
||||
pub(super) velocity: Vec3,
|
||||
age: i32,
|
||||
}
|
||||
impl DragonFireball {
|
||||
/// One a server sent (`handleAddEntity`).
|
||||
pub(super) fn remote(id: u32, at: Vec3, velocity: Vec3) -> Self {
|
||||
Self {
|
||||
id,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity,
|
||||
age: 0,
|
||||
}
|
||||
}
|
||||
/// `AbstractHurtingProjectile.tick` on a server's client: `applyInertia`
|
||||
/// first (power 0.1, inertia 0.95 or 0.8 in water), then the move; only
|
||||
/// the server decides what it hits.
|
||||
pub(super) fn remote_step(&mut self, water: bool) {
|
||||
self.previous = self.position;
|
||||
self.age += 1;
|
||||
let v = self.velocity;
|
||||
// Vec3.normalize: a vector shorter than 1e-5 stays zero.
|
||||
let l = (v.x * v.x + v.y * v.y + v.z * v.z).sqrt();
|
||||
let n = if l < 1.0e-5 {
|
||||
Vec3::new(0.0, 0.0, 0.0)
|
||||
} else {
|
||||
Vec3::new(v.x / l, v.y / l, v.z / l)
|
||||
};
|
||||
let inertia = f64::from(if water { 0.8f32 } else { 0.95f32 });
|
||||
let v = Vec3::new(
|
||||
(v.x + n.x * 0.1) * inertia,
|
||||
(v.y + n.y * 0.1) * inertia,
|
||||
(v.z + n.z * 0.1) * inertia,
|
||||
);
|
||||
self.velocity = v;
|
||||
self.position = Vec3::new(
|
||||
self.position.x + v.x,
|
||||
self.position.y + v.y,
|
||||
self.position.z + v.z,
|
||||
);
|
||||
}
|
||||
}
|
||||
pub(super) struct Breath {
|
||||
at: [f64; 3],
|
||||
radius: f32,
|
||||
age: i32,
|
||||
/// Its entity id once a server's players track it (0 until then).
|
||||
pub(super) id: u32,
|
||||
pub(super) at: [f64; 3],
|
||||
pub(super) radius: f32,
|
||||
pub(super) age: i32,
|
||||
duration: i32,
|
||||
from_dragon: bool,
|
||||
}
|
||||
@@ -2550,7 +2601,9 @@ impl Game<'_> {
|
||||
}
|
||||
pub(super) fn fight_ready(&mut self) -> bool {
|
||||
self.load_fight();
|
||||
self.fight.loaded
|
||||
// spawnExitPortal runs from scanState and the dragon's death, both
|
||||
// only with players in the fight.
|
||||
self.fight.loaded && self.fight.players
|
||||
}
|
||||
pub(super) fn exit_portal(&self) -> Option<[i32; 3]> {
|
||||
self.fight.exit_portal
|
||||
@@ -3127,16 +3180,24 @@ impl Game<'_> {
|
||||
let _ = self.activate_exit_portal();
|
||||
self.fight.dirty = true;
|
||||
}
|
||||
/// `EnderDragon.checkCrystals`' search: the nearest crystal within 32
|
||||
/// blocks on each axis.
|
||||
/// `EnderDragon.checkCrystals`' search: the nearest crystal whose box
|
||||
/// meets the dragon's box grown by 32.
|
||||
fn nearest_crystal_to(&self, p: Vec3) -> Option<usize> {
|
||||
// The dragon's 16 by 8 box.
|
||||
let reach = Aabb::new(
|
||||
Vec3::new(p.x - 8.0, p.y, p.z - 8.0),
|
||||
Vec3::new(p.x + 8.0, p.y + 8.0, p.z + 8.0),
|
||||
)
|
||||
.inflate(Vec3::new(32.0, 32.0, 32.0));
|
||||
self.crystals
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| {
|
||||
(c.position.x - p.x).abs() <= 32.0
|
||||
&& (c.position.y - p.y).abs() <= 32.0
|
||||
&& (c.position.z - p.z).abs() <= 32.0
|
||||
// An end crystal's 2 by 2 box.
|
||||
reach.intersects(Aabb::new(
|
||||
Vec3::new(c.position.x - 1.0, c.position.y, c.position.z - 1.0),
|
||||
Vec3::new(c.position.x + 1.0, c.position.y + 2.0, c.position.z + 1.0),
|
||||
))
|
||||
})
|
||||
.min_by(|a, b| {
|
||||
let da = (a.1.position.x - p.x).powi(2)
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
//! unbreaking (`minecraft:item_damage`), sharpness/smite/bane and power
|
||||
//! (`minecraft:damage`), knockback, fire aspect, infinity (`minecraft:ammo_use`),
|
||||
//! thorns (`minecraft:post_attack`), mending (`minecraft:repair_with_xp`) and
|
||||
//! the armor attribute effects (respiration, depth strider, ...).
|
||||
//! the armor attribute effects (respiration, depth strider, ...) and Soul
|
||||
//! Speed's `location_changed` and `tick` effects.
|
||||
use super::Game;
|
||||
use horizon_client::{enchant::EffectContext, items::ItemStack};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_client::{
|
||||
enchant::{EffectContext, Enchantments, level_value},
|
||||
items::ItemStack,
|
||||
};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
|
||||
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -255,23 +259,26 @@ impl Game<'_> {
|
||||
let _ = self.wind_burst(feet, radius, knockback, false, false);
|
||||
}
|
||||
}
|
||||
/// Thorns on worn armor when a mob at `from` hits the player.
|
||||
pub(super) fn thorns(&mut self, from: [f64; 3]) {
|
||||
let attacker = self
|
||||
.mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| m.alive())
|
||||
.min_by(|a, b| {
|
||||
let d = |m: &horizon_client::mobs::Mob| {
|
||||
(m.position.x - from[0]).powi(2)
|
||||
+ (m.position.y - from[1]).powi(2)
|
||||
+ (m.position.z - from[2]).powi(2)
|
||||
};
|
||||
d(a.1).total_cmp(&d(b.1))
|
||||
})
|
||||
.map(|(i, _)| i);
|
||||
let Some(attacker) = attacker else { return };
|
||||
/// Thorns on worn armor when the mob `attacker` hits the player: the
|
||||
/// `affected: attacker` effect of `Enchantment.doPostAttack` hurts the
|
||||
/// entity that caused the hit.
|
||||
pub(super) fn thorns(&mut self, attacker: u32) {
|
||||
let Some(index) = self.mobs.iter().position(|m| m.id == attacker && m.alive()) else {
|
||||
return;
|
||||
};
|
||||
for damage in self.thorns_damage() {
|
||||
let m = &mut self.mobs[index];
|
||||
m.hurt(damage, None, &mut self.world.random);
|
||||
let center = [m.position.x, m.position.y, m.position.z];
|
||||
self.audio
|
||||
.play("minecraft:enchant.thorns.hit", Some(center), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
/// The victim's side of thorns: for each worn piece whose chance comes up,
|
||||
/// the damage to deal the attacker (`DamageEntity` min 1, max 5) and the
|
||||
/// piece's wear (`ChangeItemDamage` 2).
|
||||
pub(super) fn thorns_damage(&mut self) -> Vec<f32> {
|
||||
let mut hits = Vec::new();
|
||||
for slot in 0..4 {
|
||||
let Some(stack) = self.armor[slot].clone() else {
|
||||
continue;
|
||||
@@ -299,15 +306,7 @@ impl Game<'_> {
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
// DamageEntity(min 1, max 5) and ChangeItemDamage(2).
|
||||
let damage = 1.0 + self.world.random.next_float() * 4.0;
|
||||
let center = {
|
||||
let m = &mut self.mobs[attacker];
|
||||
m.hurt(damage, None, &mut self.world.random);
|
||||
[m.position.x, m.position.y, m.position.z]
|
||||
};
|
||||
self.audio
|
||||
.play("minecraft:enchant.thorns.hit", Some(center), 1.0, 1.0);
|
||||
hits.push(1.0 + self.world.random.next_float() * 4.0);
|
||||
if let Some(s) = self.armor[slot].as_mut() {
|
||||
s.damage += 2;
|
||||
if self
|
||||
@@ -320,6 +319,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
hits
|
||||
}
|
||||
/// `ExperienceOrb.repairPlayerItems`: returns the experience left over.
|
||||
pub(super) fn mending(&mut self, amount: u32) -> u32 {
|
||||
@@ -388,7 +388,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// Bow enchantments at release: (extra base damage, infinity keeps the arrow).
|
||||
pub(super) fn bow_enchantments(&mut self) -> (f64, bool) {
|
||||
/// `ammo` is the item being fired: `Enchantment.itemContext` hands it to the
|
||||
/// `ammo_use` conditions as the tool, so Infinity only frees plain arrows.
|
||||
pub(super) fn bow_enchantments(&mut self, ammo: &str) -> (f64, bool) {
|
||||
let Some(bow) = self.inventory[self.selected].clone() else {
|
||||
return (0.0, false);
|
||||
};
|
||||
@@ -407,7 +409,7 @@ impl Game<'_> {
|
||||
0.0,
|
||||
);
|
||||
let ammo_ctx = EffectContext {
|
||||
tool: Some("minecraft:arrow"),
|
||||
tool: Some(ammo),
|
||||
items: Some(self.res.items()),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -460,6 +462,134 @@ impl Game<'_> {
|
||||
}
|
||||
ignite
|
||||
}
|
||||
/// Soul Speed (`data/minecraft/enchantment/soul_speed.json`), the parts of
|
||||
/// `Enchantment.runLocationChangedEffects` and `tick` that file uses. The
|
||||
/// `location_changed` effects run when the player enters a new block, lands,
|
||||
/// or its boots change:
|
||||
/// - the speed and movement efficiency modifiers turn on over a soul speed
|
||||
/// block (not flying, not riding) and stay on while airborne, then off
|
||||
/// (`enchantment_active_check`);
|
||||
/// - the boots lose 1 durability with a 4% chance when on such a block.
|
||||
///
|
||||
/// Every fifth tick the player walking on one also gets a soul particle
|
||||
/// (returned as its position and velocity, for the caller to show or send
|
||||
/// to the players around) and sometimes a sound. Runs for the window's own
|
||||
/// player and for each guest on the server (`guest_server_tick`); a remote
|
||||
/// client leaves it to its server.
|
||||
pub(super) fn tick_soul_speed(&mut self) -> Option<([f64; 3], [f64; 3])> {
|
||||
const ID: &str = "minecraft:soul_speed";
|
||||
let player = self.player.as_mut()?;
|
||||
if player.client_side {
|
||||
return None;
|
||||
}
|
||||
let level = self.armor[3]
|
||||
.as_ref()
|
||||
.map_or(0, |s| s.enchantment_level(ID));
|
||||
let position = player.position;
|
||||
let (on_ground, flying) = (player.ground.on_ground, player.flying);
|
||||
let moved = position.subtract(player.old_position);
|
||||
let (tick, width) = (player.health.tick_count, player.dimensions.width);
|
||||
let fx = &mut player.location_effects;
|
||||
let on_soul = fx.on_soul_speed_block;
|
||||
let block = BlockPos::containing(position);
|
||||
let changed_block = fx.block.replace(block) != Some(block);
|
||||
let landed = std::mem::take(&mut fx.landed);
|
||||
// Changed boots: `stopLocationBasedEffects`, then the effects run again.
|
||||
let equipped = std::mem::replace(&mut fx.boots_level, level) != level;
|
||||
let was_active = fx.applied_level;
|
||||
let mut active = was_active > 0 && !equipped;
|
||||
let mut wear = false;
|
||||
if level > 0 && (changed_block || landed || equipped) {
|
||||
let riding = self.riding.is_some();
|
||||
let wanted = !riding && !flying && (on_soul || (active && !on_ground));
|
||||
active = wanted;
|
||||
// `random_chance` is the first term of the wear's `all_of`: it
|
||||
// draws from the level random before the block is looked at.
|
||||
let roll = self.world.random.next_float() < 0.04;
|
||||
wear = roll && on_ground && on_soul;
|
||||
}
|
||||
fx.applied_level = if active { level } else { 0 };
|
||||
// `Entity.getKnownMovement` horizontal speed of at least 1.0E-5.
|
||||
let walking = moved.x * moved.x + moved.z * moved.z >= 1.0e-10;
|
||||
let tick_effects = level > 0 && !flying && on_ground && walking && on_soul && tick % 5 == 0;
|
||||
// The attribute effects were applied to the player before: swap them.
|
||||
if was_active > 0 && (!active || equipped) {
|
||||
for (attribute, id, _, op) in
|
||||
soul_speed_modifiers(&self.res.stations().enchantments, was_active)
|
||||
{
|
||||
let _ = player.set_modifier(&attribute, &id, 0.0, &op, false);
|
||||
}
|
||||
}
|
||||
if active && (was_active == 0 || equipped) {
|
||||
for (attribute, id, amount, op) in
|
||||
soul_speed_modifiers(&self.res.stations().enchantments, level)
|
||||
{
|
||||
let _ = player.set_modifier(&attribute, &id, amount, &op, true);
|
||||
}
|
||||
}
|
||||
if wear && !self.creative {
|
||||
self.wear_boots(1);
|
||||
}
|
||||
if level == 0 {
|
||||
return None;
|
||||
}
|
||||
let r = &mut self.world.random;
|
||||
// `spawn_particles`: in the bounding box, 0.1 above the feet, thrown
|
||||
// back against the movement (-0.2) and up (0.1).
|
||||
let particle = tick_effects.then(|| {
|
||||
let x = position.x + (r.next_double() - 0.5) * f64::from(width);
|
||||
let z = position.z + (r.next_double() - 0.5) * f64::from(width);
|
||||
(
|
||||
[x, position.y + 0.1, z],
|
||||
[moved.x * -0.2, 0.1, moved.z * -0.2],
|
||||
)
|
||||
});
|
||||
// `play_sound`: its `random_chance` (35%) comes first in its `all_of`,
|
||||
// so the level random is drawn every tick the boots are worn; volume
|
||||
// 0.6, pitch 0.6 to 1.0.
|
||||
if r.next_float() < 0.35 && tick_effects {
|
||||
let pitch = r.next_float() * 0.4 + 0.6;
|
||||
self.audio.play(
|
||||
"minecraft:particle.soul_escape",
|
||||
Some([position.x, position.y, position.z]),
|
||||
0.6,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
particle
|
||||
}
|
||||
/// `ItemStack.hurtAndBreak` on the worn boots (`change_item_damage`):
|
||||
/// Unbreaking applies, and they break when worn out.
|
||||
fn wear_boots(&mut self, amount: u32) {
|
||||
let Some(stack) = self.armor[3].clone() else {
|
||||
return;
|
||||
};
|
||||
let loss = self.durability_loss(&stack, amount);
|
||||
let max = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.map_or(0, |info| info.max_damage);
|
||||
if max == 0 {
|
||||
return;
|
||||
}
|
||||
let Some(boots) = self.armor[3].as_mut() else {
|
||||
return;
|
||||
};
|
||||
boots.damage += loss;
|
||||
if boots.damage >= max {
|
||||
self.armor[3] = None;
|
||||
if let Some(eye) = self.eye_position() {
|
||||
self.audio.play(
|
||||
"minecraft:entity.item.break",
|
||||
Some(eye),
|
||||
0.8,
|
||||
0.8 + self.world.random.next_float() * 0.4,
|
||||
);
|
||||
}
|
||||
self.apply_equipment();
|
||||
}
|
||||
}
|
||||
/// Enchantment attribute modifiers of worn armor (respiration, depth strider...).
|
||||
/// Each piece's modifier id ends with its slot (`EnchantmentAttributeEffect.idForSlot`),
|
||||
/// so the same enchantment on two pieces stacks instead of replacing itself.
|
||||
@@ -483,3 +613,28 @@ impl Game<'_> {
|
||||
self.potions.enchant_modifiers = wanted;
|
||||
}
|
||||
}
|
||||
|
||||
/// Soul Speed's `attribute` effects at `level` (attribute, id, amount, operation),
|
||||
/// the id taking the boots slot like `EnchantmentAttributeEffect.idForSlot`.
|
||||
fn soul_speed_modifiers(
|
||||
enchantments: &Enchantments,
|
||||
level: u32,
|
||||
) -> Vec<(String, String, f64, String)> {
|
||||
let Some(e) = enchantments.get("minecraft:soul_speed") else {
|
||||
return Vec::new();
|
||||
};
|
||||
e.effects["minecraft:location_changed"][0]["effect"]["effects"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|a| a["type"] == "minecraft:attribute")
|
||||
.map(|a| {
|
||||
(
|
||||
a["attribute"].as_str().unwrap_or("").to_owned(),
|
||||
format!("{}/feet", a["id"].as_str().unwrap_or("")),
|
||||
f64::from(level_value(&a["amount"], level)),
|
||||
a["operation"].as_str().unwrap_or("add_value").to_owned(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -296,12 +296,14 @@ impl Game<'_> {
|
||||
if !creative
|
||||
&& !spectator
|
||||
&& hurtable
|
||||
&& let Ok(o) = p.hurt_blocking(
|
||||
&& let Ok(o) = p.hurt_caused_by(
|
||||
&self.motion.health,
|
||||
self.world.health_rules,
|
||||
id,
|
||||
amount,
|
||||
blocking,
|
||||
// The exploding mob is the causing entity.
|
||||
exploding.is_some(),
|
||||
)
|
||||
{
|
||||
outcome = o;
|
||||
@@ -314,9 +316,6 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
if outcome.hurt && !creative {
|
||||
self.hurt_armor(amount);
|
||||
}
|
||||
if raised {
|
||||
self.after_block(outcome, amount, None, false);
|
||||
}
|
||||
@@ -391,9 +390,11 @@ impl Game<'_> {
|
||||
[feet[0] - w, feet[1], feet[2] - w],
|
||||
[feet[0] + w, feet[1] + h, feet[2] + w],
|
||||
);
|
||||
// The exploding mob is the damage source's entity, so victims
|
||||
// it kills drop its charged-creeper loot (`Creeper.killedEntity`).
|
||||
let source = horizon_client::mobs::Damage {
|
||||
kind: horizon_client::mobs::DamageKind::Explosion,
|
||||
attacker: None,
|
||||
attacker: exploding.map(horizon_client::mobs::Who::Mob),
|
||||
from: Some(center),
|
||||
};
|
||||
if hurtable && !m.damage(damage(dist, exposure), source) {
|
||||
@@ -571,16 +572,16 @@ impl Game<'_> {
|
||||
let pitch = (1.0 + (client_float() - client_float()) * 0.2) * 0.7;
|
||||
self.audio
|
||||
.play("minecraft:entity.generic.explode", Some(center), 4.0, pitch);
|
||||
let block_count = if interaction == BlockInteraction::Keep {
|
||||
0
|
||||
} else {
|
||||
targets.len()
|
||||
};
|
||||
// ServerLevel.explode sends toBlow.size() whatever the interaction, and
|
||||
// `ServerExplosion.isSmall` also covers an explosion that keeps blocks.
|
||||
let block_count = targets.len();
|
||||
let small = radius < 2.0 || interaction == BlockInteraction::Keep;
|
||||
let (world, facts) = (&self.world, self.res.facts());
|
||||
self.particles.explosion(center, radius, block_count, &|b| {
|
||||
facts.is_air(world.block(b[0], b[1], b[2]))
|
||||
});
|
||||
self.lan_explode(center, radius, block_count);
|
||||
self.particles
|
||||
.explosion(center, small, block_count, radius, &|b| {
|
||||
facts.is_air(world.block(b[0], b[1], b[2]))
|
||||
});
|
||||
self.lan_explode(center, small, radius, block_count);
|
||||
Ok(())
|
||||
}
|
||||
/// `BlockBehaviour.onExplosionHit` (TNT: `wasExploded` primes a short fuse).
|
||||
@@ -597,6 +598,11 @@ impl Game<'_> {
|
||||
}
|
||||
let name = self.res.facts().get(state).name.clone();
|
||||
let is_tnt = &*name == "minecraft:tnt";
|
||||
// CreakingHeartBlock.onExplosionHit: its creaking dies (no
|
||||
// experience, as Horizon does not track a TNT's owner).
|
||||
if &*name == "minecraft:creaking_heart" {
|
||||
self.creaking_heart_broken(pos, false);
|
||||
}
|
||||
// Block.onExplosionHit: the drops copy from the block entity, which
|
||||
// then spills its contents as the block turns to air.
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
@@ -620,6 +626,7 @@ impl Game<'_> {
|
||||
&mut self.world.random,
|
||||
Some((&mut self.rand_sequences, self.level.seed)),
|
||||
decay,
|
||||
false,
|
||||
);
|
||||
for mut stack in drops {
|
||||
if &*stack.item == "minecraft:air" || stack.count == 0 {
|
||||
|
||||
@@ -64,6 +64,25 @@ impl Game<'_> {
|
||||
pos: BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
data: 0,
|
||||
});
|
||||
} else if self
|
||||
.res
|
||||
.facts()
|
||||
.get(block.state)
|
||||
.name
|
||||
.starts_with("minecraft:suspicious_")
|
||||
{
|
||||
// BrushableBlock.onBrokenAfterFall.
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.level_events.push(LevelEvent::LevelEvent {
|
||||
id: 2001,
|
||||
pos: at,
|
||||
data: block.state.0 as i32,
|
||||
});
|
||||
self.level_events.push(LevelEvent::GameEvent {
|
||||
pos: at,
|
||||
event: "minecraft:block_destroy",
|
||||
state: None,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -108,16 +127,10 @@ impl Game<'_> {
|
||||
} else {
|
||||
amount
|
||||
};
|
||||
let hurt = if let Some(p) = self.player.as_mut()
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& let Some(id) = self.motion.health.damage.id(kind)
|
||||
{
|
||||
p.hurt(&self.motion.health, self.world.health_rules, id, amount)
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if hurt {
|
||||
self.hurt_armor(amount);
|
||||
let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,26 +71,49 @@ impl Game<'_> {
|
||||
let b = horizon_core::geometry::Aabb::new(lo, hi);
|
||||
// Every player in the box, the server's players too.
|
||||
let pos = f.pos;
|
||||
// `EvokerFangs.dealDamageTo`: indirectMagic(fangs, owner) while the
|
||||
// owner lives, plain magic (nobody to name) once it is gone.
|
||||
let owner = self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.id == f.owner && m.alive())
|
||||
.then_some(f.owner);
|
||||
let bite = move |g: &mut Self| {
|
||||
if let Some(p) = g.player_view()
|
||||
&& p.alive
|
||||
&& p.bounds.intersects(b)
|
||||
{
|
||||
g.mob_hits_player_kind(6.0, pos, "minecraft:indirect_magic");
|
||||
let kind = match owner {
|
||||
Some(_) => "minecraft:indirect_magic",
|
||||
None => "minecraft:magic",
|
||||
};
|
||||
g.mob_hits_player_caused(6.0, pos, kind, owner);
|
||||
}
|
||||
};
|
||||
bite(self);
|
||||
self.each_guest_hit(bite);
|
||||
// `AbstractIllager.considersEntityAsAlly`: with no teams, the illager
|
||||
// tag is allied; `Evoker.considersEntityAsAlly` adds a vex whose
|
||||
// owner is allied (itself or another illager). Teams are not modelled.
|
||||
let illager = |s: Species| {
|
||||
matches!(
|
||||
s,
|
||||
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator
|
||||
)
|
||||
};
|
||||
let illagers: Vec<u32> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| illager(m.species()))
|
||||
.map(|m| m.id)
|
||||
.collect();
|
||||
// A missing owner hurts everyone (`currentOwner == null`).
|
||||
let owner_known = illagers.contains(&f.owner);
|
||||
for m in &mut self.mobs {
|
||||
let ally = matches!(
|
||||
m.species(),
|
||||
Species::Evoker
|
||||
| Species::Illusioner
|
||||
| Species::Vex
|
||||
| Species::Pillager
|
||||
| Species::Vindicator
|
||||
| Species::Witch
|
||||
);
|
||||
let ally = owner_known
|
||||
&& (illager(m.species())
|
||||
|| m.species() == Species::Vex
|
||||
&& illagers.contains(&(m.extra.counter2 as u32)));
|
||||
if m.id != f.owner && !ally && m.alive() && m.bounds().intersects(b) {
|
||||
m.damage(
|
||||
6.0,
|
||||
|
||||
@@ -372,17 +372,11 @@ impl Game<'_> {
|
||||
if self.creative || self.spectator() {
|
||||
return;
|
||||
}
|
||||
let hurt = if let Some(p) = self.player.as_mut()
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& !p.health.dead_or_dying()
|
||||
&& let Some(id) = self.motion.health.damage.id("minecraft:fireworks")
|
||||
{
|
||||
p.hurt(&self.motion.health, self.world.health_rules, id, amount)
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if hurt {
|
||||
self.hurt_armor(amount);
|
||||
let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount);
|
||||
}
|
||||
}
|
||||
/// `FireworkEntityRenderer.submit`: the item (GROUND) facing the camera,
|
||||
|
||||
@@ -154,37 +154,54 @@ impl Game<'_> {
|
||||
let mut damage = 0;
|
||||
if let State::Hooked(id) = hook.state {
|
||||
// pullEntity: 0.1 of the way to the owner.
|
||||
let d = Vec3::new(
|
||||
owner.x - hook.position.x,
|
||||
owner.y - hook.position.y,
|
||||
owner.z - hook.position.z,
|
||||
);
|
||||
let pulled = |v: Vec3| Vec3::new(v.x + d.x * 0.1, v.y + d.y * 0.1, v.z + d.z * 0.1);
|
||||
let mut hooked_item = false;
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
let d = Vec3::new(
|
||||
owner.x - hook.position.x,
|
||||
owner.y - hook.position.y,
|
||||
owner.z - hook.position.z,
|
||||
);
|
||||
m.velocity = Vec3::new(
|
||||
m.velocity.x + d.x * 0.1,
|
||||
m.velocity.y + d.y * 0.1,
|
||||
m.velocity.z + d.z * 0.1,
|
||||
);
|
||||
m.velocity = pulled(m.velocity);
|
||||
} else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) {
|
||||
item.velocity = pulled(item.velocity);
|
||||
hooked_item = true;
|
||||
}
|
||||
damage = 5;
|
||||
// `hookedIn instanceof ItemEntity ? 3 : 5`.
|
||||
damage = if hooked_item { 3 } else { 5 };
|
||||
} else if hook.nibble > 0 {
|
||||
let biome = self.biome_name_at(hook.position);
|
||||
let Some(loot) = horizon_client::structure_blocks::loot_data(&self.options.resources)
|
||||
else {
|
||||
return damage;
|
||||
};
|
||||
// `withLuck(this.luck + owner.getLuck())`: the rod's Luck of the Sea
|
||||
// plus the owner's luck attribute (the Luck and Bad Luck effects).
|
||||
let owner_luck = self
|
||||
.player
|
||||
.as_mut()
|
||||
.and_then(|p| p.attribute("minecraft:luck").ok())
|
||||
.unwrap_or(0.0) as f32;
|
||||
let mut random = self.world.random.clone();
|
||||
let items = loot.roll(
|
||||
"minecraft:gameplay/fishing",
|
||||
&mut random,
|
||||
hook.luck as f32,
|
||||
hook.luck as f32 + owner_luck,
|
||||
biome.as_deref(),
|
||||
Some(hook.open_water),
|
||||
);
|
||||
self.world.random = random;
|
||||
// FishingHook.retrieve: fish_caught for a catch.
|
||||
self.stats.custom("fish_caught", 1);
|
||||
for stack in items {
|
||||
// FishingHook.retrieve: fish_caught for each catch in #minecraft:fishes.
|
||||
if self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:fishes")
|
||||
.is_some_and(|t| t.contains(stack.item.as_str()))
|
||||
{
|
||||
self.stats.custom("fish_caught", 1);
|
||||
}
|
||||
let mut item = ItemStack {
|
||||
item: Arc::from(stack.item.as_str()),
|
||||
count: stack.count.max(1) as u32,
|
||||
@@ -231,7 +248,7 @@ impl Game<'_> {
|
||||
}
|
||||
damage
|
||||
}
|
||||
fn biome_name_at(&self, p: Vec3) -> Option<String> {
|
||||
pub(super) fn biome_name_at(&self, p: Vec3) -> Option<String> {
|
||||
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
let c = self.world.chunks.get(&(x >> 4, z >> 4))?;
|
||||
Some(
|
||||
@@ -457,35 +474,47 @@ impl Game<'_> {
|
||||
self.hook = Some(h);
|
||||
return;
|
||||
}
|
||||
// checkCollision: hook the first living mob it touches.
|
||||
// checkCollision: hook the first living mob it touches, or (via
|
||||
// `canHitEntity`) a dropped item.
|
||||
let bb = bounds(h.position);
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
if let Some(m) = self.mobs.iter().find(|m| {
|
||||
let mb = m.bounds();
|
||||
let (a, z) = (mb.min_position(), mb.max_position());
|
||||
m.alive()
|
||||
&& a.x < hi.x
|
||||
&& lo.x < z.x
|
||||
&& a.y < hi.y
|
||||
&& lo.y < z.y
|
||||
&& a.z < hi.z
|
||||
&& lo.z < z.z
|
||||
}) {
|
||||
h.state = State::Hooked(m.id);
|
||||
let touches = |b: Aabb| {
|
||||
let (a, z) = (b.min_position(), b.max_position());
|
||||
a.x < hi.x && lo.x < z.x && a.y < hi.y && lo.y < z.y && a.z < hi.z && lo.z < z.z
|
||||
};
|
||||
let hit = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.alive() && touches(m.bounds()))
|
||||
.map(|m| m.id)
|
||||
.or_else(|| {
|
||||
self.dropped
|
||||
.iter()
|
||||
.find(|i| touches(i.bounds()))
|
||||
.map(|i| i.id)
|
||||
});
|
||||
if let Some(id) = hit {
|
||||
h.state = State::Hooked(id);
|
||||
h.velocity = Vec3::ZERO;
|
||||
self.hook = Some(h);
|
||||
return;
|
||||
}
|
||||
}
|
||||
State::Hooked(id) => {
|
||||
match self.mobs.iter().find(|m| m.id == id && m.alive()) {
|
||||
Some(m) => {
|
||||
h.position = Vec3::new(
|
||||
m.position.x,
|
||||
m.position.y + f64::from(m.height()) * 0.8,
|
||||
m.position.z,
|
||||
)
|
||||
}
|
||||
// updateHookedEntity: ride the hooked mob or item at 0.8 of its height.
|
||||
let hooked = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && m.alive())
|
||||
.map(|m| (m.position, f64::from(m.height())))
|
||||
.or_else(|| {
|
||||
self.dropped
|
||||
.iter()
|
||||
.find(|i| i.id == id)
|
||||
.map(|i| (i.position, horizon_client::entities::ItemEntity::WIDTH))
|
||||
});
|
||||
match hooked {
|
||||
Some((at, height)) => h.position = Vec3::new(at.x, at.y + height * 0.8, at.z),
|
||||
None => h.state = State::Flying,
|
||||
}
|
||||
self.hook = Some(h);
|
||||
|
||||
@@ -20,7 +20,8 @@ use horizon_core::{
|
||||
};
|
||||
use horizon_world::{
|
||||
block_collision::BlockCollisionRegistry, collision_context::CollisionContext,
|
||||
heightmap::HeightmapType, worldgen::terrain::TerrainBlockStates,
|
||||
heightmap::HeightmapType, level::flags::UPDATE_CLIENTS, registry::BlockStateId,
|
||||
worldgen::terrain::TerrainBlockStates,
|
||||
};
|
||||
|
||||
/// The loaded world as the spawn searches query it.
|
||||
@@ -121,33 +122,102 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(Some([ox * 16 + 8, height, oz * 16 + 8]))
|
||||
}
|
||||
/// MinecraftServer.setInitialSpawn's bonus chest: BonusChestFeature.place
|
||||
/// in the spawn chunk, a chest with the spawn bonus loot and a torch on
|
||||
/// each side that can hold one, on the first free column of a shuffled
|
||||
/// walk over the chunk. Skipped while the chunk is not loaded.
|
||||
pub(super) fn place_bonus_chest(&mut self, origin: [i32; 3]) -> Result<()> {
|
||||
use horizon_client::containers::BlockEntity;
|
||||
let (cx, cz) = (origin[0] >> 4, origin[2] >> 4);
|
||||
let registry = &self.pack.assets.registry;
|
||||
let (Some(chest), Some(torch)) = (
|
||||
registry.default_state("minecraft:chest"),
|
||||
registry.default_state("minecraft:torch"),
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
if !self.streaming.is_lit(cx, cz) {
|
||||
return Ok(());
|
||||
}
|
||||
// `level.getRandom()`. Util.toShuffledList of the chunk's x and z.
|
||||
let random = &mut self.world.random;
|
||||
let mut shuffled = |min: i32| -> [i32; 16] {
|
||||
let mut values: [i32; 16] = std::array::from_fn(|i| min + i as i32);
|
||||
for end in (2..=16usize).rev() {
|
||||
let other = random.next_int_bound(end as i32).unwrap_or(0) as usize;
|
||||
values.swap(end - 1, other);
|
||||
}
|
||||
values
|
||||
};
|
||||
let (xs, zs) = (shuffled(cx * 16), shuffled(cz * 16));
|
||||
for x in xs {
|
||||
for z in zs {
|
||||
let y = self
|
||||
.spawn_columns()
|
||||
.height(HeightmapType::MotionBlockingNoLeaves, x, z);
|
||||
if self.world.block(x, y, z) != self.world.air && !self.collision_empty([x, y, z]) {
|
||||
continue;
|
||||
}
|
||||
self.set_block_clients([x, y, z], chest)?;
|
||||
let seed = self.world.random.next_long();
|
||||
self.world
|
||||
.block_entities
|
||||
.insert((x, y, z), BlockEntity::Chest(Vec::new()));
|
||||
self.load_block_entity_nbt(
|
||||
[x, y, z],
|
||||
&format!(
|
||||
"{{LootTable:\"minecraft:chests/spawn_bonus_chest\",LootTableSeed:{seed}L}}"
|
||||
),
|
||||
);
|
||||
for [dx, dz] in [[0, -1], [1, 0], [0, 1], [-1, 0]] {
|
||||
let at = [x + dx, y, z + dz];
|
||||
let survives = self.with_level(|b, level, ticks| {
|
||||
b.can_survive(level, ticks, BlockPos::new(at[0], at[1], at[2]), torch)
|
||||
});
|
||||
if survives {
|
||||
self.set_block_clients(at, torch)?;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `Level.setBlock(pos, state, UPDATE_CLIENTS)`: no neighbour updates.
|
||||
fn set_block_clients(&mut self, pos: [i32; 3], state: BlockStateId) -> Result<()> {
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.set_block(
|
||||
level,
|
||||
ticks,
|
||||
BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
state,
|
||||
UPDATE_CLIENTS,
|
||||
);
|
||||
});
|
||||
self.process_level_events()
|
||||
}
|
||||
/// The block at `pos` has no collision shape.
|
||||
fn collision_empty(&self, pos: [i32; 3]) -> bool {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
self.collision
|
||||
.collision_shape_with_context(
|
||||
&mut scene,
|
||||
self.world.block(pos[0], pos[1], pos[2]),
|
||||
BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
&CollisionContext::empty(false),
|
||||
)
|
||||
.is_ok_and(|shape| shape.is_empty())
|
||||
}
|
||||
/// PlayerSpawnFinder.findSpawn around the world spawn: the feet of a
|
||||
/// player who has no position yet. None while a candidate's chunk loads.
|
||||
pub(super) fn find_player_spawn(&mut self, suggestion: [i32; 3]) -> Result<Option<Vec3>> {
|
||||
let mut finder = match self.player_spawn {
|
||||
Some(finder) => finder,
|
||||
None if self.level.game_mode == 2 => {
|
||||
// Adventure mode: the suggestion itself.
|
||||
let mut finder = PlayerSpawnFinder::new(suggestion, 0, |_| 0);
|
||||
finder.next_candidate();
|
||||
finder
|
||||
}
|
||||
None => {
|
||||
let mut radius = self.ui.rules.int("respawn_radius").max(0);
|
||||
let to_border = self
|
||||
.border
|
||||
.distance(f64::from(suggestion[0]), f64::from(suggestion[2]))
|
||||
.floor() as i32;
|
||||
if to_border < radius {
|
||||
radius = to_border;
|
||||
}
|
||||
if to_border <= 1 {
|
||||
radius = 1;
|
||||
}
|
||||
PlayerSpawnFinder::new(suggestion, radius, |n| {
|
||||
entity_random().next_int_bound(n).unwrap_or(0)
|
||||
})
|
||||
}
|
||||
None => self.spawn_finder(suggestion),
|
||||
};
|
||||
let found = loop {
|
||||
let Some(candidate) = finder.next_candidate() else {
|
||||
@@ -176,6 +246,58 @@ impl Game<'_> {
|
||||
self.player_spawn = found.is_none().then_some(finder);
|
||||
Ok(found)
|
||||
}
|
||||
/// A new PlayerSpawnFinder around `suggestion`: the respawn radius, cut
|
||||
/// by the world border; the suggestion itself in adventure mode.
|
||||
fn spawn_finder(&self, suggestion: [i32; 3]) -> PlayerSpawnFinder {
|
||||
if self.level.game_mode == 2 {
|
||||
let mut finder = PlayerSpawnFinder::new(suggestion, 0, |_| 0);
|
||||
finder.next_candidate();
|
||||
return finder;
|
||||
}
|
||||
let mut radius = self.ui.rules.int("respawn_radius").max(0);
|
||||
let to_border = self
|
||||
.border
|
||||
.distance(f64::from(suggestion[0]), f64::from(suggestion[2]))
|
||||
.floor() as i32;
|
||||
if to_border < radius {
|
||||
radius = to_border;
|
||||
}
|
||||
if to_border <= 1 {
|
||||
radius = 1;
|
||||
}
|
||||
PlayerSpawnFinder::new(suggestion, radius, |n| {
|
||||
entity_random().next_int_bound(n).unwrap_or(0)
|
||||
})
|
||||
}
|
||||
/// ServerPlayer.adjustSpawnLocation for a server's new player: where
|
||||
/// PlayerSpawnFinder.findSpawn puts their feet around the world spawn
|
||||
/// (`spawn`, the feet there), so that players new to a world don't all
|
||||
/// stand in one spot. Vanilla waits for each candidate's chunk; a server
|
||||
/// tick can't, so a candidate in a chunk not loaded yet is passed over,
|
||||
/// and `spawn` stays when the search never reaches a loaded column.
|
||||
pub(super) fn server_player_spawn(&self, spawn: [f64; 3]) -> [f64; 3] {
|
||||
let suggestion = spawn.map(|v| v.floor() as i32);
|
||||
let mut finder = self.spawn_finder(suggestion);
|
||||
while let Some(candidate) = finder.next_candidate() {
|
||||
let (x, z) = match candidate {
|
||||
SpawnCandidate::Column(x, z) => (x, z),
|
||||
SpawnCandidate::Suggestion => (suggestion[0], suggestion[2]),
|
||||
};
|
||||
if !self.streaming.is_lit(x >> 4, z >> 4) {
|
||||
continue;
|
||||
}
|
||||
let feet = match candidate {
|
||||
SpawnCandidate::Column(x, z) => level_respawn_pos(&self.spawn_columns(), x, z)
|
||||
.filter(|&pos| self.player_fits(pos))
|
||||
.map(bottom_center),
|
||||
SpawnCandidate::Suggestion => Some(self.fixup_spawn_height(suggestion)),
|
||||
};
|
||||
if let Some(feet) = feet {
|
||||
return [feet.x, feet.y, feet.z];
|
||||
}
|
||||
}
|
||||
spawn
|
||||
}
|
||||
/// PlayerSpawnFinder.noCollisionNoLiquid: a player's box standing at the
|
||||
/// block's bottom centre touches no block or liquid.
|
||||
fn player_fits(&self, pos: [i32; 3]) -> bool {
|
||||
|
||||
@@ -52,6 +52,8 @@ struct Flags {
|
||||
/// server follows while it runs (IntegratedServer.tickServer).
|
||||
render_distance: AtomicI32,
|
||||
simulation_distance: AtomicI32,
|
||||
/// `MinecraftServer.isCurrentlySaving`: the server is autosaving.
|
||||
saving: AtomicBool,
|
||||
}
|
||||
|
||||
/// Work the window hands its server thread (`MinecraftServer.execute`).
|
||||
@@ -69,6 +71,10 @@ impl Integrated {
|
||||
pub fn smoothed_tick_ms(&self) -> f32 {
|
||||
f32::from_bits(self.flags.smoothed_tick_ms.load(Ordering::Relaxed))
|
||||
}
|
||||
/// `MinecraftServer.isCurrentlySaving`.
|
||||
pub fn is_saving(&self) -> bool {
|
||||
self.flags.saving.load(Ordering::Relaxed)
|
||||
}
|
||||
/// `IntegratedServer.tickServer` skips the world while the game is paused.
|
||||
pub fn set_paused(&self, paused: bool) {
|
||||
self.flags.paused.store(paused, Ordering::Relaxed);
|
||||
@@ -148,6 +154,12 @@ pub(super) struct Serving {
|
||||
/// is out of its place: applied after the tick.
|
||||
pending: Option<Publish>,
|
||||
}
|
||||
impl Serving {
|
||||
/// The window reads this as `MinecraftServer.isCurrentlySaving`.
|
||||
pub(super) fn set_saving(&self, saving: bool) {
|
||||
self.flags.saving.store(saving, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
enum Publish {
|
||||
Open(TcpListener, LanSettings),
|
||||
Close,
|
||||
|
||||
@@ -196,13 +196,31 @@ impl Game<'_> {
|
||||
skins: &self.skin_downloads,
|
||||
}
|
||||
}
|
||||
/// The client level as item models read it.
|
||||
/// The client level as item models read it: worked out once a frame (a
|
||||
/// screen of items read the same level, its sky sampled once).
|
||||
pub(super) fn item_level(&self) -> LevelState {
|
||||
let dimension = match self.dim {
|
||||
Dim::Overworld => "minecraft:overworld",
|
||||
Dim::Nether => "minecraft:the_nether",
|
||||
Dim::End => "minecraft:the_end",
|
||||
};
|
||||
let key = (
|
||||
self.frames,
|
||||
self.world.game_time,
|
||||
self.world.day_time,
|
||||
dimension,
|
||||
);
|
||||
let mut cached = self.item_level_cache.borrow_mut();
|
||||
if let Some((at, level)) = cached.as_ref()
|
||||
&& *at == key
|
||||
{
|
||||
return level.clone();
|
||||
}
|
||||
let level = self.level_state_now(dimension);
|
||||
*cached = Some((key, level.clone()));
|
||||
level
|
||||
}
|
||||
fn level_state_now(&self, dimension: &'static str) -> LevelState {
|
||||
let sky = self
|
||||
.dim_environment()
|
||||
.sample(self.world.day_time, None, None);
|
||||
|
||||
@@ -11,7 +11,7 @@ use horizon_client::{
|
||||
chunks::ChunkEncoder,
|
||||
data::SyncData,
|
||||
entity::{EntityState, States},
|
||||
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
hub::{BodyFlags, HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
playerdata,
|
||||
},
|
||||
mobs::{Mob, Who, leash::Holder},
|
||||
@@ -24,11 +24,24 @@ use horizon_protocol::{
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, OnceLock},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
/// The registries and tags are the same for every world: load them once.
|
||||
static SYNC: OnceLock<std::result::Result<Arc<SyncData>, String>> = OnceLock::new();
|
||||
static SYNC: Mutex<Option<std::result::Result<Arc<SyncData>, String>>> = Mutex::new(None);
|
||||
|
||||
/// The registries and tags a server sends, loaded from `root` the first time.
|
||||
pub(super) fn sync_data(root: &std::path::Path) -> std::result::Result<Arc<SyncData>, String> {
|
||||
let mut sync = SYNC.lock().unwrap_or_else(|e| e.into_inner());
|
||||
sync.get_or_insert_with(|| SyncData::load(root).map(Arc::new))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Let go of what `sync_data` loaded: for a preload no world used
|
||||
/// (game/world_loading.rs).
|
||||
pub(super) fn forget_sync_data() {
|
||||
*SYNC.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
|
||||
/// The settings of the open world (WorldOptionsScreen's multiplayer section).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -178,6 +191,17 @@ fn mob_meta(
|
||||
meta.push((9, serializer::FLOAT, Value::Float(health)));
|
||||
}
|
||||
defaults.push((9, serializer::FLOAT, Value::Float(1.0)));
|
||||
// LivingEntity.DATA_EFFECT_PARTICLES and DATA_EFFECT_AMBIENCE_ID: the
|
||||
// swirls of its effects (the ones a mob has are visible, not ambient).
|
||||
meta.extend(
|
||||
hub.effect_meta(
|
||||
(m.extra.effects.iter())
|
||||
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
|
||||
.map(|e| (e.0, false)),
|
||||
),
|
||||
);
|
||||
defaults.push((10, serializer::PARTICLES, Value::Particles(Vec::new())));
|
||||
defaults.push((11, serializer::BOOLEAN, Value::Bool(false)));
|
||||
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4 (what
|
||||
// draws a vindicator's axe, a skeleton's drawn bow, a piglin's raised
|
||||
// weapon).
|
||||
@@ -223,6 +247,27 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((16, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// SnowGolem.DATA_PUMPKIN_ID: 16 while the pumpkin is on (the default).
|
||||
"minecraft:snow_golem" => {
|
||||
if e.sheared {
|
||||
meta.push((16, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
defaults.push((16, serializer::BYTE, Value::Byte(16)));
|
||||
}
|
||||
// MushroomCow.DATA_TYPE: 0 red, 1 brown.
|
||||
"minecraft:mooshroom" => {
|
||||
if e.variant == 1 {
|
||||
meta.push((17, serializer::INT, Value::Int(1)));
|
||||
}
|
||||
defaults.push((17, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
// Piglin.DATA_IS_DANCING (after the baby flag and the crossbow charge).
|
||||
"minecraft:piglin" => {
|
||||
if e.scratch[0] > 0 {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 when sheared.
|
||||
"minecraft:sheep" => {
|
||||
let value = (e.color & 15) | if e.sheared { 16 } else { 0 };
|
||||
@@ -337,6 +382,14 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// PolarBear.DATA_STANDING_ID (after AgeableMob's two): the bear
|
||||
// rears up; the client animates it.
|
||||
"minecraft:polar_bear" => {
|
||||
if e.flag {
|
||||
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose.
|
||||
"minecraft:evoker" | "minecraft:illusioner" => {
|
||||
if e.variant2 != 0 {
|
||||
@@ -562,10 +615,7 @@ impl Game<'_> {
|
||||
config: HubConfig,
|
||||
host: Option<u128>,
|
||||
) -> Result<()> {
|
||||
let root = self.options.resources.clone();
|
||||
let sync = SYNC
|
||||
.get_or_init(|| SyncData::load(&root).map(Arc::new))
|
||||
.clone()?;
|
||||
let sync = sync_data(&self.options.resources)?;
|
||||
let biome_names: Vec<String> = self
|
||||
.res
|
||||
.biomes()
|
||||
@@ -835,6 +885,16 @@ impl Game<'_> {
|
||||
living_flags: living_flags(self.using)
|
||||
| if p.auto_spin_attack_ticks > 0 { 4 } else { 0 },
|
||||
bed: self.sleeping,
|
||||
body: BodyFlags {
|
||||
gliding: p.fall_flying,
|
||||
swimming: p.swimming,
|
||||
crawling: p.pose == horizon_client::pose::Pose::Swimming && !p.swimming,
|
||||
crouching: p.pose == horizon_client::pose::Pose::Crouching,
|
||||
spin_attack: p.auto_spin_attack_ticks > 0,
|
||||
on_fire: p.fire_ticks > 0,
|
||||
invisible: self.potions.effects.get("invisibility").is_some(),
|
||||
glowing: self.potions.effects.get("glowing").is_some(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -944,7 +1004,24 @@ impl Game<'_> {
|
||||
for m in &mut self.mobs {
|
||||
m.needs_sync = false;
|
||||
}
|
||||
// `runBlockEvents`' broadcast precedes the tick's block changes
|
||||
// (`ChunkHolder.broadcastChanges` in `tick_level` below): the
|
||||
// guest re-runs a piston's `triggerEvent` while its level still
|
||||
// has the blocks as they were before the move.
|
||||
for (pos, b0, b1, state) in std::mem::take(&mut self.lan_block_events) {
|
||||
host.hub.block_event(pos, b0, b1, state);
|
||||
}
|
||||
let entities = states.as_slice();
|
||||
// An entity that left the level since the last tick (a snowball
|
||||
// that hit) is dropped by the tracker below: its event goes out
|
||||
// first, as `Level.broadcastEntityEvent` precedes the discard.
|
||||
let (gone, later): (Vec<_>, Vec<_>) = std::mem::take(&mut self.lan_entity_events)
|
||||
.into_iter()
|
||||
.partition(|(id, ..)| !entities.iter().any(|e| e.id == *id));
|
||||
self.lan_entity_events = later;
|
||||
for (id, event, at) in gone {
|
||||
host.hub.entity_event(id, event, at);
|
||||
}
|
||||
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
|
||||
let events = {
|
||||
let clock_rate = self.network_clock_rate();
|
||||
@@ -1121,8 +1198,17 @@ impl Game<'_> {
|
||||
g.game_mode = default_mode;
|
||||
}
|
||||
if !known {
|
||||
g.position = spawn;
|
||||
g.previous_position = spawn;
|
||||
// ServerPlayer.adjustSpawnLocation: a player new to the world
|
||||
// stands where PlayerSpawnFinder puts them, not all on one spot
|
||||
// (it reads the loaded level, which holds the spawn when it is
|
||||
// the overworld).
|
||||
let feet = if self.dim == super::Dim::Overworld {
|
||||
self.server_player_spawn(spawn)
|
||||
} else {
|
||||
spawn
|
||||
};
|
||||
g.position = feet;
|
||||
g.previous_position = feet;
|
||||
g.health = 20.0;
|
||||
g.food = 20;
|
||||
g.dimension = super::Dim::Overworld.id().to_owned();
|
||||
@@ -1177,17 +1263,29 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
for e in events {
|
||||
if let horizon_world::level::LevelEvent::LevelEvent { id, pos, data } = e {
|
||||
// The acting guest does not predict the egg's teleport
|
||||
// particles, bone meal's growth, or anvil result sounds
|
||||
// (these levelEvent calls name no excluded player).
|
||||
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
|
||||
None
|
||||
} else {
|
||||
self.lan_acting
|
||||
};
|
||||
self.lan_events
|
||||
.push((*id, [pos.x, pos.y, pos.z], *data, except));
|
||||
match e {
|
||||
horizon_world::level::LevelEvent::LevelEvent { id, pos, data } => {
|
||||
// The acting guest does not predict the egg's teleport
|
||||
// particles, bone meal's growth, or anvil result sounds
|
||||
// (these levelEvent calls name no excluded player).
|
||||
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
|
||||
None
|
||||
} else {
|
||||
self.lan_acting
|
||||
};
|
||||
self.lan_events
|
||||
.push((*id, [pos.x, pos.y, pos.z], *data, except));
|
||||
}
|
||||
// `ServerLevel.runBlockEvents` broadcasts a handled block
|
||||
// event (`ClientboundBlockEventPacket`): the guest re-runs
|
||||
// the block's `triggerEvent` — a piston's `moveBlocks` is
|
||||
// what animates the move there. `state` is the block the
|
||||
// event named, read before it ran (the packet's `block`).
|
||||
horizon_world::level::LevelEvent::BlockEntityEvent { pos, state, a, b } => {
|
||||
self.lan_block_events
|
||||
.push(([pos.x, pos.y, pos.z], *a, *b, state.0));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1205,8 +1303,9 @@ impl Game<'_> {
|
||||
self.lan_entity_events.push((id, event, at));
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.crit`: this window's emitter, and an animation for the
|
||||
/// attacker's client and observers once the hub is back in place.
|
||||
/// `ServerPlayer.crit` (or `magicCrit` when `magic`): this window's
|
||||
/// emitter, and an animation for the attacker's client and observers once
|
||||
/// the hub is back in place.
|
||||
pub(super) fn critical_hit(
|
||||
&mut self,
|
||||
attacker: Option<u32>,
|
||||
@@ -1214,16 +1313,18 @@ impl Game<'_> {
|
||||
feet: [f64; 3],
|
||||
width: f64,
|
||||
height: f64,
|
||||
magic: bool,
|
||||
) {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
self.particles.tracking_emitter(
|
||||
ParticleOptions::simple(ParticleType::Crit),
|
||||
feet,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
let particle = if magic {
|
||||
ParticleType::EnchantedHit
|
||||
} else {
|
||||
ParticleType::Crit
|
||||
};
|
||||
self.particles
|
||||
.tracking_emitter(ParticleOptions::simple(particle), feet, width, height);
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_critical_hits.push((attacker, id));
|
||||
self.lan_critical_hits.push((attacker, id, magic));
|
||||
}
|
||||
}
|
||||
/// Send the tick's level events, sounds and mob animations.
|
||||
@@ -1301,13 +1402,13 @@ impl Game<'_> {
|
||||
seen.insert(m.id, (m.hurt_time, dead, swing));
|
||||
}
|
||||
host.mob_state.insert(self.dim, seen);
|
||||
for (attacker, target) in critical_hits {
|
||||
for (attacker, target, magic) in critical_hits {
|
||||
let Some(attacker) = attacker.map_or(Some(host.host_entity), |conn| {
|
||||
host.hub.guest(conn).map(|g| g.entity_id)
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
host.hub.critical_hit(attacker, target);
|
||||
host.hub.critical_hit(attacker, target, magic);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1390,6 +1491,13 @@ impl Game<'_> {
|
||||
.each_ref()
|
||||
.map(|s| s.as_ref().map(|s| s.item.clone()));
|
||||
m.extra.crouching = g.sneaking;
|
||||
m.extra.sprinting = g.sprinting;
|
||||
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
|
||||
let visible = || g.effects.list.iter().filter(|e| e.visible);
|
||||
m.extra.effect_particles = visible()
|
||||
.filter_map(|e| super::client_ambient::effect_particle(e.id, e.ambient))
|
||||
.collect();
|
||||
m.extra.effect_ambient = visible().all(|e| e.ambient);
|
||||
// Lying in its bed (LivingEntity.getBedOrientation).
|
||||
m.extra.ints[1] = g.bed.map_or(0, |b| self.bed_facing(b));
|
||||
// LivingEntity.updateSwingTime: the arm swing lan_swings started.
|
||||
|
||||
@@ -1216,6 +1216,12 @@ impl Game<'_> {
|
||||
{
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
S::SelectBundleItem(p) => {
|
||||
if !spectator && let Ok(slot) = usize::try_from(p.slot) {
|
||||
host.hub
|
||||
.select_bundle_item(conn, slot, p.index, &mut self.world);
|
||||
}
|
||||
}
|
||||
S::ContainerClick(p) => {
|
||||
let rules = ItemRules(self.res.items());
|
||||
let slots = host.hub.guests[gi]
|
||||
@@ -2183,6 +2189,7 @@ impl Game<'_> {
|
||||
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
|
||||
let at = g.position;
|
||||
let sneaking = g.sneaking;
|
||||
let mut soul = None;
|
||||
let events = self.as_guest(host, conn, |game| {
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
@@ -2252,6 +2259,7 @@ impl Game<'_> {
|
||||
w.player.shift_key_down = sneaking;
|
||||
w.player.freeze_immune = freeze_immune;
|
||||
let _ = w.server_block_damage(start);
|
||||
let _ = w.update_soul_speed_block();
|
||||
let _ = w.tick_environment_damage();
|
||||
let _ = w.apply_fire_contact();
|
||||
let _ = w.server_hazards_tick(in_wall);
|
||||
@@ -2279,6 +2287,9 @@ impl Game<'_> {
|
||||
// LivingEntity.baseTick: outside the world border, then tickEffects.
|
||||
game.border_damage();
|
||||
game.tick_mob_effects();
|
||||
// Soul Speed's effects (LivingEntity.baseTick's location_changed
|
||||
// and tick effects); its particle goes to the players around.
|
||||
soul = game.tick_soul_speed();
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
@@ -2301,16 +2312,48 @@ impl Game<'_> {
|
||||
let marked = std::mem::take(&mut player.health.sync_velocity);
|
||||
let velocity = marked.then_some(player.velocity);
|
||||
game.record_combat(&events);
|
||||
game.wear_armor(&events);
|
||||
(events, velocity)
|
||||
});
|
||||
let Some((events, marked)) = events else {
|
||||
return;
|
||||
};
|
||||
if let Some((at, velocity)) = soul {
|
||||
super::lan_particles::send_soul_particle(host, at, velocity);
|
||||
}
|
||||
self.as_guest(host, conn, |game| game.tick_player_sculk_events());
|
||||
for event in events {
|
||||
match event {
|
||||
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
|
||||
// LivingEntity.checkFallDamage: the crumbs reach every player.
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Particles {
|
||||
state,
|
||||
position,
|
||||
count,
|
||||
speed,
|
||||
}) => self.landing_dust(Some(host), state, position, count, speed),
|
||||
MotionEvent::Fall(event) => self.landing_block_updates(&event),
|
||||
// HoneyBlock.maybeDoSlideEffects: the sound reaches the
|
||||
// others (Player.playSound excludes the player, whose client
|
||||
// plays it), the entity event 53 everyone around.
|
||||
MotionEvent::HoneySlide {
|
||||
sound, particles, ..
|
||||
} => {
|
||||
let Some((id, at)) = host.hub.guest(conn).map(|g| (g.entity_id, g.position))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if sound {
|
||||
let name = "minecraft:block.honey_block.slide";
|
||||
let source = horizon_client::settings::sound_source(name) as i32;
|
||||
self.audio.play_unshared(name, Some(at), 1.0, 1.0);
|
||||
host.hub.sound(name, at, 1.0, 1.0, source, Some(conn));
|
||||
}
|
||||
if particles {
|
||||
self.honey_slide_particles(at);
|
||||
host.hub.entity_event(id, 53, at);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -2394,9 +2437,13 @@ impl Game<'_> {
|
||||
}
|
||||
if (next / 10) % 2 == 0
|
||||
&& !creative
|
||||
&& let Some(elytra) = game.armor[1].as_mut()
|
||||
&& let Some(stack) = game.armor[1].clone()
|
||||
{
|
||||
elytra.damage += 1;
|
||||
// `ItemStack.hurtAndBreak`: Unbreaking can save the wear.
|
||||
let loss = game.durability_loss(&stack, 1);
|
||||
if let Some(elytra) = game.armor[1].as_mut() {
|
||||
elytra.damage += loss;
|
||||
}
|
||||
}
|
||||
let ctx = game.player_context(None);
|
||||
game.post_game_event("minecraft:elytra_glide", at, ctx);
|
||||
@@ -2659,7 +2706,13 @@ impl Game<'_> {
|
||||
.random
|
||||
.next_int_bound(touching.len() as i32)
|
||||
.unwrap_or(0) as usize;
|
||||
let orb = self.orbs.remove(touching[pick]);
|
||||
// A merged orb gives its value once per count and goes at zero.
|
||||
let orb = self.orbs[touching[pick]].clone();
|
||||
if orb.count <= 1 {
|
||||
self.orbs.remove(touching[pick]);
|
||||
} else {
|
||||
self.orbs[touching[pick]].count -= 1;
|
||||
}
|
||||
play.xp_delay = 2;
|
||||
host.hub.take_item(orb.id as i32, id, 1, at);
|
||||
// ClientPacketListener.handleTakeItemEntity's sound, at the orb.
|
||||
|
||||
@@ -289,6 +289,10 @@ impl Game<'_> {
|
||||
self.bolts.iter_mut().for_each(|b| give(&mut b.id));
|
||||
self.potions.thrown_ids().for_each(&mut give);
|
||||
self.end.eye_ids().for_each(&mut give);
|
||||
self.breath_clouds.iter_mut().for_each(|b| give(&mut b.id));
|
||||
self.dragon_fireballs
|
||||
.iter_mut()
|
||||
.for_each(|b| give(&mut b.id));
|
||||
}
|
||||
|
||||
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
|
||||
@@ -350,6 +354,20 @@ impl Game<'_> {
|
||||
a.is_some_and(|a| a.in_ground) || p.stuck,
|
||||
));
|
||||
}
|
||||
// ThrownTrident.ID_LOYALTY (none for a mob's) and ID_FOIL, on
|
||||
// AbstractArrow's flags.
|
||||
K::Trident => {
|
||||
let a = p.arrow.as_deref();
|
||||
e.meta.extend(arrow_meta(
|
||||
false,
|
||||
a.is_some_and(|a| a.no_physics),
|
||||
0,
|
||||
a.is_some_and(|a| a.in_ground) || p.stuck,
|
||||
));
|
||||
if a.is_some_and(|a| a.pickup_item.has_any_enchantments()) {
|
||||
e.meta.push((12, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// WitherSkull.DATA_DANGEROUS.
|
||||
K::WitherSkull { dangerous: true } => {
|
||||
e.meta.push((8, serializer::BOOLEAN, Value::Bool(true)));
|
||||
@@ -445,6 +463,35 @@ impl Game<'_> {
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
// The dragon's breath clouds (AreaEffectCloud's DATA_RADIUS,
|
||||
// DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the
|
||||
// client draws them from these (`tick_remote_clouds`).
|
||||
let breath = self.network_particle(
|
||||
&horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::DragonBreath,
|
||||
),
|
||||
None,
|
||||
);
|
||||
for b in self.dragon_fireballs.iter().filter(|b| b.id != 0) {
|
||||
let p = b.position;
|
||||
let mut e = state(b.id, "minecraft:dragon_fireball", at(p), 0.0, false);
|
||||
e.velocity = [b.velocity.x, b.velocity.y, b.velocity.z];
|
||||
out.push(e);
|
||||
}
|
||||
for b in self.breath_clouds.iter().filter(|b| b.id != 0) {
|
||||
let mut e = state(b.id, "minecraft:area_effect_cloud", b.at, 0.0, false);
|
||||
e.meta.push((8, serializer::FLOAT, Value::Float(b.radius)));
|
||||
if b.age < 20 {
|
||||
e.meta.push((9, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
if let Some(p) = &breath {
|
||||
e.meta
|
||||
.push((10, serializer::PARTICLE, Value::Particle(p.clone())));
|
||||
}
|
||||
e.defaults
|
||||
.push((9, serializer::BOOLEAN, Value::Bool(false)));
|
||||
out.push(e);
|
||||
}
|
||||
out.extend(self.lan_dragon_state());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +370,7 @@ mod tests {
|
||||
velocity: Vec3::ZERO,
|
||||
yaw: 0.0,
|
||||
hero: false,
|
||||
effects: Vec::new(),
|
||||
uuid: [0; 4],
|
||||
allies: Default::default(),
|
||||
remote: false,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Particles a server sends its players and its clients draw:
|
||||
//! `ClientboundLevelParticlesPacket` (`ServerLevel.sendParticles`, which
|
||||
//! `/particle` uses) and `ClientboundExplodePacket` (`ServerLevel.explode`).
|
||||
use super::Game;
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::lan::hub::Hub;
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_protocol::{
|
||||
@@ -24,6 +25,35 @@ fn rgb(c: [f32; 4]) -> [f32; 3] {
|
||||
[c[0], c[1], c[2]]
|
||||
}
|
||||
|
||||
/// `ServerLevel.sendParticles` of one soul particle moving by `velocity`
|
||||
/// (Soul Speed's `spawn_particles`): sent to the players within 32 blocks.
|
||||
pub(super) fn send_soul_particle(host: &super::lan::LanHost, at: [f64; 3], velocity: [f64; 3]) {
|
||||
let Some(id) = host
|
||||
.hub
|
||||
.registry_id("minecraft:particle_type", "minecraft:soul")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let packet = C::LevelParticles(play::LevelParticles {
|
||||
particle: Particle { id, data: None },
|
||||
override_limiter: false,
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: velocity.map(|v| v as f32),
|
||||
max_speed: [1.0; 3],
|
||||
count: 0,
|
||||
randomization: 0,
|
||||
});
|
||||
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
|
||||
let distance: f64 = (0..3)
|
||||
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
.sum();
|
||||
if distance < 32.0 * 32.0 {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `SonicBoom.tick` uses `ServerLevel.sendParticles` for each ray point.
|
||||
/// Keep the local event's rendering; connected players receive one at rest.
|
||||
@@ -52,6 +82,66 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.checkFallDamage`'s `ServerLevel.sendParticles` of the
|
||||
/// landing block's crumbs: drawn in this window, when it has one, and
|
||||
/// sent to each player within 32 blocks. `host` is the hub of a guest's
|
||||
/// turn, which is out of `self.lan` meanwhile.
|
||||
pub(super) fn landing_dust(
|
||||
&mut self,
|
||||
host: Option<&LanHost>,
|
||||
state: BlockStateId,
|
||||
at: Vec3,
|
||||
count: i32,
|
||||
speed: f64,
|
||||
) {
|
||||
let at = [at.x, at.y, at.z];
|
||||
if self.serving.is_none() && self.options.dedicated.is_none() {
|
||||
let block = [
|
||||
at[0].floor() as i32,
|
||||
(at[1] - 0.2).floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
];
|
||||
if let Some((sprite, tint)) = self.particle_sprite(state, block) {
|
||||
self.particles.send_particles(
|
||||
&ParticleOptions::block(ParticleType::Block, sprite, tint),
|
||||
at,
|
||||
[0.0; 3],
|
||||
speed,
|
||||
count as u32,
|
||||
);
|
||||
}
|
||||
}
|
||||
let Some(hub) = host.or(self.lan.as_ref()).map(|h| &h.hub) else {
|
||||
return;
|
||||
};
|
||||
let Some(particle) = Self::hub_particle(
|
||||
hub,
|
||||
&ParticleOptions::simple(ParticleType::Block),
|
||||
Some(state),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let packet = C::LevelParticles(play::LevelParticles {
|
||||
particle,
|
||||
override_limiter: false,
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: [0.0; 3],
|
||||
max_speed: [speed as f32; 3],
|
||||
count,
|
||||
randomization: 0,
|
||||
});
|
||||
for g in hub.guests.iter().filter(|g| hub.here(g)) {
|
||||
// ServerLevel compares the player's block centre, strictly within 32.
|
||||
let distance: f64 = (0..3)
|
||||
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
.sum();
|
||||
if distance < 32.0 * 32.0 {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ParticleTypes.STREAM_CODEC`'s value for `options`: the type's
|
||||
/// network id and the data it carries (`state` for the block particles).
|
||||
/// None while not serving, or for a particle whose data isn't sent from
|
||||
@@ -60,13 +150,19 @@ impl Game<'_> {
|
||||
&self,
|
||||
options: &ParticleOptions,
|
||||
state: Option<BlockStateId>,
|
||||
) -> Option<Particle> {
|
||||
Self::hub_particle(&self.lan.as_ref()?.hub, options, state)
|
||||
}
|
||||
|
||||
/// `network_particle` for a hub that is out of `self.lan` meanwhile (a
|
||||
/// guest's turn).
|
||||
fn hub_particle(
|
||||
hub: &Hub,
|
||||
options: &ParticleOptions,
|
||||
state: Option<BlockStateId>,
|
||||
) -> Option<Particle> {
|
||||
let name = format!("minecraft:{}", options.ty.name());
|
||||
let id = self
|
||||
.lan
|
||||
.as_ref()?
|
||||
.hub
|
||||
.registry_id("minecraft:particle_type", &name)?;
|
||||
let id = hub.registry_id("minecraft:particle_type", &name)?;
|
||||
use ParticleType as T;
|
||||
let c = options.color;
|
||||
let data = match options.ty {
|
||||
@@ -95,7 +191,11 @@ impl Game<'_> {
|
||||
|
||||
/// The options a server's particle stands for (None for a type or data
|
||||
/// this client doesn't draw).
|
||||
fn received_particle(&mut self, p: &Particle, at: [f64; 3]) -> Option<ParticleOptions> {
|
||||
pub(super) fn received_particle(
|
||||
&mut self,
|
||||
p: &Particle,
|
||||
at: [f64; 3],
|
||||
) -> Option<ParticleOptions> {
|
||||
let name = self
|
||||
.remote
|
||||
.as_ref()?
|
||||
@@ -221,8 +321,14 @@ impl Game<'_> {
|
||||
/// `ServerLevel.explode`'s packet to each player within 64 blocks. The
|
||||
/// sound and the push reach them on their own (the level's sounds, the
|
||||
/// motion a hurt player is sent), so the packet carries neither.
|
||||
pub(super) fn lan_explode(&self, center: [f64; 3], radius: f32, block_count: usize) {
|
||||
let emitter = if radius < 2.0 {
|
||||
pub(super) fn lan_explode(
|
||||
&self,
|
||||
center: [f64; 3],
|
||||
small: bool,
|
||||
radius: f32,
|
||||
block_count: usize,
|
||||
) {
|
||||
let emitter = if small {
|
||||
"minecraft:explosion"
|
||||
} else {
|
||||
"minecraft:explosion_emitter"
|
||||
|
||||
@@ -37,6 +37,8 @@ pub(super) struct Strike {
|
||||
amount: f32,
|
||||
scale: f32,
|
||||
crit: bool,
|
||||
/// `Player.attack`'s `magicBoost > 0`: the enchantments added damage.
|
||||
magic: bool,
|
||||
from: [f64; 3],
|
||||
sweep: Option<[f64; 3]>,
|
||||
/// `causeExtraKnockback`: its strength and direction (the attacker's
|
||||
@@ -109,7 +111,7 @@ impl Game<'_> {
|
||||
pub(super) fn pvp_strike(&mut self) -> Option<Strike> {
|
||||
let p = self.player.as_ref()?;
|
||||
let from = [p.position.x, p.position.y, p.position.z];
|
||||
let (amount, scale, crit) = self.melee_damage("minecraft:player");
|
||||
let (amount, scale, crit, magic) = self.melee_damage("minecraft:player");
|
||||
let sweep = self.sweep_attack_position(scale, crit);
|
||||
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
|
||||
let (xd, zd) = self.extra_knockback_direction();
|
||||
@@ -125,6 +127,7 @@ impl Game<'_> {
|
||||
amount,
|
||||
scale,
|
||||
crit,
|
||||
magic,
|
||||
from,
|
||||
sweep,
|
||||
knockback: (strength, xd, zd),
|
||||
@@ -245,15 +248,19 @@ impl Game<'_> {
|
||||
if hurt && strength > 0.0 {
|
||||
g.knock_back_player(f64::from(strength), xd, zd);
|
||||
}
|
||||
hurt
|
||||
// The victim's thorns, dealt to the attacker below.
|
||||
let thorns = if hurt { g.thorns_damage() } else { Vec::new() };
|
||||
(hurt, thorns)
|
||||
};
|
||||
let hurt = self.ui.rules.bool("pvp")
|
||||
&& harm
|
||||
&& match victim {
|
||||
let (hurt, thorns) = if self.ui.rules.bool("pvp") && harm {
|
||||
match victim {
|
||||
Victim::Host => land(self),
|
||||
Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or(false),
|
||||
};
|
||||
if hurt && s.crit {
|
||||
Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or_default(),
|
||||
}
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
if hurt && (s.crit || s.magic) {
|
||||
let target = match victim {
|
||||
Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)),
|
||||
Victim::Guest(conn) => host
|
||||
@@ -263,13 +270,26 @@ impl Game<'_> {
|
||||
};
|
||||
if let Some((id, p)) = target {
|
||||
let d = p.dimensions;
|
||||
self.critical_hit(
|
||||
s.attacker,
|
||||
id,
|
||||
let (feet, width, height) = (
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
f64::from(d.width),
|
||||
f64::from(d.height),
|
||||
);
|
||||
if s.crit {
|
||||
self.critical_hit(s.attacker, id, feet, width, height, false);
|
||||
}
|
||||
if s.magic {
|
||||
self.critical_hit(s.attacker, id, feet, width, height, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
// `Enchantment.doPostAttack`: thorns hurt the attacking player.
|
||||
for damage in thorns {
|
||||
match s.attacker {
|
||||
None => self.hurt_player_by("minecraft:thorns", damage),
|
||||
Some(conn) => {
|
||||
self.guest_hit_by(host, conn, |g| g.hurt_player_by("minecraft:thorns", damage));
|
||||
}
|
||||
}
|
||||
}
|
||||
let sound = match (hurt, s.crit, s.scale > 0.9) {
|
||||
|
||||
@@ -104,6 +104,9 @@ impl Game<'_> {
|
||||
};
|
||||
let m = &mut self.mobs[i];
|
||||
if let Some(h) = holder {
|
||||
if h == Holder::Player {
|
||||
m.extra.leash_player = Some(player_uuid);
|
||||
}
|
||||
m.extra.leash = Some(h);
|
||||
m.extra.leash_pending = None;
|
||||
} else if m.age > 100 {
|
||||
@@ -201,7 +204,9 @@ impl Game<'_> {
|
||||
if self.mobs[index].extra.leash.is_some() {
|
||||
self.break_leash(id, true, false);
|
||||
}
|
||||
let holder = self.acting_player_uuid();
|
||||
self.mobs[index].extra.leash = Some(Holder::Player);
|
||||
self.mobs[index].extra.leash_player = Some(holder);
|
||||
self.lead_sound("tied", at);
|
||||
// The lead is always used up.
|
||||
if let Some(stack) = self.held_mut().as_mut() {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
|
||||
//! off (3004) and scraping (3005).
|
||||
use super::Game;
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_client::particles::{self, ParticleOptions, ParticleType};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
use horizon_world::level::LevelEvent;
|
||||
@@ -134,6 +135,68 @@ impl Game<'_> {
|
||||
// Each guest plays it from the global packet.
|
||||
self.audio.play_unshared(sound, at, volume, 1.0);
|
||||
}
|
||||
/// `LevelEventHandler.potionSplashParticles` (2002 with `Effect`, 2007
|
||||
/// with `InstantEffect`): eight splash potion crumbs, and a hundred
|
||||
/// spell particles in the potion's colour `data`, flung out in a disc.
|
||||
pub(super) fn potion_splash_particles(&mut self, pos: BlockPos, data: i32, instant: bool) {
|
||||
let ty = if instant {
|
||||
ParticleType::InstantEffect
|
||||
} else {
|
||||
ParticleType::Effect
|
||||
};
|
||||
// Vec3.atBottomCenterOf.
|
||||
let at = [
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y),
|
||||
f64::from(pos.z) + 0.5,
|
||||
];
|
||||
let crumb = self.item_particle(&ItemStack::new("minecraft:splash_potion", 1));
|
||||
let r = self.particles.level_random();
|
||||
let crumbs: Vec<[f64; 3]> = (0..8)
|
||||
.map(|_| {
|
||||
[
|
||||
r.next_gaussian() * 0.15,
|
||||
r.next_double() * 0.2,
|
||||
r.next_gaussian() * 0.15,
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
for v in crumbs {
|
||||
self.particles.add(&crumb, at, v);
|
||||
}
|
||||
let channel = |shift: i32| ((data >> shift) & 0xFF) as f32 / 255.0;
|
||||
let color = [channel(16), channel(8), channel(0)];
|
||||
for _ in 0..100 {
|
||||
let r = self.particles.level_random();
|
||||
let dist = r.next_double() * 4.0;
|
||||
let angle = r.next_double() * std::f64::consts::PI * 2.0;
|
||||
let velocity = [
|
||||
angle.cos() * dist,
|
||||
0.01 + r.next_double() * 0.5,
|
||||
angle.sin() * dist,
|
||||
];
|
||||
let brightness = 0.75 + r.next_float() * 0.25;
|
||||
let options = ParticleOptions {
|
||||
color: [
|
||||
color[0] * brightness,
|
||||
color[1] * brightness,
|
||||
color[2] * brightness,
|
||||
1.0,
|
||||
],
|
||||
scale: dist as f32,
|
||||
..ParticleOptions::simple(ty)
|
||||
};
|
||||
self.particles.add(
|
||||
&options,
|
||||
[
|
||||
at[0] + velocity[0] * 0.1,
|
||||
at[1] + 0.3,
|
||||
at[2] + velocity[2] * 0.1,
|
||||
],
|
||||
velocity,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Queue and play level event `id` at `pos` (`Level.levelEvent`).
|
||||
pub(super) fn level_event_at(&mut self, id: i32, pos: [i32; 3], data: i32) {
|
||||
self.level_events.push(LevelEvent::LevelEvent {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! The game's tick code reads one level: the one in place, held in `Game`'s
|
||||
//! own fields (the world, its streaming and save, block ticks, entities).
|
||||
//! A server (the dedicated server and single player's server thread) keeps
|
||||
//! the other dimensions' levels here and ticks each one that has players by
|
||||
//! swapping it into place (`enter_level`), the way `as_guest` swaps a player
|
||||
//! the other dimensions' levels here and ticks each one with players or forced
|
||||
//! chunks by swapping it into place (`enter_level`), the way `as_guest` swaps a player
|
||||
//! in, so the tick code runs unchanged, one level at a time. The in-frame
|
||||
//! game holds one level, which follows its player (`enter_dimension`).
|
||||
//!
|
||||
@@ -75,7 +75,7 @@ pub(super) struct Parked {
|
||||
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
|
||||
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
|
||||
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
|
||||
lan_critical_hits: Vec<(Option<u32>, i32)>,
|
||||
lan_critical_hits: Vec<(Option<u32>, i32, bool)>,
|
||||
/// Only the per-level parts of these are used (`swap_level`).
|
||||
anims: BlockEntityAnims,
|
||||
raiding: Raiding,
|
||||
@@ -109,6 +109,19 @@ pub(super) struct Levels {
|
||||
parked: HashMap<Dim, Parked>,
|
||||
}
|
||||
|
||||
impl Levels {
|
||||
/// The chunks of each level not in place (capability `world` lends
|
||||
/// them to server mods for a call).
|
||||
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
|
||||
pub(super) fn parked_chunks(
|
||||
&mut self,
|
||||
) -> impl Iterator<Item = (Dim, &mut horizon_client::chunk_map::ChunkMap)> {
|
||||
self.parked
|
||||
.iter_mut()
|
||||
.map(|(d, p)| (*d, &mut p.world.chunks))
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A new level for `dim`, opened from the save (`MinecraftServer.createLevels`).
|
||||
fn open_level(&self, dim: Dim) -> Result<Parked> {
|
||||
@@ -179,6 +192,7 @@ impl Game<'_> {
|
||||
let mut locator = self.end.locator();
|
||||
swap(&mut locator, &mut p.locator);
|
||||
self.end.set_locator(locator);
|
||||
self.share_spawn_conditions();
|
||||
swap(&mut self.save, &mut p.save);
|
||||
swap(&mut self.block_ticks, &mut p.block_ticks);
|
||||
swap(&mut self.tick_chunks, &mut p.tick_chunks);
|
||||
@@ -225,6 +239,7 @@ impl Game<'_> {
|
||||
swap(&mut a.pots, &mut b.pots);
|
||||
swap(&mut a.gateways, &mut b.gateways);
|
||||
swap(&mut a.gateway_servers, &mut b.gateway_servers);
|
||||
swap(&mut a.conduit_servers, &mut b.conduit_servers);
|
||||
self.raiding.swap_level(&mut p.raiding);
|
||||
self.sculk.swap_level(&mut p.sculk);
|
||||
self.creaking.swap_level(&mut p.creaking);
|
||||
@@ -293,8 +308,8 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// One server tick (`MinecraftServer.tickServer`): each level with
|
||||
/// players in turn (`tickChildren`), its chunks streamed and loaded and
|
||||
/// its world ticked. The first pass reads the network and runs the
|
||||
/// players or forced chunks in turn (`tickChildren`), its chunks streamed
|
||||
/// and loaded and its world ticked. The first pass reads the network and runs the
|
||||
/// tick's shared parts (the clock, the weather, functions, the autosave);
|
||||
/// the next ones only their level. While `paused`, only the connections
|
||||
/// run.
|
||||
@@ -313,6 +328,8 @@ impl Game<'_> {
|
||||
};
|
||||
self.update_dedicated_streaming();
|
||||
self.update_world();
|
||||
// What server mods set in this level since its last tick.
|
||||
self.apply_mod_block_writes();
|
||||
result = if paused {
|
||||
self.tick_lan(true);
|
||||
Ok(())
|
||||
@@ -383,7 +400,8 @@ impl Game<'_> {
|
||||
}
|
||||
*next += self.clock.tick_duration();
|
||||
if *next > now {
|
||||
// A `/pregen` task keeps the workers fed until the next tick.
|
||||
// A `/pregen` task or force-loaded chunks keep the workers fed
|
||||
// until the next tick.
|
||||
self.pump_pregen(*next);
|
||||
let now = Instant::now();
|
||||
if *next > now {
|
||||
@@ -396,7 +414,7 @@ impl Game<'_> {
|
||||
|
||||
/// The levels this server tick runs, in `MinecraftServer.levels` order
|
||||
/// (the Overworld, the Nether, the End): every level with a player in
|
||||
/// it or a `/pregen` task, or the Overworld while nobody is online.
|
||||
/// it, forced chunks or a `/pregen` task, or the Overworld while idle.
|
||||
pub(super) fn levels_to_tick(&self) -> Vec<Dim> {
|
||||
let guests = self
|
||||
.lan
|
||||
@@ -405,7 +423,17 @@ impl Game<'_> {
|
||||
let mut dims: Vec<Dim> = [Dim::Overworld, Dim::Nether, Dim::End]
|
||||
.into_iter()
|
||||
.filter(|d| {
|
||||
guests.iter().any(|g| g.dimension == d.id()) || self.pregen_level() == Some(*d)
|
||||
let forced = if *d == self.dim {
|
||||
!self.streaming.forced.is_empty()
|
||||
} else {
|
||||
self.levels
|
||||
.parked
|
||||
.get(d)
|
||||
.is_some_and(|p| !p.streaming.forced.is_empty())
|
||||
};
|
||||
guests.iter().any(|g| g.dimension == d.id())
|
||||
|| forced
|
||||
|| self.pregen_level() == Some(*d)
|
||||
})
|
||||
.collect();
|
||||
if dims.is_empty() {
|
||||
|
||||
@@ -74,14 +74,74 @@ impl Game<'_> {
|
||||
self.spawn_bolt(at, trap);
|
||||
}
|
||||
}
|
||||
/// `findLightningTargetAround`: a living entity under open sky in the
|
||||
/// column (3 blocks around), else the top of the column.
|
||||
/// `ServerLevel.findLightningRod`: `PoiManager.findClosest` over the
|
||||
/// lightning rod points of interest within 128 blocks of `center` whose
|
||||
/// block is the top (`WORLD_SURFACE`) of its column; the strike lands one
|
||||
/// above it. Horizon keeps no point-of-interest index, so this scans the
|
||||
/// loaded chunks the way `/locate poi` does.
|
||||
fn find_lightning_rod(&self, center: [i32; 3]) -> Option<[i32; 3]> {
|
||||
const RANGE: i32 = 128;
|
||||
let facts = self.res.facts();
|
||||
let mut is_rod: std::collections::HashMap<u32, bool> = std::collections::HashMap::new();
|
||||
let mut best: Option<(i64, [i32; 3])> = None;
|
||||
let (cx0, cz0) = ((center[0] - RANGE) >> 4, (center[2] - RANGE) >> 4);
|
||||
let (cx1, cz1) = ((center[0] + RANGE) >> 4, (center[2] + RANGE) >> 4);
|
||||
for cx in cx0..=cx1 {
|
||||
for cz in cz0..=cz1 {
|
||||
let Some(chunk) = self.world.chunks.get(&(cx, cz)) else {
|
||||
continue;
|
||||
};
|
||||
for (i, section) in chunk.sections.iter().enumerate() {
|
||||
let mut rod = |state: u32| {
|
||||
*is_rod.entry(state).or_insert_with(|| {
|
||||
facts
|
||||
.get(horizon_world::registry::BlockStateId(state))
|
||||
.name
|
||||
.ends_with("lightning_rod")
|
||||
})
|
||||
};
|
||||
if !section.blocks.maybe_has(&mut rod) {
|
||||
continue;
|
||||
}
|
||||
let base_y = horizon_client::chunk::MIN_Y + 16 * i as i32;
|
||||
for j in 0..4096 {
|
||||
if !rod(section.get(j)) {
|
||||
continue;
|
||||
}
|
||||
let p = [
|
||||
cx * 16 + (j & 15) as i32,
|
||||
base_y + (j >> 8) as i32,
|
||||
cz * 16 + ((j >> 4) & 15) as i32,
|
||||
];
|
||||
let d = [p[0] - center[0], p[1] - center[1], p[2] - center[2]]
|
||||
.map(|d| i64::from(d) * i64::from(d))
|
||||
.iter()
|
||||
.sum::<i64>();
|
||||
if d > i64::from(RANGE * RANGE) || best.is_some_and(|(b, _)| d >= b) {
|
||||
continue;
|
||||
}
|
||||
let surface = self.world.world_surface(p[0], p[2], |s| facts.is_air(s));
|
||||
if p[1] == surface - 1 {
|
||||
best = Some((d, p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, p)| [p[0], p[1] + 1, p[2]])
|
||||
}
|
||||
/// `findLightningTargetAround`: the closest lightning rod, else a living
|
||||
/// entity under open sky in the column (3 blocks around), else the top of
|
||||
/// the column.
|
||||
fn lightning_target(&mut self, x: i32, z: i32) -> Option<[i32; 3]> {
|
||||
if !self.world.loaded(x, z) {
|
||||
return None;
|
||||
}
|
||||
let top =
|
||||
self.with_level(|b, level, _| b.motion_blocking_top(level, BlockPos::new(x, 0, z)));
|
||||
if let Some(rod) = self.find_lightning_rod([x, top.y, z]) {
|
||||
return Some(rod);
|
||||
}
|
||||
let sees_sky = |g: &Self, p: [i32; 3]| g.world.light(p[0], p[1], p[2]) >> 4 == 15;
|
||||
let mut candidates: Vec<[i32; 3]> = self
|
||||
.mobs
|
||||
@@ -162,11 +222,9 @@ impl Game<'_> {
|
||||
converted: Vec::new(),
|
||||
});
|
||||
}
|
||||
/// `spawnFire`: fire at the strike point and `extra` random neighbours.
|
||||
/// `spawnFire`: fire at the strike point and `extra` random neighbours,
|
||||
/// wherever `BaseFireBlock.getState` fire can survive.
|
||||
fn bolt_fire(&mut self, at: [f64; 3], extra: i32) {
|
||||
if self.ui.difficulty < 2 {
|
||||
return;
|
||||
}
|
||||
let base = [
|
||||
at[0].floor() as i32,
|
||||
at[1].floor() as i32,
|
||||
@@ -183,18 +241,13 @@ impl Game<'_> {
|
||||
if !self.res.facts().is_air(state) {
|
||||
continue;
|
||||
}
|
||||
// BaseFireBlock.getState: soul fire on soul soil and sand.
|
||||
let below = self.res.facts().get(self.world.block(p[0], p[1] - 1, p[2]));
|
||||
let name = if matches!(&*below.name, "minecraft:soul_soil" | "minecraft:soul_sand") {
|
||||
"minecraft:soul_fire"
|
||||
} else {
|
||||
"minecraft:fire"
|
||||
};
|
||||
// FireBlock.canSurvive: a sturdy floor (flammable neighbours aside).
|
||||
if below.sturdy_full >> 1 & 1 == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(fire) = self.pack.assets.registry.default_state(name) {
|
||||
// BaseFireBlock.getState and FireBlock/SoulFireBlock.canSurvive.
|
||||
let at = BlockPos::new(p[0], p[1], p[2]);
|
||||
let fire = self.with_level(|b, level, ticks| {
|
||||
let fire = b.fire_state_for(level, ticks, at);
|
||||
b.can_survive(level, ticks, at, fire).then_some(fire)
|
||||
});
|
||||
if let Some(fire) = fire {
|
||||
let _ = self.level_set(p, fire);
|
||||
}
|
||||
}
|
||||
@@ -219,7 +272,10 @@ impl Game<'_> {
|
||||
pitch,
|
||||
);
|
||||
if !b.visual_only {
|
||||
self.bolt_fire(b.at, 4);
|
||||
// Only Normal and Hard start fires with the strike itself.
|
||||
if self.ui.difficulty >= 2 {
|
||||
self.bolt_fire(b.at, 4);
|
||||
}
|
||||
self.strike_rod(b.at);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const KNOCKBACK_POWER: f32 = 0.7;
|
||||
/// `Entity.isAlliedTo` between the player (`player` its UUID) and a mob
|
||||
/// without teams: `TamableAnimal.considersEntityAsAlly` (a pet it tamed) and
|
||||
/// `Allay.considersEntityAsAlly` (an allay that likes it).
|
||||
fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool {
|
||||
pub(super) fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool {
|
||||
let tamable = matches!(
|
||||
m.species(),
|
||||
Species::Wolf
|
||||
|
||||
@@ -159,8 +159,10 @@ impl Containers<'_> {
|
||||
});
|
||||
}
|
||||
let entity = self.world.block_entities.get(&pos)?;
|
||||
// A beehive keeps bees, not items: it is no `Container`.
|
||||
if matches!(entity, BlockEntity::Beehive(_)) {
|
||||
// A beehive keeps bees, not items, and a campfire cooks its own: neither
|
||||
// is a `Container` (HopperBlockEntity.getContainerAt), so a dropper drops
|
||||
// in front of them and a hopper leaves them alone.
|
||||
if matches!(entity, BlockEntity::Beehive(_) | BlockEntity::Campfire(_)) {
|
||||
return None;
|
||||
}
|
||||
let kind = match (entity, name) {
|
||||
|
||||
@@ -87,6 +87,9 @@ pub(super) struct Minecart {
|
||||
pub fuse: i32,
|
||||
/// Hopper minecarts: an unpowered activator rail enables them.
|
||||
pub enabled: bool,
|
||||
/// Chest minecarts: the loot table rolled into `items` on load, saved
|
||||
/// again while the items are still that roll.
|
||||
pub loot: Option<horizon_client::save::RolledLoot>,
|
||||
}
|
||||
pub(super) const WIDTH: f64 = 0.98;
|
||||
pub(super) const HEIGHT: f64 = 0.7;
|
||||
@@ -182,6 +185,7 @@ impl Minecart {
|
||||
push: [0.0; 2],
|
||||
fuse: -1,
|
||||
enabled: true,
|
||||
loot: None,
|
||||
}
|
||||
}
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
@@ -1527,6 +1531,12 @@ impl Minecart {
|
||||
}
|
||||
CartKind::Rideable => {}
|
||||
}
|
||||
// A loaded loot table goes back while the items are still its roll;
|
||||
// otherwise the items replace it, as vanilla's unpackLootTable does.
|
||||
if !self.loot.as_ref().is_some_and(|l| l.restore(map)) {
|
||||
map.remove(&JavaString::from("LootTable"));
|
||||
map.remove(&JavaString::from("LootTableSeed"));
|
||||
}
|
||||
}
|
||||
tag
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Blocks mobs carry: block items in a hand (`ItemInHandLayer`) and the
|
||||
//! block an enderman holds (`CarriedBlockLayer`), drawn as shaded cubes like
|
||||
//! dropped block items.
|
||||
//! dropped block items. Also the block models mobs wear (`SnowGolemHeadLayer`,
|
||||
//! `MushroomCowMushroomLayer`).
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
@@ -218,6 +219,7 @@ impl Game<'_> {
|
||||
) {
|
||||
self.captain_banner_geometry(mob, camera, alpha, light, out);
|
||||
self.held_item_geometry(mob, camera, alpha, light, true, out);
|
||||
self.worn_block_geometry(mob, camera, alpha, light, out);
|
||||
if mob.species() == Species::Enderman
|
||||
&& let Some(state) = mob.extra.carried
|
||||
&& let Some((sprite, tint)) = self.block_face_sprite(state)
|
||||
@@ -244,6 +246,76 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `SnowGolemHeadLayer` and `MushroomCowMushroomLayer`: the carved pumpkin
|
||||
/// on a snow golem's head, and the mushrooms on an adult mooshroom's back
|
||||
/// and head, as baked block models.
|
||||
fn worn_block_geometry(
|
||||
&self,
|
||||
mob: &Mob,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
use glam::{Mat4, Vec3};
|
||||
let species = mob.species();
|
||||
let on_head = match species {
|
||||
Species::SnowGolem => !mob.extra.sheared,
|
||||
Species::Mooshroom => !mob.is_baby(),
|
||||
_ => false,
|
||||
};
|
||||
// The layers draw nothing on an invisible mob (the glow outline is not drawn).
|
||||
if !on_head || horizon_client::mobs::trader::is_invisible(mob) {
|
||||
return;
|
||||
}
|
||||
let registry = &self.pack.assets.registry;
|
||||
let block = match species {
|
||||
Species::SnowGolem => "minecraft:carved_pumpkin",
|
||||
_ if mob.extra.variant == 1 => "minecraft:brown_mushroom",
|
||||
_ => "minecraft:red_mushroom",
|
||||
};
|
||||
let Some(state) = registry.default_state(block) else {
|
||||
return;
|
||||
};
|
||||
let alpha = alpha as f32;
|
||||
let Some(head) = render::part_transform(mob, alpha, camera, self.res.mob_data(), "head")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let unit = Mat4::from_translation(Vec3::splat(-0.5));
|
||||
let flip = Mat4::from_scale(Vec3::new(-1.0, -1.0, 1.0));
|
||||
let at = |x: f32, y: f32, z: f32| Mat4::from_translation(Vec3::new(x, y, z));
|
||||
let spin = |degrees: f32| Mat4::from_rotation_y(degrees.to_radians());
|
||||
let transforms = if species == Species::SnowGolem {
|
||||
vec![
|
||||
head * at(0.0, -0.34375, 0.0)
|
||||
* spin(180.0)
|
||||
* Mat4::from_scale(Vec3::new(0.625, -0.625, -0.625))
|
||||
* unit,
|
||||
]
|
||||
} else {
|
||||
let base = render::base_transform(mob, alpha, camera);
|
||||
vec![
|
||||
base * at(0.2, -0.35, 0.5) * spin(-48.0) * flip * unit,
|
||||
base * at(0.2, -0.35, 0.5)
|
||||
* spin(42.0)
|
||||
* at(0.1, 0.0, -0.6)
|
||||
* spin(-48.0)
|
||||
* flip
|
||||
* unit,
|
||||
head * at(0.0, -0.7, -0.2) * spin(-78.0) * flip * unit,
|
||||
]
|
||||
};
|
||||
// The hurt tint reaches these blocks as it does the body.
|
||||
let overlay = horizon_client::overlay::living(0.0, mob.hurt_time > 0 || mob.death_time > 0);
|
||||
for t in transforms {
|
||||
let start = out.len();
|
||||
self.block_model_vertices(state, 0, t, light, true, out);
|
||||
for vertex in &mut out[start..] {
|
||||
vertex.layer = overlay;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ItemInHandLayer`: both hands through the item model pipeline; `lit`
|
||||
/// applies the level's diffuse lighting (off for GUI previews, which
|
||||
/// light the projected quads themselves).
|
||||
|
||||
Loaded 100 of 337 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user