mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
424
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8674aa784d | ||
|
|
33e35d37c0 | ||
|
|
f08a1016fd | ||
|
|
e247790c83 | ||
|
|
ceb12718cb | ||
|
|
f74d2fd5f4 | ||
|
|
8a4ebcc553 | ||
|
|
9ff9055b4f | ||
|
|
0495d3cbdd | ||
|
|
4d7f30f145 | ||
|
|
b65e082fd7 | ||
|
|
036e6bd8d6 | ||
|
|
80d6252734 | ||
|
|
f7144ccd19 | ||
|
|
a74d6e49e6 | ||
|
|
ea426bd53d | ||
|
|
ab2bc22515 | ||
|
|
ce3284a516 | ||
|
|
9137279e10 | ||
|
|
55034fcce1 | ||
|
|
9a8003df0b | ||
|
|
d759a89cf0 | ||
|
|
1960dc6eb2 | ||
|
|
7c42cc63ce | ||
|
|
79ee670d79 | ||
|
|
22ad65611e | ||
|
|
172ae241f1 | ||
|
|
2eb699228a | ||
|
|
1487a1bff7 | ||
|
|
60bad91a50 | ||
|
|
3960c09159 | ||
|
|
5ca16b97fc | ||
|
|
eeb4cdb680 | ||
|
|
2439475716 | ||
|
|
1959c397a0 | ||
|
|
7cc47e2b55 | ||
|
|
c9c53629d4 | ||
|
|
6fe88978a7 | ||
|
|
d9591047fa | ||
|
|
fe3a3af568 | ||
|
|
ac54227465 | ||
|
|
4a61134edd | ||
|
|
39ae543796 | ||
|
|
963d5cedd3 | ||
|
|
fe7bb3a34b | ||
|
|
eafe97da74 | ||
|
|
d95932e62c | ||
|
|
660407fa58 | ||
|
|
6be7daebc8 | ||
|
|
2639e9e1d7 | ||
|
|
1d81c67b7b | ||
|
|
9c09d2897d | ||
|
|
173a55e608 | ||
|
|
14be6ca394 | ||
|
|
9fcf91e4bc | ||
|
|
630f521072 | ||
|
|
0e65e85878 | ||
|
|
62a6b3a620 | ||
|
|
6049012b52 | ||
|
|
4aa1f8e14a | ||
|
|
b7f4cba355 | ||
|
|
13772b4e5b | ||
|
|
c5fe5d4bcf | ||
|
|
0d8249e124 | ||
|
|
c35ea9568f | ||
|
|
f88fba8d05 | ||
|
|
e325fa6587 | ||
|
|
2423e9da00 | ||
|
|
cb81f645da | ||
|
|
7e5e469783 | ||
|
|
4680a73f51 | ||
|
|
e0cb831dbf | ||
|
|
6b35c35ebf | ||
|
|
48863fcf84 | ||
|
|
982f7f12be | ||
|
|
12f2ed9ff7 | ||
|
|
10db017c7c | ||
|
|
e6acebc23d | ||
|
|
69df5671e7 | ||
|
|
06b4c7faf5 | ||
|
|
d522d8fe50 | ||
|
|
55e7084890 | ||
|
|
cffa98d509 | ||
|
|
9df27670f2 | ||
|
|
83c6d78013 | ||
|
|
83db7e3c41 | ||
|
|
39438efd76 | ||
|
|
0e36cb4d7a | ||
|
|
4dff61b697 | ||
|
|
13f98233ea | ||
|
|
5c9f11b954 | ||
|
|
c8f00dcf1e | ||
|
|
3d36883f52 | ||
|
|
e84f86a813 | ||
|
|
48c1b04cb0 | ||
|
|
10f6b183e3 | ||
|
|
f949f08488 | ||
|
|
144741b7c7 | ||
|
|
5bb492ca54 | ||
|
|
15616aa115 | ||
|
|
910f59bf0e | ||
|
|
247a33d889 | ||
|
|
a1597c041f | ||
|
|
1a4e783a04 | ||
|
|
91466d160c | ||
|
|
ce04baf1ba | ||
|
|
b75370cf26 | ||
|
|
feeefb3a33 | ||
|
|
65bd2d0d45 | ||
|
|
5c4600e403 | ||
|
|
2898b40670 | ||
|
|
ed28e1dd0d | ||
|
|
7acebbae45 | ||
|
|
0c133101b6 | ||
|
|
9c6b35d4cb | ||
|
|
357dceb999 | ||
|
|
aec75bbd28 | ||
|
|
8b6d49c4b4 | ||
|
|
03c4d2c683 | ||
|
|
891b122783 | ||
|
|
06b754db4a | ||
|
|
1c18ac6087 | ||
|
|
d6f8dda823 | ||
|
|
0e81f178ec | ||
|
|
d4c37adb0b | ||
|
|
8120559415 | ||
|
|
ba85348907 | ||
|
|
f73dc03fee | ||
|
|
0623d41954 | ||
|
|
f642105e84 | ||
|
|
820dfe1819 | ||
|
|
15b2007f54 | ||
|
|
d42c246122 | ||
|
|
9e1401cc92 | ||
|
|
f6d82abb66 | ||
|
|
850354ca28 | ||
|
|
4fe9d19ca3 | ||
|
|
213cffcf3e | ||
|
|
0a450d2963 | ||
|
|
7db6ce51a6 | ||
|
|
768f149190 | ||
|
|
75d818fad6 | ||
|
|
3bfb345714 | ||
|
|
51be242af0 | ||
|
|
ef792ec3b9 | ||
|
|
684ec5357e | ||
|
|
b62a866f39 | ||
|
|
a6e3fca1dd | ||
|
|
bcbf860aaa | ||
|
|
b117930ae4 | ||
|
|
a57efd4fa0 | ||
|
|
7eeb7ebbc2 | ||
|
|
3f58e512e0 | ||
|
|
602e86e093 | ||
|
|
8b0152bd11 | ||
|
|
1c7656ee9b | ||
|
|
91f42fb633 | ||
|
|
828398b5ec | ||
|
|
e6237aa1da | ||
|
|
f582eae9f4 | ||
|
|
e4fb8f0f3f | ||
|
|
b6a018bf87 | ||
|
|
bdbe59155d | ||
|
|
43a6fe0f7d | ||
|
|
92b0ee1347 | ||
|
|
91dc5b0f18 | ||
|
|
014fc57b97 | ||
|
|
a9ccd5458c | ||
|
|
3e99730b98 | ||
|
|
f7b6777af6 | ||
|
|
00b438d0ce | ||
|
|
990c609269 | ||
|
|
9cfed309ac | ||
|
|
0ff735b664 | ||
|
|
2b088da069 | ||
|
|
5472d4c668 | ||
|
|
5e55ebd2a7 | ||
|
|
181c91d1b8 | ||
|
|
906d02fff3 | ||
|
|
900bba1269 | ||
|
|
9e11394600 | ||
|
|
901a5b8207 | ||
|
|
ed76af1c11 | ||
|
|
8500adab0c | ||
|
|
8b52929723 | ||
|
|
a4e68e6a53 | ||
|
|
4eb7a98330 | ||
|
|
27bf66b354 | ||
|
|
b3e9d8ebd1 | ||
|
|
baf013dd3f | ||
|
|
e310a5ca5e | ||
|
|
c7f6903144 | ||
|
|
05497ddd10 | ||
|
|
038e52f87e | ||
|
|
69887b87d1 | ||
|
|
4786d56d5b | ||
|
|
a5d68dc890 | ||
|
|
9ae041ccd3 | ||
|
|
63507e7c26 | ||
|
|
52aa397b26 | ||
|
|
18078e058d | ||
|
|
33dcc66379 | ||
|
|
525129fbe6 | ||
|
|
18bf8cd2ef | ||
|
|
bf274d9254 | ||
|
|
07e7179773 | ||
|
|
a161487994 | ||
|
|
8c2f683e0a | ||
|
|
190faa4f55 | ||
|
|
725962b858 | ||
|
|
9f54087a86 | ||
|
|
36aa1ec0a2 | ||
|
|
c8d8a35273 | ||
|
|
65fbde0f15 | ||
|
|
54e9379bc1 | ||
|
|
5a2d55c863 | ||
|
|
b6b6eb9a87 | ||
|
|
6bd80fe817 | ||
|
|
3ce3fe0374 | ||
|
|
8ddb74031c | ||
|
|
7f7a05cbd0 | ||
|
|
e5fbba281c | ||
|
|
63d59b26cf | ||
|
|
72b749cdb0 | ||
|
|
1a8dae9f21 | ||
|
|
5d77ace9a4 | ||
|
|
afd3874482 | ||
|
|
16b7e60074 | ||
|
|
83fa14e51b | ||
|
|
7a0bdbc05e | ||
|
|
67277d4571 | ||
|
|
d3bad12383 | ||
|
|
a9682c5554 | ||
|
|
663b11d8dd | ||
|
|
4779b48d2b | ||
|
|
01f1a0360d | ||
|
|
dcb3749ed1 | ||
|
|
96a031792b | ||
|
|
08a73694ab | ||
|
|
fb7665d88e | ||
|
|
9e1c3ec860 | ||
|
|
be16401f90 | ||
|
|
7eded537d6 | ||
|
|
fd9e6a7e78 | ||
|
|
aae60c16ba | ||
|
|
4e04e97565 | ||
|
|
fc8643b380 | ||
|
|
954bb6a1fa | ||
|
|
042adc674a | ||
|
|
91d89b4abf | ||
|
|
cd494e74ba | ||
|
|
f1f8170c58 | ||
|
|
700c9ec8cd | ||
|
|
60fc787288 | ||
|
|
35ffbebcdd | ||
|
|
1ec2762fe6 | ||
|
|
2f8d913b85 | ||
|
|
6c4309d36f | ||
|
|
95d0a06ba5 | ||
|
|
90afc3dbf4 | ||
|
|
cdaf1d79a0 | ||
|
|
d68ca84275 | ||
|
|
270ba3fea9 | ||
|
|
4f4dc33370 | ||
|
|
59adff16ff | ||
|
|
160076e15f | ||
|
|
658d54e578 | ||
|
|
3576a7a1c0 | ||
|
|
0ed1bd8deb | ||
|
|
f254871c1f | ||
|
|
9f687dd1ec | ||
|
|
dc6b648f69 | ||
|
|
36a5696abf | ||
|
|
48d6211781 | ||
|
|
1ef5f8fae5 | ||
|
|
0928c83d4c | ||
|
|
724f42355f | ||
|
|
8b16f76b3c | ||
|
|
70c06224fe | ||
|
|
c0fd8dabfd | ||
|
|
da991045f9 | ||
|
|
f926d1d2b1 | ||
|
|
b1dc23551b | ||
|
|
f53998674d | ||
|
|
b8f79247b8 | ||
|
|
a97d8098c7 | ||
|
|
d67779d5a1 | ||
|
|
e7d42dc9d0 | ||
|
|
ecbb0368c5 | ||
|
|
15b8514584 | ||
|
|
6e83037fca | ||
|
|
6d36d468e7 | ||
|
|
d86a1b2882 | ||
|
|
ca2d5e04dc | ||
|
|
b054c8db11 | ||
|
|
fba42920e4 | ||
|
|
a3f81f3a30 | ||
|
|
240e76160b | ||
|
|
c9c7cda779 | ||
|
|
3530560581 | ||
|
|
4a350bb0a0 | ||
|
|
da996b9fd1 | ||
|
|
022600481e | ||
|
|
abb7a97136 | ||
|
|
fa14833b41 | ||
|
|
e4ea9e0199 | ||
|
|
4ad6bb18ef | ||
|
|
93bde90aaf | ||
|
|
92b8cd692d | ||
|
|
05400438c7 | ||
|
|
5b44452284 | ||
|
|
34c3c1cffe | ||
|
|
756cf4af07 | ||
|
|
0eaa13020c | ||
|
|
5eb224a909 | ||
|
|
15b6313292 | ||
|
|
a648622b92 | ||
|
|
97edfec914 | ||
|
|
ca125cb028 | ||
|
|
fe5e20507a | ||
|
|
41e6c3eca3 | ||
|
|
92d2e2b86d | ||
|
|
ad676af5eb | ||
|
|
9f6d781782 | ||
|
|
a20ebde7ab | ||
|
|
71edc9d41c | ||
|
|
d01327862d | ||
|
|
c139387e2a | ||
|
|
a5e0645610 | ||
|
|
399382084a | ||
|
|
753c071c37 | ||
|
|
5d82b4c999 | ||
|
|
4e46f2a57a | ||
|
|
e01296f99b | ||
|
|
ac60b337aa | ||
|
|
a49cf5502d | ||
|
|
2ced9a451d | ||
|
|
86fdc72069 | ||
|
|
9fe228cd1e | ||
|
|
84171cfc94 | ||
|
|
bd36508c29 | ||
|
|
b88218d019 | ||
|
|
a069c2716f | ||
|
|
d5972490a5 | ||
|
|
54f01d6bb1 | ||
|
|
44e289f919 | ||
|
|
b197682dc8 | ||
|
|
86e08072e4 | ||
|
|
c4a12964dc | ||
|
|
41055168af | ||
|
|
55eabf4b20 | ||
|
|
4332014673 | ||
|
|
f787bc9b75 | ||
|
|
1a3fb08d68 | ||
|
|
2ae6ae2aeb | ||
|
|
90ff954f23 | ||
|
|
bdad451ab4 | ||
|
|
ba1e7e23cf | ||
|
|
ec9d62858a | ||
|
|
631160263f | ||
|
|
badc91f94b | ||
|
|
554932e9fd | ||
|
|
98c77f6fc7 | ||
|
|
6323b4a517 | ||
|
|
82122f4970 | ||
|
|
8859d7a3a3 | ||
|
|
d02b66fd9a | ||
|
|
aa65c74e0f | ||
|
|
ea5994a58a | ||
|
|
7e410d93b3 | ||
|
|
4e45a3bddf | ||
|
|
9d58d6f525 | ||
|
|
38276fbc30 | ||
|
|
c77863f163 | ||
|
|
938a25a712 | ||
|
|
21baf0dc99 | ||
|
|
8a03758c3a | ||
|
|
de8cb0dfa6 | ||
|
|
41a4ce4edb | ||
|
|
0ae1ab2e70 | ||
|
|
3aa36f95ab | ||
|
|
84c7f39148 | ||
|
|
388f59aff4 | ||
|
|
d0330061bd | ||
|
|
5bad717213 | ||
|
|
dd1525f045 | ||
|
|
14bd239141 | ||
|
|
9054acfed2 | ||
|
|
770809908d | ||
|
|
83d001460c | ||
|
|
8f738d35ec | ||
|
|
6f1f83d07c | ||
|
|
743cc8e069 | ||
|
|
8291c8af5b | ||
|
|
e6b92eb4ee | ||
|
|
2f3f1377a3 | ||
|
|
dbb1ca0907 | ||
|
|
ca5404e52d | ||
|
|
b5a92262c8 | ||
|
|
3e868683ba | ||
|
|
4fecf43d28 | ||
|
|
c8f02d91b7 | ||
|
|
cd37ea98a0 | ||
|
|
255f1960ee | ||
|
|
30a2cc7c61 | ||
|
|
bc9e5afacc | ||
|
|
16738c9f26 | ||
|
|
e70b520e5d | ||
|
|
c2b7f3a4ea | ||
|
|
8e700aa683 | ||
|
|
dfe795bc09 | ||
|
|
9019d708d0 | ||
|
|
186777110f | ||
|
|
cb0bbe7d19 | ||
|
|
863a309161 | ||
|
|
3917f35e3e | ||
|
|
a893fa6125 | ||
|
|
3b21a975bb | ||
|
|
67ca65bb3b | ||
|
|
6216b84669 | ||
|
|
2ecbdd57eb | ||
|
|
62874d8dc4 | ||
|
|
da314fa658 | ||
|
|
6127790456 |
No files matched your search
@@ -6,7 +6,6 @@
|
||||
/.github/ @ludovic111
|
||||
/tools/ci/ @ludovic111
|
||||
/tools/release_train.py @ludovic111
|
||||
/tools/release_channels.py @ludovic111
|
||||
/crates/horizon-release-sign/ @ludovic111
|
||||
/crates/horizon-server/src/update.rs @ludovic111
|
||||
/crates/horizon-server/Cargo.toml @ludovic111
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
0.2
|
||||
@@ -18,6 +18,10 @@ concurrency:
|
||||
# A new push to a pull request replaces its running checks; main's runs all finish (releases follow them).
|
||||
group: checks-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -60,9 +64,13 @@ jobs:
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
|
||||
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: cargo +stable test --workspace
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests
|
||||
cargo +stable test --workspace --doc
|
||||
parity:
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: ./.github/workflows/parity.yml
|
||||
|
||||
@@ -49,6 +49,10 @@ concurrency:
|
||||
group: launcher-${{ github.ref == 'refs/heads/main' && 'release' || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
|
||||
jobs:
|
||||
decide:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -188,9 +192,6 @@ jobs:
|
||||
retry "$launcher" install latest
|
||||
"$launcher" versions --installed
|
||||
"$launcher" links --json
|
||||
"$launcher" menu-style classic
|
||||
grep -qx 'menuStyle:classic' "$HORIZON_HOME/horizon.txt"
|
||||
"$launcher" menu-style horizon
|
||||
clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*)
|
||||
client="${clients[0]}"
|
||||
echo "installed client: $client"
|
||||
|
||||
@@ -23,6 +23,10 @@ permissions:
|
||||
concurrency:
|
||||
group: nightly-audit
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: [self-hosted, horizon-game]
|
||||
|
||||
@@ -15,6 +15,10 @@ on:
|
||||
required: true
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
|
||||
jobs:
|
||||
parity:
|
||||
runs-on: [self-hosted, horizon-game]
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
name: promote
|
||||
# Beta game releases become stable (docs/release-flow.md, "Channels"): every 30 minutes tools/release_channels.py
|
||||
# promotes the newest beta that has been out 6 hours, passed its release smoke test and isn't covered by an issue
|
||||
# labelled release-blocker. By hand, from GitHub's app too (Actions > promote > Run workflow):
|
||||
# gh workflow run promote -f tag=v2026.10.19.03 # that beta now, without the wait
|
||||
# gh workflow run promote -f tag=v2026.10.19.03 -f force=true # even blocked or without its smoke test
|
||||
# gh workflow run promote -f dry_run=true # say what would be promoted
|
||||
# Holding betas back: label an issue release-blocker. While it is open it holds every beta; closed as completed it
|
||||
# still holds the betas published before it closed. The issue's text is never read (its author could edit it).
|
||||
# When the oldest waiting beta is 24 hours old, an issue "Promotion to stable has stalled" says so; it closes itself
|
||||
# after the next promotion.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '13,43 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: The beta to promote now (empty = the newest beta that is due)
|
||||
type: string
|
||||
default: ''
|
||||
force:
|
||||
description: Promote that tag even if a release-blocker covers it or its smoke test didn't run
|
||||
type: boolean
|
||||
default: false
|
||||
dry_run:
|
||||
description: Say what would be promoted, change nothing
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
concurrency:
|
||||
group: promote
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The notes of a promoted release cover every pull request since the last stable one.
|
||||
fetch-depth: 0
|
||||
# gh uses GH_TOKEN below; nothing needs git credentials on disk.
|
||||
persist-credentials: false
|
||||
- name: Promote
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
FORCE: ${{ inputs.force && 'yes' || '' }}
|
||||
DRY_RUN: ${{ (inputs.dry_run || github.ref != 'refs/heads/main') && 'yes' || '' }}
|
||||
run: python3 tools/release_channels.py promote ${TAG:+--tag "$TAG"} ${FORCE:+--force} ${DRY_RUN:+--dry-run}
|
||||
@@ -1,14 +1,17 @@
|
||||
name: release
|
||||
# Automatic game releases (docs/release-flow.md). After every green `checks` run on main, every 10 minutes (so a
|
||||
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides:
|
||||
# a release when pull requests merged since the last one changed what players run and the newest merged 15 minutes
|
||||
# ago (the oldest an hour ago, if merges never pause), or at once for a pull request labelled release-now.
|
||||
# RELEASES_PAUSED=1 (repository variable) stops them.
|
||||
# a release when pull requests merged since the last one changed what players run and the newest merged 5 minutes
|
||||
# ago (the oldest 20 minutes ago, if merges never pause), or at once for a pull request labelled release-now.
|
||||
# RELEASES_PAUSED=1 (repository variable) stops them; so does an open issue labelled release-blocker, forced runs too.
|
||||
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
|
||||
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then
|
||||
# publishes vX.Y.Z on the release line in .github/release-line.txt (docs/versioning.md; without it, the dated
|
||||
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as a beta (a pre-release) that promote.yml makes
|
||||
# stable after 6 hours. macOS: with the repository variable MACOS_SIGN_IDENTITY set, the programs are signed with
|
||||
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as the release and GitHub's latest: one
|
||||
# channel, which launchers, `horizon-server update` and install.sh follow at once. So it is published only after the
|
||||
# checks that used to gate a promotion pass in this run: the Mac mini's smoke test drew a world, and no release-blocker
|
||||
# is open (tools/release_train.py gate); otherwise nothing is published. macOS: with the repository variable
|
||||
# MACOS_SIGN_IDENTITY set, the programs are signed with
|
||||
# Developer ID and the hardened runtime and the zip is notarized, from the runner's signing keychain
|
||||
# (packaging/macos/README.md); unset (forks), ad hoc.
|
||||
# gh workflow run release --ref <branch> # dry run: builds and notes, publishes nothing
|
||||
@@ -32,6 +35,8 @@ on:
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
# The open release-blocker issues (decide).
|
||||
issues: read
|
||||
concurrency:
|
||||
group: game-release
|
||||
cancel-in-progress: false
|
||||
@@ -203,7 +208,7 @@ jobs:
|
||||
or not report.get("meshed_sections") or not report.get("player"):
|
||||
sys.exit("the smoke run did not draw a world")
|
||||
EOF
|
||||
# Published with the release: a beta becomes stable only with it (tools/release_channels.py).
|
||||
# Published with the release; the publish job checks it again before it publishes anything.
|
||||
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: runner.environment == 'self-hosted'
|
||||
@@ -319,6 +324,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
issues: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -341,6 +347,17 @@ jobs:
|
||||
run: |
|
||||
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
|
||||
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
|
||||
- name: Ready to publish (the smoke test passed, no release-blocker is open)
|
||||
# Right before publishing: an issue labelled while the builds ran still holds this release. A dry run only
|
||||
# reports what a real run would do.
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DRY_RUN: ${{ needs.decide.outputs.dry_run }}
|
||||
run: |
|
||||
if ! python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json --blockers; then
|
||||
[[ "$DRY_RUN" == true ]] || { echo "::error::Nothing published"; exit 1; }
|
||||
echo "::warning::A real run would publish nothing"
|
||||
fi
|
||||
- name: Publish ${{ needs.decide.outputs.tag }}
|
||||
if: needs.decide.outputs.dry_run != 'true'
|
||||
env:
|
||||
@@ -348,16 +365,15 @@ jobs:
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
smoke=(); [[ -f dist/smoke-test.json ]] && smoke=(dist/smoke-test.json)
|
||||
# A beta (pre-release): promote.yml makes it stable and latest after 6 hours (docs/release-flow.md).
|
||||
# The release and the latest at once: there is one channel (docs/release-flow.md).
|
||||
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
|
||||
--notes-file notes/body.md --prerelease --latest=false dist/horizon-linux-x86_64.tar.gz \
|
||||
--notes-file notes/body.md --latest dist/horizon-linux-x86_64.tar.gz \
|
||||
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz \
|
||||
dist/SHA256SUMS "${signature[@]}" "${smoke[@]}"
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
- name: Dry run, the release that would be published
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
run: |
|
||||
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }}: $(cat notes/title.txt)"
|
||||
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }} as the release and latest: $(cat notes/title.txt)"
|
||||
ls -l dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -11,6 +11,10 @@ permissions:
|
||||
concurrency:
|
||||
group: run-parity-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
|
||||
jobs:
|
||||
parity:
|
||||
if: github.event.label.name == 'run-parity'
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# Horizon: a map for agents
|
||||
|
||||
Minecraft: Java Edition 26.3 rewritten in Rust (wgpu). Vanilla is the specification wherever it has the
|
||||
feature. Read [CONTRIBUTING.md](CONTRIBUTING.md) once; this page tells you where things are.
|
||||
Feature → files → harness for the most-changed areas: [docs/architecture.md](docs/architecture.md).
|
||||
|
||||
## Rules that bite
|
||||
|
||||
- **Never commit Mojang's files**: game jars, assets, decompiled source, prepared resources (`.local/`).
|
||||
`tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit.
|
||||
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
|
||||
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
|
||||
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Never write to
|
||||
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
|
||||
- **Mark what an agent posts.** A PR body starts with
|
||||
`> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.` (issues: "Filed by", reviews:
|
||||
"Review by"); every comment starts with `🤖 Agent:`. Never write as Ludovic.
|
||||
- **Attribution.** Commits end with `Co-Authored-By: Claude <model> <[email protected]>`; PR bodies end
|
||||
with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
||||
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
|
||||
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
|
||||
they would change the vanilla look.
|
||||
- Small PRs (< ~500 lines). Labels: `feature` (players get something new), `skip-release` (they see no
|
||||
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Every merge to main ships
|
||||
([docs/release-flow.md](docs/release-flow.md)).
|
||||
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
|
||||
|
||||
## Crates
|
||||
|
||||
Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-server` are separate workspaces.
|
||||
|
||||
| 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-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` |
|
||||
| `horizon-assets` | Resource decoding: block-state and item models, baking, atlases |
|
||||
| `horizon-prepare` | Builds `.local/horizon-client/26.3` from the player's own Minecraft install (runs the Java exporters) |
|
||||
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
|
||||
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-shaderpack` | OptiFine/Iris packs: preprocessor, GLSL → WGSL ([docs/shader-packs.md](docs/shader-packs.md)) |
|
||||
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
|
||||
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `voxel-kernels`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, occupancy kernels, repo tasks |
|
||||
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
|
||||
|
||||
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
|
||||
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
|
||||
`website/` (playhorizon.dev; its paths are relative to `website/`), `docs/`, `.github/workflows/`.
|
||||
|
||||
## Build and test fast
|
||||
|
||||
```sh
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
python3 tools/parity.py mob_ai sculk # named harnesses
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (host in-frame); --server-thread for the host on its server thread
|
||||
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
```
|
||||
|
||||
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
|
||||
`tools/`): see [docs/parity-workflow.md](docs/parity-workflow.md). Without it, pr_ready lists the
|
||||
harnesses for CI.
|
||||
- A harness = `tools/test_<name>_parity.py` (324 of them) + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`)
|
||||
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
|
||||
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
|
||||
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
|
||||
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
|
||||
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
|
||||
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
|
||||
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude horizon-mods-test-guest`.
|
||||
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
|
||||
then `horizon-ctl --socket … state|keys|turn|command|screenshot|quit`
|
||||
([docs/horizon-ctl-commands.md](docs/horizon-ctl-commands.md)).
|
||||
|
||||
## Vanilla reference
|
||||
|
||||
- Decompiled sources: `.local/minecraft/26.3/sources/{client,server}/` (`python3 tools/lab.py decompile`).
|
||||
- Bytecode queries (callers through lambdas, every overload):
|
||||
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`
|
||||
([docs/decompilation-status.md](docs/decompilation-status.md)).
|
||||
- A harness runs the oracle against the real 26.3 jars and the Rust probe on the same inputs, then compares
|
||||
their JSON. Oracle runs are cached by `tools/parity_shim.py`; `--fresh` reruns them.
|
||||
- Mirror vanilla's `Math` calls with `horizon_core::jmath`, and vanilla's RNG with `horizon_core::random`:
|
||||
random draws must happen in vanilla's order.
|
||||
|
||||
## horizon-client: where things live
|
||||
|
||||
`L:` = lib (`crates/horizon-client/src/`), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
|
||||
`Game`; bin code reaches the lib as `horizon_client::<module>`.
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `window.rs`, `spans.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `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` |
|
||||
| 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/}` |
|
||||
| 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` |
|
||||
| Shader packs | L: `shader_pack.rs` (Horizon WGSL packs), `java_pack.rs` + crate `horizon-shaderpack` (Iris/OptiFine), `gfx/pack_stages.rs`, `gfx/java_stages.rs`; G: `shader_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` |
|
||||
|
||||
## How `Game` is organised
|
||||
|
||||
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
|
||||
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
|
||||
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
|
||||
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
|
||||
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
|
||||
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
|
||||
single player on its server thread), `lan` (`LanHost`: this world is open to the network), `integrated`
|
||||
(the window's own server thread), `serving` (this `Game` *is* that server thread), `options.dedicated`.
|
||||
`fn tick` returns `tick_remote()` when `remote` is set; nothing else of the local simulation runs.
|
||||
- A server's players run through the same gameplay code as the local player: `as_guest` swaps the guest's
|
||||
state (position, inventory, mode, mining, item use) into `Game` for one call (`game/lan_actions.rs`), and
|
||||
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
|
||||
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
|
||||
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
|
||||
(`pack`), both in game.rs.
|
||||
|
||||
## The two single-player paths
|
||||
|
||||
1. **In-frame (default since #1428):** the window's `Game` ticks the world itself, one level that follows
|
||||
the player (`fn enter_dimension`). This is what Stable players run and what harnesses test.
|
||||
2. **Server thread (`--server-thread` or `HORIZON_SERVER_THREAD=1`):** `game/integrated.rs` runs a second
|
||||
`Game` (`serving`) on a "Server thread" with one level per dimension; the window joins it as a client
|
||||
through memory (`remote`), exactly as a LAN guest or a dedicated server's player does. Not at parity yet:
|
||||
open `[server-thread]` issues list the gaps.
|
||||
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
|
||||
when you touch player-facing gameplay:
|
||||
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # in-frame
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --server-thread # server thread
|
||||
python3 tools/lan_e2e.py --check <name> # host in-frame, guest is a server client
|
||||
python3 tools/lan_e2e.py --server-thread --check <name> # host on its server thread (portals, levels, distance, lava need it)
|
||||
python3 tools/parity.py dedicated_server server_entities server_player_damage
|
||||
```
|
||||
|
||||
## Docs worth knowing
|
||||
|
||||
[docs/parity-workflow.md](docs/parity-workflow.md) (runner, gate, shards, scopes) ·
|
||||
[docs/native-client.md](docs/native-client.md) (options, controls, horizon-ctl) ·
|
||||
[docs/server-levels.md](docs/server-levels.md) (levels per dimension) ·
|
||||
[docs/rust-parity.md](docs/rust-parity.md) (what is ported) · [docs/known-issues.md](docs/known-issues.md) ·
|
||||
[docs/release-flow.md](docs/release-flow.md) · [docs/README.md](docs/README.md) (index).
|
||||
Generated
+1
@@ -1395,6 +1395,7 @@ dependencies = [
|
||||
name = "horizon-bench"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-telemetry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -46,7 +46,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs, shader packs and data packs:** resource packs work, Java shader packs run their full-screen effects (experimental), and data packs load functions, predicates and item modifiers.
|
||||
- **Playing with others:** invite friends from the pause menu with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
|
||||
@@ -56,8 +56,8 @@ Screenshots, captured in the Rust client itself with vanilla assets and no mods
|
||||
<td colspan="2"><img src="docs/media/sunset.webp" alt="Sunset over a savanna river valley and a plains village"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/menu-title.webp" alt="Horizon's title screen over a veiled snowy landscape"></td>
|
||||
<td width="50%"><img src="docs/media/settings.webp" alt="The Video settings in their Simple view: a preset, render distance, field of view, framerate, VSync and GUI scale, each with what it costs"></td>
|
||||
<td width="50%"><img src="docs/media/survival.webp" alt="Survival at dusk: a zombie walks up, with the hotbar, health, hunger and experience"></td>
|
||||
<td width="50%"><img src="docs/media/settings.webp" alt="Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics, Advanced Graphics and Shader Packs"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/village.webp" alt="A plains village among savanna hills, with farms, villagers and an iron golem"></td>
|
||||
|
||||
@@ -36,3 +36,15 @@ SOFTWARE.
|
||||
developers, with one upstream fix applied
|
||||
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124)). It is licensed under MIT or
|
||||
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
|
||||
|
||||
## Pixelify Sans
|
||||
|
||||
`crates/horizon-theme/fonts/PixelifySans-*.ttf` is [Pixelify Sans](https://github.com/eifetx/Pixelify-Sans)
|
||||
by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1.1
|
||||
(`crates/horizon-theme/fonts/OFL-PixelifySans.txt`).
|
||||
|
||||
## Pixelarticons
|
||||
|
||||
`crates/horizon-theme/icons/pixel/*.svg` are icons from
|
||||
[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT
|
||||
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`).
|
||||
@@ -8,3 +8,5 @@ description = "The benchmark report format (docs/benchmark-report.md), shared by
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# The hardware probes shared with telemetry (cores, clocks, drive, VRAM, power, form).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
@@ -16,8 +16,22 @@ pub struct Hardware {
|
||||
pub cpu: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_threads: Option<usize>,
|
||||
/// Physical cores.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_cores: Option<u16>,
|
||||
/// Base and top boost clock, where the OS gives them.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_base_mhz: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_boost_mhz: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ram_mb: Option<u64>,
|
||||
/// The drive under the game folder: `nvme`, `ssd` or `hdd`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub storage: Option<String>,
|
||||
/// `laptop` or `desktop`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub form: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gpu: Option<Gpu>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -33,6 +47,9 @@ pub struct Gpu {
|
||||
pub backend: String,
|
||||
/// `integrated`, `discrete`, `virtual`, `cpu`, `other`.
|
||||
pub kind: String,
|
||||
/// Its own memory in MiB (rounded to 256), for a discrete GPU whose driver says.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub vram_mb: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -47,17 +64,50 @@ pub struct Display {
|
||||
|
||||
impl Hardware {
|
||||
pub fn detect() -> Hardware {
|
||||
let probes = probes();
|
||||
Hardware {
|
||||
os: std::env::consts::OS.to_owned(),
|
||||
os_version: os_version(),
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
cpu: cpu(),
|
||||
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
cpu_cores: probes.cores,
|
||||
cpu_base_mhz: probes.base,
|
||||
cpu_boost_mhz: probes.boost,
|
||||
ram_mb: ram_bytes().map(|b| b >> 20),
|
||||
storage: None,
|
||||
form: probes.form,
|
||||
gpu: None,
|
||||
display: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hardware::detect`] with the kind of drive `game_dir` is on.
|
||||
pub fn detect_in(game_dir: &std::path::Path) -> Hardware {
|
||||
Hardware {
|
||||
storage: horizon_telemetry::system::storage(game_dir).map(str::to_owned),
|
||||
..Hardware::detect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the telemetry probes read (`horizon_telemetry::system`).
|
||||
struct Probes {
|
||||
cores: Option<u16>,
|
||||
base: Option<u32>,
|
||||
boost: Option<u32>,
|
||||
form: Option<String>,
|
||||
}
|
||||
|
||||
fn probes() -> Probes {
|
||||
let s = horizon_telemetry::payload::System::detect();
|
||||
let some = |n: u32| (n > 0).then_some(n);
|
||||
Probes {
|
||||
cores: (s.cores > 0).then_some(s.cores),
|
||||
base: some(s.cpu_base_mhz),
|
||||
boost: some(s.cpu_boost_mhz),
|
||||
form: (!s.form.is_empty()).then_some(s.form),
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-minute load average: above about the number of threads the machine was busy.
|
||||
@@ -82,25 +132,7 @@ pub fn load_average() -> Option<f64> {
|
||||
|
||||
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
|
||||
pub fn power_source() -> Option<&'static str> {
|
||||
if cfg!(target_os = "macos") {
|
||||
let batt = output("pmset", &["-g", "batt"])?;
|
||||
return Some(if batt.contains("'Battery Power'") {
|
||||
"battery"
|
||||
} else {
|
||||
"ac"
|
||||
});
|
||||
}
|
||||
let mut on_battery = None;
|
||||
for entry in std::fs::read_dir("/sys/class/power_supply").ok()?.flatten() {
|
||||
let read =
|
||||
|name: &str| std::fs::read_to_string(entry.path().join(name)).unwrap_or_default();
|
||||
match read("type").trim() {
|
||||
"Mains" if read("online").trim() == "1" => return Some("ac"),
|
||||
"Battery" => on_battery = Some("battery"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
on_battery
|
||||
horizon_telemetry::system::power()
|
||||
}
|
||||
|
||||
/// Resident memory of a process, in bytes (macOS, Linux).
|
||||
|
||||
@@ -30,7 +30,9 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
# Shader packs: pack.toml, and WGSL checked before it reaches wgpu.
|
||||
toml = "1.1"
|
||||
naga = { version = "30.0.1", features = ["wgsl-in"] }
|
||||
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
|
||||
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
# Java (OptiFine/Iris) shader packs, translated to WGSL.
|
||||
horizon-shaderpack = { path = "../horizon-shaderpack" }
|
||||
# Sound (ALSA on Linux); the `audio` feature.
|
||||
@@ -42,8 +44,9 @@ log = "0.4"
|
||||
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
|
||||
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
|
||||
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
@@ -80,7 +83,3 @@ rng-trace = ["horizon-core/rng-trace"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
@@ -82,12 +82,19 @@ fn main() -> horizon_client::Result<()> {
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
// The inventory menu never reads banner patterns.
|
||||
let banners = horizon_client::banner::Banners::default();
|
||||
let station_recipes = horizon_client::stations::StationRecipes::default();
|
||||
let enchantments = horizon_client::enchant::Enchantments::default();
|
||||
let names = horizon_client::creative::Names::default();
|
||||
let env = Env {
|
||||
items: &items,
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
station_recipes: &station_recipes,
|
||||
enchantments: &enchantments,
|
||||
names: &names,
|
||||
trade: None,
|
||||
maps: None,
|
||||
};
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Firework tooltip companion for tools/test_firework_tooltip_parity.py: the
|
||||
//! lines `item_tooltip::firework_lines` gives each stack (SNBT, one per line;
|
||||
//! the `#resources` line names the prepared resources for en_us).
|
||||
use horizon_client::{item_data::parse_stack, item_tooltip::firework_lines, lang::Language};
|
||||
use horizon_client::{enchant::Enchantments, item_data::parse_stack, item_tooltip::firework_lines};
|
||||
use std::{error::Error, fmt::Write, path::PathBuf};
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
@@ -12,8 +12,9 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
.find_map(|l| l.strip_prefix("#resources "))
|
||||
.map(PathBuf::from)
|
||||
.ok_or("no #resources line")?;
|
||||
let language = Language::load(&[&root], "en_us");
|
||||
let translate = |key: &str| language.get(key).map(str::to_owned);
|
||||
// Use the inventory's filtered translations so missing tooltip keys are caught.
|
||||
let enchantments = Enchantments::load(&root);
|
||||
let translate = |key: &str| enchantments.translate(key).map(str::to_owned);
|
||||
let mut output = String::new();
|
||||
for line in input.lines().filter(|l| !l.starts_with('#')) {
|
||||
let stack = parse_stack(line)?.ok_or("empty stack")?;
|
||||
|
||||
@@ -14,13 +14,22 @@ fn main() {
|
||||
for count in [0, 1, 4, 64] {
|
||||
let mut slot = Some(ItemStack::new("minecraft:stone", count));
|
||||
let mut dropped = 0;
|
||||
let mut swings = 0;
|
||||
let mut packets = 0;
|
||||
for _ in 0..2 {
|
||||
if let Some(stack) =
|
||||
take_held_drop(&mut slot, all, mode == GameMode::Spectator)
|
||||
{
|
||||
dropped += stack.count;
|
||||
swings += 1;
|
||||
}
|
||||
println!("{} {}", slot.as_ref().map_or(0, |s| s.count), dropped);
|
||||
if mode != GameMode::Spectator {
|
||||
packets += 1;
|
||||
}
|
||||
println!(
|
||||
"{} {dropped} {swings} {packets}",
|
||||
slot.as_ref().map_or(0, |s| s.count),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tick_frozen: false,
|
||||
tick_steps: (0, 0),
|
||||
collision: None,
|
||||
border: &horizon_client::worldborder::WorldBorder::default(),
|
||||
};
|
||||
for event in hub.tick(&mut ctx) {
|
||||
match event {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Read-only saved chunk benchmark. All timings exclude resource/corpus setup.
|
||||
//!
|
||||
//! load_bench RESOURCES DIMENSION_DIR [--rounds N] [--limit N] [--checksum]
|
||||
//! Add --profile-worker when a sampling collector cannot sample the main thread.
|
||||
//! Reports warm filesystem SaveDir::load (read, inflate, NBT, decode and light),
|
||||
//! plus isolated inflate/NBT and native decode. No files in the save are changed.
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
save::{SaveDir, SaveTables, decode_with},
|
||||
};
|
||||
use horizon_world::{nbt::Limits, region::RegionFile};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{hint::black_box, path::PathBuf, sync::Arc, time::Instant};
|
||||
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
if std::env::args().any(|a| a == "--profile-worker") {
|
||||
std::thread::spawn(|| run().map_err(|e| e.to_string()))
|
||||
.join()
|
||||
.expect("benchmark worker")
|
||||
.map_err(Into::into)
|
||||
} else {
|
||||
run()
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let usage = "load_bench RESOURCES DIMENSION_DIR [--rounds N] [--limit N] [--checksum]";
|
||||
let root = PathBuf::from(args.first().ok_or(usage)?);
|
||||
let dimension = PathBuf::from(args.get(1).ok_or(usage)?);
|
||||
let flag = |name: &str, default: usize| -> Result<usize> {
|
||||
Ok(match args.iter().position(|a| a == name) {
|
||||
Some(i) => args.get(i + 1).ok_or(usage)?.parse()?,
|
||||
None => default,
|
||||
})
|
||||
};
|
||||
let rounds = flag("--rounds", 5)?;
|
||||
let limit = flag("--limit", 256)?;
|
||||
if rounds == 0 || limit == 0 {
|
||||
return Err("rounds and limit must be positive".into());
|
||||
}
|
||||
let assets = Assets::load(&root)?;
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let biomes = Biomes::load(&root)?;
|
||||
let tables = Arc::new(SaveTables::new(&assets).with_block_entities(&facts));
|
||||
let source = SaveDir::new(dimension.clone());
|
||||
let _ = source.tables.set(tables.clone());
|
||||
let mut paths: Vec<_> = std::fs::read_dir(dimension.join("region"))?
|
||||
.map(|e| e.map(|e| e.path()))
|
||||
.collect::<std::io::Result<_>>()?;
|
||||
paths.retain(|p| p.extension().is_some_and(|e| e == "mca"));
|
||||
paths.sort();
|
||||
let mut corpus = Vec::new();
|
||||
'regions: for path in paths {
|
||||
let mut region = RegionFile::open(path)?;
|
||||
let mut positions: Vec<_> = region.chunks().map(|(pos, _)| pos).collect();
|
||||
positions.sort_by_key(|p| (p.x, p.z));
|
||||
for pos in positions {
|
||||
let record = region.read(pos)?.ok_or("missing record")?;
|
||||
let tag = record.nbt(Limits::default())?;
|
||||
if decode_with(&tag, assets.registry, &biomes, Some(&tables)).is_none() {
|
||||
continue;
|
||||
}
|
||||
corpus.push((pos, record, tag));
|
||||
if corpus.len() == limit {
|
||||
break 'regions;
|
||||
}
|
||||
}
|
||||
}
|
||||
if corpus.is_empty() {
|
||||
return Err("no full chunks in corpus".into());
|
||||
}
|
||||
let mut records_hash = Sha256::new();
|
||||
for (pos, record, _) in &corpus {
|
||||
records_hash.update(pos.x.to_le_bytes());
|
||||
records_hash.update(pos.z.to_le_bytes());
|
||||
records_hash.update((record.compressed.len() as u64).to_le_bytes());
|
||||
records_hash.update(&record.compressed);
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({"corpus": dimension, "chunks": corpus.len(), "rounds": rounds,
|
||||
"compressed_records_sha256": format!("{:x}", records_hash.finalize()),
|
||||
"compressed_bytes": corpus.iter().map(|(_, r, _)| r.compressed.len()).sum::<usize>()})
|
||||
);
|
||||
// Prime SaveDir's region handles and the filesystem cache before measurement.
|
||||
for (pos, _, _) in &corpus {
|
||||
black_box(
|
||||
source
|
||||
.load(pos.x, pos.z, assets.registry, &biomes)
|
||||
.ok_or("load failed")?,
|
||||
);
|
||||
}
|
||||
for round in 0..rounds {
|
||||
let started = Instant::now();
|
||||
for (pos, _, _) in &corpus {
|
||||
black_box(
|
||||
source
|
||||
.load(pos.x, pos.z, assets.registry, &biomes)
|
||||
.ok_or("load failed")?,
|
||||
);
|
||||
}
|
||||
let load_s = started.elapsed().as_secs_f64();
|
||||
let started = Instant::now();
|
||||
for (_, record, _) in &corpus {
|
||||
black_box(record.nbt(Limits::default())?);
|
||||
}
|
||||
let nbt_s = started.elapsed().as_secs_f64();
|
||||
let started = Instant::now();
|
||||
for (_, _, tag) in &corpus {
|
||||
black_box(
|
||||
decode_with(tag, assets.registry, &biomes, Some(&tables)).ok_or("decode failed")?,
|
||||
);
|
||||
}
|
||||
let decode_s = started.elapsed().as_secs_f64();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({"round": round, "load_s": load_s,
|
||||
"inflate_nbt_s": nbt_s, "decode_s": decode_s})
|
||||
);
|
||||
}
|
||||
if args.iter().any(|a| a == "--checksum") {
|
||||
let mut hash = Sha256::new();
|
||||
let mut light_loaded = 0;
|
||||
for (pos, _, _) in &corpus {
|
||||
let chunk = source
|
||||
.load(pos.x, pos.z, assets.registry, &biomes)
|
||||
.ok_or("load failed")?;
|
||||
hash.update(pos.x.to_le_bytes());
|
||||
hash.update(pos.z.to_le_bytes());
|
||||
light_loaded += usize::from(chunk.light_loaded);
|
||||
for section in &chunk.sections {
|
||||
for i in 0..4096 {
|
||||
hash.update(section.get(i).to_le_bytes());
|
||||
hash.update([section.light(i)]);
|
||||
}
|
||||
for i in 0..64 {
|
||||
hash.update(section.biomes.get(i).to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({"blocks_biomes_light_sha256": format!("{:x}", hash.finalize()),
|
||||
"light_loaded": light_loaded})
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -11,8 +11,41 @@ use horizon_client::{
|
||||
items::Items,
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
};
|
||||
use horizon_protocol::registry::Ids;
|
||||
use serde_json::Value;
|
||||
use std::{fs, path::Path};
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
/// The case's maps: (locked, scale) by id, and the next free id.
|
||||
#[derive(Default)]
|
||||
struct CaseMaps {
|
||||
maps: RefCell<HashMap<i32, (bool, u8)>>,
|
||||
next: Cell<i32>,
|
||||
}
|
||||
|
||||
impl horizon_client::menu::Maps for CaseMaps {
|
||||
fn state(&self, id: i32) -> Option<(bool, u8)> {
|
||||
self.maps.borrow().get(&id).copied()
|
||||
}
|
||||
|
||||
/// `MapItemSavedData.scaled` (unlocked, one scale up) or `locked`.
|
||||
fn derive(&self, id: i32, scale: bool) -> Option<i32> {
|
||||
let (_, s) = self.state(id)?;
|
||||
let new = self.next.get();
|
||||
self.next.set(new + 1);
|
||||
let made = if scale {
|
||||
(false, (s + 1).min(4))
|
||||
} else {
|
||||
(true, s)
|
||||
};
|
||||
self.maps.borrow_mut().insert(new, made);
|
||||
Some(new)
|
||||
}
|
||||
}
|
||||
|
||||
fn state(menu: &Menu) -> String {
|
||||
let slots: Vec<String> = (0..menu.slot_count())
|
||||
@@ -24,13 +57,21 @@ fn state(menu: &Menu) -> String {
|
||||
.iter()
|
||||
.map(|(slot, stack)| format!("{slot}:{}", canon_stack(stack.as_ref())))
|
||||
.collect();
|
||||
format!(
|
||||
let line = format!(
|
||||
"{} | {} |{data} | {} | {}",
|
||||
slots.join(" "),
|
||||
canon_stack(menu.carried.as_ref()),
|
||||
menu.drops.join(";"),
|
||||
equipped.join(";")
|
||||
)
|
||||
);
|
||||
if menu.kind() != Kind::Lectern {
|
||||
return line;
|
||||
}
|
||||
// No inventory slots: the inventory Take Book fills.
|
||||
let items: String = (0..36)
|
||||
.map(|i| format!(" {}", canon_stack(menu.player_item(i))))
|
||||
.collect();
|
||||
format!("{line} | inv{items}")
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
@@ -44,13 +85,28 @@ fn main() -> horizon_client::Result<()> {
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let banners = horizon_client::banner::Banners::load(root);
|
||||
let station_recipes = horizon_client::stations::StationRecipes::load(root, &items);
|
||||
let enchantments = horizon_client::enchant::Enchantments::load(root);
|
||||
let names = horizon_client::creative::Names::load(root);
|
||||
let case_maps = CaseMaps::default();
|
||||
let env = Env {
|
||||
items: &items,
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
station_recipes: &station_recipes,
|
||||
enchantments: &enchantments,
|
||||
names: &names,
|
||||
trade: None,
|
||||
maps: Some(&case_maps),
|
||||
};
|
||||
// The mob effects' network ids (BeaconMenu.encodeEffect).
|
||||
let builtin = horizon_protocol::registry::Builtin::find(root)
|
||||
.and_then(|p| horizon_protocol::registry::Builtin::load(&p).ok());
|
||||
let effects = builtin
|
||||
.as_ref()
|
||||
.and_then(|b| b.get("minecraft:mob_effect"))
|
||||
.cloned();
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
let case: Value = serde_json::from_str(line)?;
|
||||
@@ -68,15 +124,41 @@ fn main() -> horizon_client::Result<()> {
|
||||
.unwrap_or(name),
|
||||
)
|
||||
.ok_or("menu kind")?;
|
||||
// [id, scale, locked] each; the level's new maps from 1000.
|
||||
case_maps.next.set(1000);
|
||||
*case_maps.maps.borrow_mut() = case["maps"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|m| {
|
||||
let id = m[0].as_i64().unwrap_or(0) as i32;
|
||||
(
|
||||
id,
|
||||
(
|
||||
m[2].as_bool().unwrap_or(false),
|
||||
m[1].as_u64().unwrap_or(0) as u8,
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut menu = Menu::new(kind);
|
||||
menu.creative = case["creative"].as_bool().unwrap_or(false);
|
||||
menu.levels = case["levels"].as_i64().unwrap_or(0) as i32;
|
||||
for (i, slot) in case["slots"].as_array().ok_or("slots")?.iter().enumerate() {
|
||||
menu.set_slot(&env, i, parse_stack(slot.as_str().unwrap_or("-"))?);
|
||||
}
|
||||
menu.carried = parse_stack(case["carried"].as_str().unwrap_or("-"))?;
|
||||
for (i, item) in case["inventory"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
{
|
||||
menu.set_player_item(i, parse_stack(item.as_str().unwrap_or("-"))?);
|
||||
}
|
||||
menu.settle(&env);
|
||||
for (i, v) in case["data"].as_array().ok_or("data")?.iter().enumerate() {
|
||||
menu.data[i] = v.as_i64().unwrap_or(0) as i32;
|
||||
menu.set_data(i, v.as_i64().unwrap_or(0) as i32);
|
||||
}
|
||||
let mut furnace = furnace_kind.map(|kind| Furnace {
|
||||
kind,
|
||||
@@ -100,6 +182,26 @@ fn main() -> horizon_client::Result<()> {
|
||||
out += &format!("{}\n", state(&menu));
|
||||
continue;
|
||||
}
|
||||
// ServerboundRenameItemPacket: AnvilMenu.setItemName.
|
||||
"RENAME" => {
|
||||
menu.rename(&env, op[3].as_str().unwrap_or_default());
|
||||
out += &format!("{}\n", state(&menu));
|
||||
continue;
|
||||
}
|
||||
// ServerboundSetBeaconPacket: BeaconMenu.updateEffects.
|
||||
"SET_BEACON" => {
|
||||
fn power<'a>(ids: Option<&Ids>, v: &'a Value) -> Option<(i32, &'a str)> {
|
||||
let name = v.as_str()?;
|
||||
Some((ids?.id(name)?, name))
|
||||
}
|
||||
let (primary, secondary) = (
|
||||
power(effects.as_ref(), &op[3]),
|
||||
power(effects.as_ref(), &op[4]),
|
||||
);
|
||||
menu.beacon_update(&env, primary, secondary);
|
||||
out += &format!("{}\n", state(&menu));
|
||||
continue;
|
||||
}
|
||||
// ServerboundContainerSlotStateChangedPacket.
|
||||
"SLOT_STATE" => {
|
||||
if (0..9).contains(&slot) {
|
||||
|
||||
@@ -1027,6 +1027,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
nearest_player: player.as_ref().filter(|p| !p.spectator).map(|p| p.position),
|
||||
others: $snapshot,
|
||||
others_grid: $grid,
|
||||
others_index: None,
|
||||
daylight: (world.day_time.rem_euclid(24000)) < 12000,
|
||||
game_time: world.game_time,
|
||||
day_time: world.day_time,
|
||||
@@ -1132,7 +1133,7 @@ fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
#[allow(unused_mut)] // filled only with the rng-trace feature
|
||||
let mut traces: Vec<String> = Vec::new();
|
||||
let mut pushes = Vec::new();
|
||||
let mut pushes = horizon_client::mobs::PendingPushes::default();
|
||||
let mut born = Vec::new();
|
||||
for i in 0..mobs.len() {
|
||||
if mobs[i].removed {
|
||||
|
||||
@@ -111,6 +111,8 @@ struct World<'a> {
|
||||
slime: f32,
|
||||
starts: Vec<Start>,
|
||||
players: Vec<([f64; 3], String)>,
|
||||
/// Where the non-spectators among them are.
|
||||
nearest: Vec<[f64; 3]>,
|
||||
respawn: Option<[i32; 3]>,
|
||||
unloaded: HashSet<(i32, i32)>,
|
||||
spawn_mobs: bool,
|
||||
@@ -302,19 +304,8 @@ impl NaturalLevel for World<'_> {
|
||||
fn structure_spawns(&self) -> &StructureSpawns {
|
||||
self.s.structures
|
||||
}
|
||||
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
|
||||
let mut best: Option<f64> = None;
|
||||
for (p, mode) in &self.players {
|
||||
if mode == "spectator" {
|
||||
continue;
|
||||
}
|
||||
let d = [p[0] - x, p[1] - y, p[2] - z];
|
||||
let d = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
if best.is_none_or(|b| d < b) {
|
||||
best = Some(d);
|
||||
}
|
||||
}
|
||||
best
|
||||
fn nearest_player_positions(&self) -> &[[f64; 3]] {
|
||||
&self.nearest
|
||||
}
|
||||
fn respawn(&self) -> Option<[i32; 3]> {
|
||||
self.respawn
|
||||
@@ -730,6 +721,11 @@ fn main() -> horizon_client::Result<()> {
|
||||
dimension_id: dimension_id.clone(),
|
||||
slime: get("slime", "0.0").parse()?,
|
||||
starts,
|
||||
nearest: players
|
||||
.iter()
|
||||
.filter(|(_, mode)| mode != "spectator")
|
||||
.map(|(p, _)| *p)
|
||||
.collect(),
|
||||
players,
|
||||
respawn,
|
||||
unloaded,
|
||||
|
||||
@@ -38,12 +38,19 @@ fn main() -> horizon_client::Result<()> {
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let banners = horizon_client::banner::Banners::default();
|
||||
let station_recipes = horizon_client::stations::StationRecipes::default();
|
||||
let enchantments = horizon_client::enchant::Enchantments::default();
|
||||
let names = horizon_client::creative::Names::default();
|
||||
let env = Env {
|
||||
items: &items,
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
station_recipes: &station_recipes,
|
||||
enchantments: &enchantments,
|
||||
names: &names,
|
||||
trade: None,
|
||||
maps: None,
|
||||
};
|
||||
let mut out = String::new();
|
||||
for (line, order) in cases.lines().zip(orders.lines()) {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Random-tick sections companion for tools/test_random_tick_sections_parity.py:
|
||||
//! the game's `RandomTickSections` choosing the sections of a chunk that
|
||||
//! `Behaviours::tick_chunk_random` ticks, after the case's edits (see
|
||||
//! VanillaRandomTickSectionsOracle for the format; the `#resources` line names
|
||||
//! the prepared resources). The level the ticks run against reads as air
|
||||
//! everywhere, so nothing ticks for real: its reads are the drawn positions.
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, Section},
|
||||
random_tick_sections::RandomTickSections,
|
||||
};
|
||||
use horizon_core::{
|
||||
position::{BlockPos, ChunkPos},
|
||||
random::LegacyRandom,
|
||||
};
|
||||
use horizon_world::{
|
||||
fluid::FluidKind,
|
||||
level::{GameRules, LevelAccess, LevelEvent, Ticks, data},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
use std::{cell::RefCell, error::Error, fmt::Write, path::PathBuf, sync::Arc};
|
||||
|
||||
struct Air {
|
||||
reads: RefCell<Vec<BlockPos>>,
|
||||
random: LegacyRandom,
|
||||
speed: i32,
|
||||
}
|
||||
impl LevelAccess for Air {
|
||||
fn block_state(&self, pos: BlockPos) -> BlockStateId {
|
||||
self.reads.borrow_mut().push(pos);
|
||||
BlockStateId(0)
|
||||
}
|
||||
fn set_raw(&mut self, _: BlockPos, _: BlockStateId) -> Option<BlockStateId> {
|
||||
None
|
||||
}
|
||||
fn is_loaded(&self, _: BlockPos) -> bool {
|
||||
true
|
||||
}
|
||||
fn random(&mut self) -> &mut LegacyRandom {
|
||||
&mut self.random
|
||||
}
|
||||
fn game_time(&self) -> i64 {
|
||||
0
|
||||
}
|
||||
fn game_rules(&self) -> GameRules {
|
||||
GameRules {
|
||||
random_tick_speed: self.speed,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn emit(&mut self, _: LevelEvent) {}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = std::fs::read_to_string(&args[1])?;
|
||||
let root = input
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("#resources "))
|
||||
.map(PathBuf::from)
|
||||
.ok_or("no #resources line")?;
|
||||
let registry = data::load_registry(&root)?;
|
||||
let (facts, behaviours) = BlockFacts::load_pair(&root, ®istry)?;
|
||||
let mut output = String::new();
|
||||
for line in input.lines().filter(|l| !l.starts_with('#')) {
|
||||
let mut words = line.split(' ');
|
||||
let mut ticks = Ticks::new(0);
|
||||
ticks.random_tick_value = words.next().ok_or("no randValue")?.parse()?;
|
||||
let mut level = Air {
|
||||
reads: RefCell::default(),
|
||||
random: LegacyRandom::new(0),
|
||||
speed: words.next().ok_or("no speed")?.parse()?,
|
||||
};
|
||||
let mut chunk = Arc::new(Chunk::empty(0, 0));
|
||||
let mut cache = RandomTickSections::default();
|
||||
for word in words {
|
||||
let op: Vec<&str> = word.split(':').collect();
|
||||
let int = |i: usize| -> Result<i32, Box<dyn Error>> { Ok(op[i].parse()?) };
|
||||
match op[0] {
|
||||
"s" => {
|
||||
let (section, index) = (int(1)?, int(2)?);
|
||||
Arc::make_mut(&mut chunk).set(
|
||||
index & 15,
|
||||
MIN_Y + section * 16 + (index >> 8),
|
||||
index >> 4 & 15,
|
||||
int(3)? as u32,
|
||||
);
|
||||
}
|
||||
"f" => {
|
||||
let (a, b, m) = (int(2)? as u32, int(3)? as u32, int(4)? as usize);
|
||||
let values = std::array::from_fn(|i| if i % m == m - 1 { b } else { a });
|
||||
Arc::make_mut(&mut Arc::make_mut(&mut chunk).sections[int(1)? as usize])
|
||||
.blocks = Section::from_blocks(&values);
|
||||
}
|
||||
"t" => {
|
||||
output.push('|');
|
||||
level.reads.borrow_mut().clear();
|
||||
behaviours.tick_chunk_random(
|
||||
&mut level,
|
||||
&mut ticks,
|
||||
ChunkPos::new(0, 0),
|
||||
|_, from| cache.get((0, 0), &chunk, &facts).from(from),
|
||||
);
|
||||
for pos in level.reads.borrow().iter() {
|
||||
let state = chunk.block(pos.x, pos.y, pos.z);
|
||||
let fluid = matches!(
|
||||
behaviours.fluid(state).kind(),
|
||||
FluidKind::Lava | FluidKind::FlowingLava
|
||||
);
|
||||
write!(
|
||||
output,
|
||||
" {},{},{},{}{}",
|
||||
pos.x,
|
||||
pos.y,
|
||||
pos.z,
|
||||
if behaviours.is_randomly_ticking(state) {
|
||||
'B'
|
||||
} else {
|
||||
'-'
|
||||
},
|
||||
if fluid { 'F' } else { '-' },
|
||||
)?;
|
||||
}
|
||||
}
|
||||
other => return Err(format!("unknown op {other}").into()),
|
||||
}
|
||||
}
|
||||
writeln!(output, " | {}", ticks.random_tick_value)?;
|
||||
}
|
||||
std::fs::write(&args[2], output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -78,6 +78,7 @@ fn main() -> Result<()> {
|
||||
let mut selected = collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &entities,
|
||||
border: None,
|
||||
};
|
||||
let movement = world_movement::resolve_world_movement(
|
||||
&mut selected,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! what the render thread pays for it in `Streaming::update`.
|
||||
//!
|
||||
//! streaming_bench RESOURCES [--threads N] [--radius R] [--seed S] [--fly CHUNKS_PER_S] [--seconds T] [--hz F]
|
||||
//! [--dimension overworld|the_nether|the_end] [--center-x X] [--center-z Z]
|
||||
//!
|
||||
//! First the area around (0, 0) fills; then, with `--fly`, the centre moves along +x
|
||||
//! at that speed for T seconds while frames run at F per second. Each phase reports
|
||||
@@ -72,14 +73,33 @@ static ALLOCATOR: CountingAllocator = CountingAllocator;
|
||||
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn cpu_seconds() -> f64 {
|
||||
std::fs::read_to_string("/proc/self/stat")
|
||||
.ok()
|
||||
.and_then(|stat| {
|
||||
let fields = stat
|
||||
.rsplit_once(')')?
|
||||
.1
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>();
|
||||
Some((fields[11].parse::<f64>().ok()? + fields[12].parse::<f64>().ok()?) / 100.0)
|
||||
})
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Phase {
|
||||
updates: Vec<f64>,
|
||||
local: (u64, u64),
|
||||
uploads: usize,
|
||||
stage_samples: [u64; 7],
|
||||
cpu_start: f64,
|
||||
}
|
||||
impl Phase {
|
||||
fn update(&mut self, streaming: &mut Streaming, world: &mut World) {
|
||||
if self.updates.is_empty() {
|
||||
self.cpu_start = cpu_seconds();
|
||||
}
|
||||
let before = LOCAL.with(Cell::get);
|
||||
let started = Instant::now();
|
||||
let (uploads, _) = streaming.update(world);
|
||||
@@ -89,6 +109,9 @@ impl Phase {
|
||||
self.local.1 += after.1 - before.1;
|
||||
self.uploads += uploads.len();
|
||||
drop(uploads);
|
||||
for (sum, (_, count)) in self.stage_samples.iter_mut().zip(streaming.stages()) {
|
||||
*sum += count as u64;
|
||||
}
|
||||
}
|
||||
fn report(&self, name: &str, wall: Duration, streaming: &Streaming, generated: u64) {
|
||||
let mut sorted = self.updates.clone();
|
||||
@@ -106,6 +129,16 @@ impl Phase {
|
||||
at(0.99),
|
||||
sorted.last().copied().unwrap_or(0.0),
|
||||
);
|
||||
println!(
|
||||
"BENCH {}",
|
||||
serde_json::json!({
|
||||
"phase": name, "wall_s": wall.as_secs_f64(),
|
||||
"cpu_s": cpu_seconds() - self.cpu_start,
|
||||
"chunks": streaming.generated_total - generated, "sections": self.uploads,
|
||||
"updates": self.updates.len(), "update_ms": total,
|
||||
"mean_stages": self.stage_samples.map(|n| n as f64 / self.updates.len() as f64),
|
||||
})
|
||||
);
|
||||
if TRACK {
|
||||
println!(
|
||||
" update allocations: {} ({:.1} MiB)",
|
||||
@@ -142,6 +175,22 @@ fn main() -> Result<()> {
|
||||
let fly = flag("--fly", 0.0);
|
||||
let seconds = flag("--seconds", 10.0);
|
||||
let hz = flag("--hz", 240.0);
|
||||
let dimension = args
|
||||
.iter()
|
||||
.position(|a| a == "--dimension")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map(|d| {
|
||||
if d.contains(':') {
|
||||
d.clone()
|
||||
} else {
|
||||
format!("minecraft:{d}")
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "minecraft:overworld".into());
|
||||
let center = (
|
||||
flag("--center-x", 0.0) as i32,
|
||||
flag("--center-z", 0.0) as i32,
|
||||
);
|
||||
|
||||
let assets = Arc::new(Assets::load(&root)?);
|
||||
let render = Arc::new(assets.reload(&root)?);
|
||||
@@ -154,11 +203,12 @@ fn main() -> Result<()> {
|
||||
models::load_visibility(&root, assets.registry)?,
|
||||
)?);
|
||||
let light = Arc::new(LightTables::load(&root, assets.registry, &facts)?);
|
||||
let terrain = Arc::new(VanillaTerrain::new(
|
||||
let terrain = Arc::new(VanillaTerrain::for_dimension(
|
||||
&root.join("worldgen"),
|
||||
assets.registry,
|
||||
&biomes,
|
||||
seed,
|
||||
&dimension,
|
||||
)?);
|
||||
let context = Arc::new(WorkerContext {
|
||||
assets,
|
||||
@@ -170,8 +220,12 @@ fn main() -> Result<()> {
|
||||
let mut world = World::new(facts.air, seed);
|
||||
let mut streaming = Streaming::new(terrain, context, threads);
|
||||
streaming.radius = radius;
|
||||
streaming.center = (0, 0);
|
||||
println!("{threads} workers, radius {radius}, seed {seed}");
|
||||
streaming.center = center;
|
||||
streaming.nether_shading = dimension == "minecraft:the_nether";
|
||||
streaming.sky_light = dimension != "minecraft:the_nether";
|
||||
println!(
|
||||
"{threads} workers, radius {radius}, seed {seed}, dimension {dimension}, center {center:?}"
|
||||
);
|
||||
let (allocations, bytes) = (ALLOCATIONS.load(Relaxed), BYTES.load(Relaxed));
|
||||
|
||||
// Fill: update as often as the game would uncapped, until settled.
|
||||
@@ -198,12 +252,12 @@ fn main() -> Result<()> {
|
||||
let mut behind = Vec::new();
|
||||
while started.elapsed().as_secs_f64() < seconds {
|
||||
let next = Instant::now() + frame;
|
||||
let x = (started.elapsed().as_secs_f64() * fly) as i32;
|
||||
streaming.center = (x, 0);
|
||||
let x = center.0 + (started.elapsed().as_secs_f64() * fly) as i32;
|
||||
streaming.center = (x, center.1);
|
||||
phase.update(&mut streaming, &mut world);
|
||||
// Columns within 2 chunks of the player still missing their meshes.
|
||||
let near = (-2..=2)
|
||||
.flat_map(|dz| (-2..=2).map(move |dx| (x + dx, dz)))
|
||||
.flat_map(|dz| (-2..=2).map(move |dx| (x + dx, center.1 + dz)))
|
||||
.filter(|&(cx, cz)| !streaming.is_lit(cx, cz))
|
||||
.count();
|
||||
behind.push(near);
|
||||
|
||||
@@ -239,10 +239,12 @@ fn main() -> horizon_client::Result<()> {
|
||||
let vanilla = fs::read_to_string(args.get(3).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let enchantments = Enchantments::load(root);
|
||||
let names = horizon_client::creative::Names::default();
|
||||
let trades = Trades::load(root);
|
||||
let recipes = horizon_client::crafting::Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let banners = horizon_client::banner::Banners::load(root);
|
||||
let station_recipes = horizon_client::stations::StationRecipes::default();
|
||||
let types: HashMap<_, _> = horizon_client::mobs::MobTypes::load(root)?
|
||||
.types
|
||||
.into_iter()
|
||||
@@ -378,7 +380,11 @@ fn main() -> horizon_client::Result<()> {
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
station_recipes: &station_recipes,
|
||||
enchantments: &enchantments,
|
||||
names: &names,
|
||||
trade: Some(&env_trade),
|
||||
maps: None,
|
||||
};
|
||||
trade(&mut out, name, &kv, &mut world, &env, origin)?;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ fn bits(value: f32) -> String {
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let mut output = String::new();
|
||||
let mut parameters = weather::ParameterCache::default();
|
||||
for (case, line) in fs::read_to_string(&args[1])?.lines().enumerate() {
|
||||
let p: Vec<&str> = line.split_whitespace().collect();
|
||||
let int = |i: usize| p[i].parse::<i32>().unwrap();
|
||||
@@ -38,6 +39,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
bits(column.v_offset),
|
||||
column.light_coords
|
||||
)?;
|
||||
assert_eq!(
|
||||
parameters
|
||||
.get(x, z)
|
||||
.column(kind != 0, ticks, x, bottom, top, z, light, partial),
|
||||
column,
|
||||
"cached parameters differ from original random source at case {case}"
|
||||
);
|
||||
let max_alpha = if kind == 0 { 1.0 } else { 0.8 };
|
||||
for v in weather::column_quad(&column, camera, max_alpha, radius, intensity) {
|
||||
write!(
|
||||
|
||||
@@ -22,6 +22,11 @@ impl PlayerMotion {
|
||||
pub fn refresh_attributes(&mut self) -> Result<()> {
|
||||
live_updates::refresh(self)
|
||||
}
|
||||
/// `Player.internalSetAbsorptionAmount`: the amount as given, unclamped
|
||||
/// (the saved `AbsorptionAmount`, or what a client's server sent).
|
||||
pub fn internal_set_absorption(&mut self, value: f32) -> Result<()> {
|
||||
self.store_absorption(value)
|
||||
}
|
||||
}
|
||||
impl HealthWorld for PlayerMotion {
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `banner_patterns` layers carried by banner items, shields and placed
|
||||
//! banners, and the loom rules of `LoomMenu` (selectable patterns, the
|
||||
//! six-layer limit and the result stack).
|
||||
use crate::{block_entity_render::DYES, item_data, items::ItemStack, items::Items};
|
||||
use crate::{block_entity_render::DYES, items::ItemStack, items::Items};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path, sync::Arc};
|
||||
@@ -261,12 +261,6 @@ pub fn layers_from_nbt(tag: Option<&Tag>) -> Vec<BannerLayer> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Keeps `item_data` linked: the `StackData` fields the banner code reads.
|
||||
pub fn set_layers(stack: &mut ItemStack, layers: Vec<BannerLayer>) {
|
||||
let data: &mut item_data::StackData = stack.data_mut();
|
||||
data.banner_patterns = layers;
|
||||
stack.tidy();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -140,7 +140,22 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
driver: text(&renderer["driver"]),
|
||||
backend: text(&renderer["backend"]).to_lowercase(),
|
||||
kind: text(&renderer["device_type"]),
|
||||
vram_mb: None,
|
||||
});
|
||||
if let Some(gpu) = report.hardware.gpu.as_mut() {
|
||||
let vendor = horizon_telemetry::system::gpu_vendor(&gpu.name, 0);
|
||||
let vendor = if gpu.vendor.is_empty() {
|
||||
vendor
|
||||
} else {
|
||||
gpu.vendor.as_str()
|
||||
}
|
||||
.to_owned();
|
||||
if gpu.kind.contains("iscrete") {
|
||||
gpu.vram_mb = horizon_telemetry::system::vram_mb(&vendor);
|
||||
}
|
||||
}
|
||||
report.hardware.storage =
|
||||
horizon_telemetry::system::storage(&setup.game_dir).map(str::to_owned);
|
||||
report.hardware.display = serde_json::from_value(title["display"].clone()).ok();
|
||||
|
||||
replace_world(&setup.game_dir)?;
|
||||
@@ -224,6 +239,10 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
|
||||
let status = control.status();
|
||||
let renderer = &status["renderer"];
|
||||
// On Wayland the window is on a screen only once it has been shown: ask again.
|
||||
if report.hardware.display.is_none() {
|
||||
report.hardware.display = serde_json::from_value(status["display"].clone()).ok();
|
||||
}
|
||||
let c = &mut report.conditions;
|
||||
c.scenario = Some(setup.scenario.clone());
|
||||
c.seed = Some(SEED.to_string());
|
||||
|
||||
@@ -32,16 +32,18 @@ pub enum Group {
|
||||
Move,
|
||||
Act,
|
||||
Screens,
|
||||
Multiplayer,
|
||||
World,
|
||||
Scripting,
|
||||
}
|
||||
impl Group {
|
||||
pub const ALL: [Group; 7] = [
|
||||
pub const ALL: [Group; 8] = [
|
||||
Group::Session,
|
||||
Group::Observe,
|
||||
Group::Move,
|
||||
Group::Act,
|
||||
Group::Screens,
|
||||
Group::Multiplayer,
|
||||
Group::World,
|
||||
Group::Scripting,
|
||||
];
|
||||
@@ -52,6 +54,7 @@ impl Group {
|
||||
Group::Move => "Move and look",
|
||||
Group::Act => "Act on the world",
|
||||
Group::Screens => "Screens, menus and chat",
|
||||
Group::Multiplayer => "Multiplayer, LAN and friends",
|
||||
Group::World => "World and cheats",
|
||||
Group::Scripting => "Scripting and benchmarks",
|
||||
}
|
||||
@@ -117,6 +120,50 @@ const OVER: Param = flag(
|
||||
"turn with mouse motion spread over this many seconds (eased), as a player would",
|
||||
);
|
||||
const FACE: Ty = Ty::Enum(&["down", "up", "north", "south", "west", "east"]);
|
||||
const BOX: [Param; 6] = [
|
||||
p("x1", Ty::Int, "corner X"),
|
||||
p("y1", Ty::Int, "corner Y"),
|
||||
p("z1", Ty::Int, "corner Z"),
|
||||
p("x2", Ty::Int, "opposite X"),
|
||||
p("y2", Ty::Int, "opposite Y"),
|
||||
p("z2", Ty::Int, "opposite Z"),
|
||||
];
|
||||
const PAGES: Ty = Ty::Enum(&[
|
||||
"main",
|
||||
"video",
|
||||
"sound",
|
||||
"controls",
|
||||
"keybinds",
|
||||
"controller",
|
||||
"controller-binds",
|
||||
"mouse",
|
||||
"chat",
|
||||
"accessibility",
|
||||
"language",
|
||||
"font",
|
||||
"packs",
|
||||
"mods",
|
||||
"graphics",
|
||||
"graphics-advanced",
|
||||
"extras",
|
||||
"horizon",
|
||||
"shader-packs",
|
||||
"agent",
|
||||
]);
|
||||
|
||||
/// `--confirm`, required by destructive commands: the player agreed.
|
||||
const CONFIRM: Param = Param {
|
||||
name: "confirm",
|
||||
ty: Ty::Bool,
|
||||
required: true,
|
||||
flag: true,
|
||||
help: "true once the player agreed: it cannot be undone",
|
||||
};
|
||||
|
||||
/// MCP tools that delete or overwrite the player's things (`destructiveHint`).
|
||||
pub const DESTRUCTIVE: &[&str] = &["world-delete", "fill", "setblock"];
|
||||
/// MCP tools that reach other players or the network (`openWorldHint`): only when the player asks.
|
||||
pub const OPEN_WORLD: &[&str] = &["connect", "lan", "friends", "chat", "command"];
|
||||
|
||||
macro_rules! cmd {
|
||||
($name:expr, $group:ident, $usage:expr, $summary:expr, $details:expr, [$($ex:expr),* $(,)?], $params:expr, mcp: $mcp:expr, local: $local:expr $(, wire: $wire:expr)?) => {
|
||||
@@ -164,7 +211,19 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[flag("lines", Ty::Int, "how many lines of latest.log (default 100)"), flag("crash", Ty::Bool, "the newest crash report instead")],
|
||||
mcp: true, local: true),
|
||||
cmd!("status", Session, "status", "game, renderer and streaming state (the scripting socket's original summary)",
|
||||
"", ["horizon-ctl status"], &[], mcp: false, local: false),
|
||||
"Also what F3 shows: the overlay flags, the renderer, chunk streaming and memory.", ["horizon-ctl status"], &[], mcp: true, local: false),
|
||||
cmd!("worlds", Session, "worlds", "the saved worlds, newest first: folder, name, mode, last played, version",
|
||||
"folder is what open, world-rename, world-backup and world-delete take.", ["horizon-ctl worlds"], &[], mcp: true, local: false),
|
||||
cmd!("world-rename", Session, "world-rename FOLDER NAME", "rename a world, as Edit World does (its folder stays)",
|
||||
"Needs the title screen (no world open).", ["horizon-ctl world-rename Test \"My Base\""],
|
||||
&[p("folder", Ty::Str, "world folder from worlds"), p("name", Ty::Rest, "the new name")], mcp: true, local: false),
|
||||
cmd!("world-backup", Session, "world-backup FOLDER", "zip a world into backups/, as Edit World's Make Backup does",
|
||||
"Needs the title screen. Replies once the zip is written, with its size.", ["horizon-ctl world-backup Test"],
|
||||
&[p("folder", Ty::Str, "world folder from worlds")], mcp: true, local: false),
|
||||
cmd!("world-delete", Session, "world-delete FOLDER --confirm", "delete a world for good, as Select World's Delete does",
|
||||
"Needs the title screen and --confirm: ask the player first, it cannot be undone (world-backup keeps a copy).",
|
||||
["horizon-ctl world-delete Test --confirm"],
|
||||
&[p("folder", Ty::Str, "world folder from worlds"), CONFIRM], mcp: true, local: false),
|
||||
cmd!("open", Session, "open WORLD [SEED] [creative]", "open or create a world from the title screen",
|
||||
"Opens saves/WORLD, creating it with SEED (and creative mode) when it doesn't exist.",
|
||||
["horizon-ctl open Test 12345", "horizon-ctl open Build 0 creative"],
|
||||
@@ -190,10 +249,10 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("light", Observe, "light X1 Y1 Z1 X2 Y2 Z2", "the stored sky and block light of a box",
|
||||
"At most 32768 blocks per request. One hex digit per block in `sky` and `block`, x fastest, then z, then y; `.` where no chunk is loaded.",
|
||||
["horizon-ctl light 0 64 0 15 70 15"],
|
||||
&[p("x1", Ty::Int, "corner X"), p("y1", Ty::Int, "corner Y"), p("z1", Ty::Int, "corner Z"), p("x2", Ty::Int, "opposite X"), p("y2", Ty::Int, "opposite Y"), p("z2", Ty::Int, "opposite Z")], mcp: true, local: false),
|
||||
&BOX, mcp: true, local: false),
|
||||
cmd!("blocks", Observe, "blocks X1 Y1 Z1 X2 Y2 Z2", "the non-air blocks of a box, with counts per block",
|
||||
"At most 32768 blocks per request.", ["horizon-ctl blocks 0 60 0 15 70 15"],
|
||||
&[p("x1", Ty::Int, "corner X"), p("y1", Ty::Int, "corner Y"), p("z1", Ty::Int, "corner Z"), p("x2", Ty::Int, "opposite X"), p("y2", Ty::Int, "opposite Y"), p("z2", Ty::Int, "opposite Z")], mcp: true, local: false),
|
||||
&BOX, mcp: true, local: false),
|
||||
cmd!("find-block", Observe, "find-block BLOCK [RADIUS]", "the nearest loaded blocks of a kind",
|
||||
"Searches a cube of RADIUS (default 32, at most 64) around the player; returns the count and the 16 nearest.",
|
||||
["horizon-ctl find-block oak_log", "horizon-ctl find-block diamond_ore 48"],
|
||||
@@ -264,12 +323,15 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
"down holds the key until key ... up, release or stop; wait does not release it. move, goto, jump, break and the other actions release the movement keys when they end.",
|
||||
["horizon-ctl key space tap"], &[p("key", Ty::Str, "key name"), p("action", Ty::Enum(&["down", "up", "tap", "repeat"]), "what to do")], mcp: true, local: false),
|
||||
cmd!("mouse", Act, "mouse left|right|middle down|up|click", "press or release a mouse button in the world", "",
|
||||
["horizon-ctl mouse right click"], &[p("button", Ty::Enum(&["left", "right", "middle"]), "button"), p("action", Ty::Enum(&["down", "up", "click"]), "what to do")], mcp: false, local: false),
|
||||
["horizon-ctl mouse right click"], &[p("button", Ty::Enum(&["left", "right", "middle"]), "button"), p("action", Ty::Enum(&["down", "up", "click"]), "what to do")], mcp: true, local: false),
|
||||
cmd!("interact-entity", Act, "interact-entity ID", "right-click an entity: trade with a villager, mount a horse, ride a boat, shear a sheep",
|
||||
"Faces the entity (from entities), right-clicks it with the held item, waits two ticks and replies with the screen.",
|
||||
["horizon-ctl interact-entity 42"], &[p("id", Ty::Int, "entity id from entities")], mcp: true, local: true),
|
||||
cmd!("pad", Act, "pad BUTTON down|up|tap | pad stick left|right X Y | pad trigger left|right V | pad release | pad status", "a virtual controller: buttons, sticks and triggers as a real pad would send them",
|
||||
"Buttons by position: south east west north lb rb lt rt select start ls rs up down left right (south is Xbox A / PlayStation cross). Sticks run -1..1 with up positive and stay tilted until centred; triggers 0..1. Merged with any real controller; status lists the connected ones and what is held.",
|
||||
["horizon-ctl pad south tap", "horizon-ctl pad stick left 0 1", "horizon-ctl pad trigger right 1", "horizon-ctl pad status"],
|
||||
&[p("what", Ty::Str, "a button name, stick, trigger, release or status"), opt("a", Ty::Str, "down|up|tap, or left|right"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: false, local: false),
|
||||
cmd!("release", Act, "release", "release every key and mouse button", "", ["horizon-ctl release"], &[], mcp: false, local: false),
|
||||
&[p("what", Ty::Str, "a button name, stick, trigger, release or status"), opt("a", Ty::Str, "down|up|tap, or left|right"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: true, local: false),
|
||||
cmd!("release", Act, "release", "release every key and mouse button", "", ["horizon-ctl release"], &[], mcp: true, local: false),
|
||||
cmd!("stop", Act, "stop", "cancel the running action (goto, break...) and release every key", "", ["horizon-ctl stop"], &[], mcp: true, local: false),
|
||||
// Screens
|
||||
cmd!("open-inventory", Screens, "open-inventory", "open the inventory screen (crafting grid 100-103, result 200)", "",
|
||||
@@ -317,7 +379,31 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("respawn", Screens, "respawn", "respawn from the death screen", "", ["horizon-ctl respawn"], &[], mcp: true, local: false),
|
||||
cmd!("pause", Screens, "pause", "open the pause menu", "", ["horizon-ctl pause"], &[], mcp: true, local: false),
|
||||
cmd!("resume", Screens, "resume", "leave the pause menu", "", ["horizon-ctl resume"], &[], mcp: true, local: false),
|
||||
cmd!("options", Screens, "options [PAGE]", "open the options screen, or one of its pages (Horizon's too)",
|
||||
"Then ui lists its widgets and click, set and type change them, as with the mouse. options-set changes an options.txt value without the screen.",
|
||||
["horizon-ctl options video", "horizon-ctl options horizon"], &[opt("page", PAGES, "the page (default: the main options)")], mcp: true, local: false),
|
||||
cmd!("book", Screens, "book [next|back|take|done]", "the open book or lectern: its page text, or press one of its buttons",
|
||||
"", ["horizon-ctl book", "horizon-ctl book next"], &[opt("action", Ty::Enum(&["next", "back", "take", "done"]), "a button to press")], mcp: true, local: false),
|
||||
cmd!("signedit", Screens, "signedit X Y Z [front|back]", "open the sign editor of the sign at X Y Z (then type and close)",
|
||||
"", ["horizon-ctl signedit 3 64 1"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("side", Ty::Enum(&["front", "back"]), "which side")], mcp: true, local: false),
|
||||
cmd!("mount-menu", Screens, "mount-menu", "open the inventory of the horse, donkey, mule or llama being ridden",
|
||||
"As the inventory key does while riding; also opens the nearest one's.", ["horizon-ctl mount-menu"], &[], mcp: true, local: false),
|
||||
cmd!("chat-log", Screens, "chat-log [N]", "the newest N chat messages", "", ["horizon-ctl chat-log 5"], &[], mcp: false, local: false),
|
||||
// Multiplayer
|
||||
cmd!("connect", Multiplayer, "connect ADDRESS [NAME]", "join a multiplayer server, or a friend's world by its invite code, from the title screen",
|
||||
"ADDRESS is HOST[:PORT], or friend:CODE for an invite code. Only when the player asks: it reaches the network.",
|
||||
["horizon-ctl connect play.example.net", "horizon-ctl connect localhost:25565", "horizon-ctl connect friend:0B33K-Q405Q-..."],
|
||||
&[p("address", Ty::Str, "HOST[:PORT] or friend:CODE"), opt("name", Ty::Str, "player name when offline")], mcp: true, local: false),
|
||||
cmd!("disconnect", Multiplayer, "disconnect", "leave the server, back to the server list", "", ["horizon-ctl disconnect"], &[], mcp: true, local: false),
|
||||
cmd!("servers", Multiplayer, "servers", "the Multiplayer screen's server list: name and address",
|
||||
"Add, edit and remove them on the Multiplayer screen (click-button Multiplayer, then ui).", ["horizon-ctl servers"], &[], mcp: true, local: false),
|
||||
cmd!("lan", Multiplayer, "lan open [PORT] [commands] [force] | lan close | lan status", "open the world to LAN (Open to LAN), stop, or see the guests",
|
||||
"PORT 0 or none picks one; commands lets guests use cheats; force forces the game mode. Only when the player asks.",
|
||||
["horizon-ctl lan open", "horizon-ctl lan status", "horizon-ctl lan close"],
|
||||
&[p("action", Ty::Enum(&["open", "close", "status"]), "what to do"), opt("port", Ty::Int, "port for open"), opt("commands", Ty::Bool, "allow cheats"), opt("force", Ty::Bool, "force the game mode")], mcp: true, local: false),
|
||||
cmd!("friends", Multiplayer, "friends host|stop|status", "Play with Friends: share the open world behind an invite code, stop, or see who joined",
|
||||
"status gives the code: share it only when the player asks. Friends join with connect friend:CODE.",
|
||||
["horizon-ctl friends host", "horizon-ctl friends status"], &[p("action", Ty::Enum(&["host", "stop", "status"]), "what to do")], mcp: true, local: false),
|
||||
// World
|
||||
cmd!("give", World, "give ITEM [COUNT]", "add items to the inventory", "", ["horizon-ctl give oak_log 16"],
|
||||
&[p("item", Ty::Str, "item id"), opt("count", Ty::Int, "how many (default 1)")], mcp: true, local: false),
|
||||
@@ -325,8 +411,20 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[p("ticks", Ty::Int, "day time")], mcp: true, local: false),
|
||||
cmd!("setblock", World, "setblock X Y Z BLOCK[props]", "set a block (with neighbour updates)", "", ["horizon-ctl setblock 0 64 0 stone"],
|
||||
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], p("block", Ty::Str, "block state such as oak_stairs[facing=east]")], mcp: true, local: false),
|
||||
cmd!("summon", World, "summon KIND [X Y Z] [baby]", "spawn a mob", "", ["horizon-ctl summon cow"], &[p("kind", Ty::Str, "mob type")], mcp: true, local: false),
|
||||
cmd!("effect", World, "effect ID SECONDS [AMPLIFIER] | effect clear", "give a status effect", "", ["horizon-ctl effect speed 30 1"], &[], mcp: false, local: false),
|
||||
cmd!("summon", World, "summon KIND [X Y Z] [baby]", "spawn a mob (in front of the player without a position)", "Replies with its id.", ["horizon-ctl summon cow", "horizon-ctl summon pig 10 64 3 baby"],
|
||||
&[p("kind", Ty::Str, "mob type"), opt("x", Ty::Num, "X"), opt("y", Ty::Num, "Y"), opt("z", Ty::Num, "Z"), opt("baby", Ty::Bool, "a baby")], mcp: true, local: false),
|
||||
cmd!("effect", World, "effect ID SECONDS [AMPLIFIER] | effect clear", "give a status effect, or clear them all", "", ["horizon-ctl effect speed 30 1", "horizon-ctl effect clear"],
|
||||
&[p("id", Ty::Str, "effect id such as speed, or clear"), opt("seconds", Ty::Num, "duration"), opt("amplifier", Ty::Int, "level - 1 (default 0)")], mcp: true, local: false),
|
||||
cmd!("effects", World, "effects", "the player's active status effects", "", ["horizon-ctl effects"], &[], mcp: true, local: false),
|
||||
cmd!("xp", World, "xp LEVELS", "add experience levels (negative removes)", "", ["horizon-ctl xp 30"], &[p("levels", Ty::Int, "levels to add")], mcp: true, local: false),
|
||||
cmd!("enchant", World, "enchant ID LEVEL", "enchant the held item (a book stores it)", "", ["horizon-ctl enchant sharpness 5"],
|
||||
&[p("id", Ty::Str, "enchantment id"), p("level", Ty::Int, "level")], mcp: true, local: false),
|
||||
cmd!("potion", World, "potion ITEM POTION", "give a potion item", "", ["horizon-ctl potion splash_potion healing"],
|
||||
&[p("item", Ty::Enum(&["potion", "splash_potion", "lingering_potion", "tipped_arrow"]), "which item"), p("potion", Ty::Str, "potion id")], mcp: true, local: false),
|
||||
cmd!("fill", World, "fill X1 Y1 Z1 X2 Y2 Z2 BLOCK", "set a box of loaded blocks", "", ["horizon-ctl fill 0 64 0 4 64 4 glass"],
|
||||
&[BOX[0], BOX[1], BOX[2], BOX[3], BOX[4], BOX[5], p("block", Ty::Str, "block state")], mcp: true, local: false),
|
||||
cmd!("container", World, "container X Y Z [ITEM COUNT]", "list a container's items, or add items to it", "", ["horizon-ctl container 3 64 1", "horizon-ctl container 3 64 1 diamond 3"],
|
||||
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("item", Ty::Str, "item to add"), opt("count", Ty::Int, "how many")], mcp: true, local: false),
|
||||
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
|
||||
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
|
||||
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
|
||||
@@ -340,8 +438,8 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("mcp", Scripting, "mcp", "serve the Model Context Protocol on stdin/stdout, one tool per command",
|
||||
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image.",
|
||||
["horizon-ctl mcp"], &[], mcp: false, local: true),
|
||||
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[], mcp: false, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[], mcp: false, local: false),
|
||||
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again")], mcp: true, local: false),
|
||||
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
|
||||
"The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). With raw, the reply also lists every frame time in milliseconds (frame_ms).",
|
||||
["horizon-ctl fly 0 100 0 500 100 0 20"], &[], mcp: false, local: false),
|
||||
@@ -366,8 +464,9 @@ pub fn overview() -> String {
|
||||
}
|
||||
out.push_str(
|
||||
"\nEvery reply is one JSON line. Errors are {\"error\": \"...\"}; the exit status is 0 on\n\
|
||||
success, 1 when the game replied with an error, 2 for a usage error and 3 when the\n\
|
||||
game cannot be reached or started. Actions (goto, break, place...) block until done.\n\
|
||||
success, 1 when the game replied with an error, 2 for a usage error, 3 when the\n\
|
||||
game cannot be reached or started and 4 while No AI is on (the launcher's settings).\n\
|
||||
Actions (goto, break, place...) block until done.\n\
|
||||
horizon-ctl help --game lists the running game's own commands, test hooks included.\n\
|
||||
The socket is $HORIZON_CONTROL, else $XDG_RUNTIME_DIR/horizon-control.sock (on Windows\n\
|
||||
%TEMP%\\horizon-control.json, a localhost TCP port with a token; any path ending in .json\n\
|
||||
@@ -402,6 +501,73 @@ pub fn detail(c: &Cmd) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The generated reference, docs/horizon-ctl-commands.md (`horizon-ctl help --markdown`).
|
||||
pub fn markdown() -> String {
|
||||
let cell = |s: &str| s.replace('|', "\\|");
|
||||
let mut out = String::from(
|
||||
"<!-- Generated by `horizon-ctl help --markdown` from the command table in \
|
||||
crates/horizon-client/src/bin/horizon-ctl/commands.rs: edit the table, then regenerate. -->\n\
|
||||
# horizon-ctl commands\n\n\
|
||||
Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launcher's \
|
||||
`horizon-launcher mcp` offers the same tools as `game_<tool>`, all but `launch`) and its \
|
||||
arguments. Positional arguments come first, in order; `--flags` after them. \
|
||||
`horizon-ctl help --game` lists the game's own commands, test hooks included. \
|
||||
Design rules and the full inventory: [agent-control.md](agent-control.md).\n",
|
||||
);
|
||||
for group in Group::ALL {
|
||||
out.push_str(&format!(
|
||||
"\n## {}\n\n| Command | MCP tool | What it does | Arguments |\n| --- | --- | --- | --- |\n",
|
||||
group.title()
|
||||
));
|
||||
for c in COMMANDS.iter().filter(|c| c.group == group) {
|
||||
let tool = if c.mcp {
|
||||
let mut t = format!("`{}`", tool_name(c));
|
||||
if DESTRUCTIVE.contains(&c.name) {
|
||||
t.push_str(" (destructive)");
|
||||
} else if OPEN_WORLD.contains(&c.name) {
|
||||
t.push_str(" (network)");
|
||||
}
|
||||
t
|
||||
} else {
|
||||
"CLI only".to_owned()
|
||||
};
|
||||
let args: Vec<String> = c
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let ty = match p.ty {
|
||||
Ty::Int => "integer".to_owned(),
|
||||
Ty::Num => "number".to_owned(),
|
||||
Ty::Str => "string".to_owned(),
|
||||
Ty::Rest => "text".to_owned(),
|
||||
Ty::Bool => "boolean".to_owned(),
|
||||
Ty::Enum(v) => v.join(", "),
|
||||
};
|
||||
let name = if p.flag {
|
||||
format!("--{}", p.name)
|
||||
} else {
|
||||
p.name.to_owned()
|
||||
};
|
||||
let need = if p.required { ", required" } else { "" };
|
||||
format!("`{name}` ({ty}{need}): {}", cell(p.help))
|
||||
})
|
||||
.collect();
|
||||
out.push_str(&format!(
|
||||
"| `{}` | {} | {} | {} |\n",
|
||||
cell(c.usage),
|
||||
tool,
|
||||
cell(&capitalize(c.summary)),
|
||||
if args.is_empty() {
|
||||
"none".to_owned()
|
||||
} else {
|
||||
args.join("<br>")
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
chars
|
||||
@@ -640,6 +806,43 @@ mod tests {
|
||||
assert_eq!(tool_name(find("look-at-entity").unwrap()), "look_at_entity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_generated_reference_is_up_to_date() {
|
||||
let path = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../docs/horizon-ctl-commands.md"
|
||||
);
|
||||
let on_disk = std::fs::read_to_string(path).unwrap_or_default();
|
||||
assert!(
|
||||
on_disk == markdown(),
|
||||
"docs/horizon-ctl-commands.md is out of date: run `horizon-ctl help --markdown > docs/horizon-ctl-commands.md`"
|
||||
);
|
||||
for c in COMMANDS {
|
||||
assert!(
|
||||
on_disk.contains(&format!("`{}", c.usage.replace('|', "\\|"))),
|
||||
"{} undocumented",
|
||||
c.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destructive_and_network_tools_exist() {
|
||||
for name in DESTRUCTIVE.iter().chain(OPEN_WORLD) {
|
||||
assert!(find(name).is_some_and(|c| c.mcp), "{name}");
|
||||
}
|
||||
let delete = find("world-delete").unwrap();
|
||||
assert_eq!(
|
||||
line_for(delete, &json!({ "folder": "A", "confirm": true })).unwrap(),
|
||||
["world-delete", "A", "--confirm"]
|
||||
);
|
||||
let lan = find("lan").unwrap();
|
||||
assert_eq!(
|
||||
line_for(lan, &json!({ "action": "open", "commands": true })).unwrap(),
|
||||
["lan", "open", "commands"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_mentions_every_command() {
|
||||
let text = overview();
|
||||
|
||||
@@ -20,6 +20,8 @@ use serde_json::{Value, json};
|
||||
const GAME_ERROR: u8 = 1;
|
||||
const USAGE: u8 = 2;
|
||||
const UNREACHABLE: u8 = 3;
|
||||
/// No AI is on (`horizon_core::no_ai`): agent control is off.
|
||||
const DENIED: u8 = 4;
|
||||
|
||||
/// A failure, with the exit status it maps to.
|
||||
#[derive(Debug)]
|
||||
@@ -34,6 +36,13 @@ impl Failure {
|
||||
status: USAGE,
|
||||
}
|
||||
}
|
||||
/// No AI is on in the game's home.
|
||||
fn denied() -> Self {
|
||||
Self {
|
||||
reply: json!({ "error": horizon_core::no_ai::REFUSAL, "kind": "denied" }),
|
||||
status: DENIED,
|
||||
}
|
||||
}
|
||||
fn unreachable(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
reply: json!({ "error": message.into(), "kind": "connection" }),
|
||||
@@ -98,6 +107,8 @@ impl Client {
|
||||
pub struct Session {
|
||||
pub socket: PathBuf,
|
||||
pub timeout: Option<Duration>,
|
||||
/// The game's home, where No AI is read (`horizon_core::no_ai`).
|
||||
pub home: PathBuf,
|
||||
client: Option<Client>,
|
||||
}
|
||||
|
||||
@@ -106,9 +117,14 @@ impl Session {
|
||||
Self {
|
||||
socket,
|
||||
timeout,
|
||||
home: horizon_client::logging::game_home(),
|
||||
client: None,
|
||||
}
|
||||
}
|
||||
/// A failure while No AI is on: every command refuses (help aside).
|
||||
pub fn allowed(&self) -> Result<(), Failure> {
|
||||
horizon_core::no_ai::check(&self.home).map_err(|_| Failure::denied())
|
||||
}
|
||||
fn client(&mut self) -> Result<&mut Client, Failure> {
|
||||
if self.client.is_none() {
|
||||
self.client = Some(Client::connect(&self.socket, self.timeout)?);
|
||||
@@ -137,6 +153,7 @@ impl Session {
|
||||
let Some(name) = words.first().map(String::as_str) else {
|
||||
return Err(Failure::usage("no command; try horizon-ctl help"));
|
||||
};
|
||||
self.allowed()?;
|
||||
if words.iter().skip(1).any(|w| w == "--help" || w == "-h") {
|
||||
return Err(Failure::usage(
|
||||
"use horizon-ctl COMMAND --help outside scripts",
|
||||
@@ -174,6 +191,18 @@ impl Session {
|
||||
ready["launch"] = reply;
|
||||
Ok(ready)
|
||||
}
|
||||
"interact-entity" => {
|
||||
let id = match words {
|
||||
[_, id] if id.parse::<i64>().is_ok() => id,
|
||||
_ => return Err(Failure::usage(format!("usage: {}", usage(name)))),
|
||||
};
|
||||
self.game(&format!("look-at entity {id}"))?;
|
||||
self.game("mouse right click")?;
|
||||
self.game("wait 2")?;
|
||||
let mut screen = self.game("screen")?;
|
||||
screen["ok"] = json!(true);
|
||||
Ok(screen)
|
||||
}
|
||||
"hold" => {
|
||||
let (key, seconds) = match words {
|
||||
[_, key, _] => (key.clone(), number(2, "SECONDS")?),
|
||||
@@ -183,7 +212,7 @@ impl Session {
|
||||
thread::sleep(Duration::from_secs_f64(seconds.max(0.0)));
|
||||
self.game(&format!("key {key} up"))
|
||||
}
|
||||
"logs" => logs(&horizon_client::logging::game_home(), &words[1..]),
|
||||
"logs" => logs(&self.home, &words[1..]),
|
||||
"wait-ready" => {
|
||||
let limit = if words.len() > 1 {
|
||||
number(1, "SECONDS")?
|
||||
@@ -417,6 +446,10 @@ fn main() -> ExitCode {
|
||||
print!("{}", commands::overview());
|
||||
0
|
||||
}
|
||||
Some("help") if args.get(1).is_some_and(|a| a == "--markdown") => {
|
||||
print!("{}", commands::markdown());
|
||||
0
|
||||
}
|
||||
Some("help") if args.get(1).is_none_or(|a| a != "--game") => match args.get(1) {
|
||||
None => {
|
||||
print!("{}", commands::overview());
|
||||
@@ -451,6 +484,10 @@ fn main() -> ExitCode {
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("mcp") if session.allowed().is_err() => {
|
||||
eprintln!("horizon-ctl mcp: {}", horizon_core::no_ai::REFUSAL);
|
||||
DENIED
|
||||
}
|
||||
Some("mcp") => match mcp::serve(&mut session) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
@@ -581,6 +618,27 @@ mod tests {
|
||||
assert_eq!(f.status, USAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_ai_refuses_every_command() {
|
||||
let home = std::env::temp_dir().join(format!("horizon-ctl-no-ai-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-nobody.sock"), None);
|
||||
s.home = home.clone();
|
||||
assert_eq!(
|
||||
s.run(&["state".to_owned()]).unwrap_err().status,
|
||||
UNREACHABLE
|
||||
);
|
||||
horizon_core::no_ai::set(&home, true).unwrap();
|
||||
for words in [vec!["state"], vec!["logs"], vec!["launch", "--world", "A"]] {
|
||||
let words: Vec<String> = words.into_iter().map(str::to_owned).collect();
|
||||
let f = s.run(&words).unwrap_err();
|
||||
assert_eq!(f.status, DENIED, "{words:?}");
|
||||
assert_eq!(f.reply["kind"], "denied");
|
||||
assert_eq!(f.reply["error"], horizon_core::no_ai::REFUSAL);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_paths_become_absolute() {
|
||||
let mut words = vec!["screenshot".to_owned(), "a.png".to_owned()];
|
||||
|
||||
@@ -23,7 +23,13 @@ fn tools() -> Vec<Value> {
|
||||
schema = json!({ "type": "object", "properties": {} });
|
||||
description = "Capture the game window and return it as a PNG image.".into();
|
||||
}
|
||||
json!({ "name": commands::tool_name(c), "description": description, "inputSchema": schema })
|
||||
let mut tool = json!({ "name": commands::tool_name(c), "description": description, "inputSchema": schema });
|
||||
if commands::DESTRUCTIVE.contains(&c.name) {
|
||||
tool["annotations"] = json!({ "destructiveHint": true });
|
||||
} else if commands::OPEN_WORLD.contains(&c.name) {
|
||||
tool["annotations"] = json!({ "openWorldHint": true });
|
||||
}
|
||||
tool
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -144,9 +144,6 @@ impl BlockEntities {
|
||||
pub fn set_states(&mut self, states: Vec<Ticking>) {
|
||||
self.states = states;
|
||||
}
|
||||
pub fn take_states(&mut self) -> Vec<Ticking> {
|
||||
std::mem::take(&mut self.states)
|
||||
}
|
||||
pub fn ticking(&self, state: u32) -> Ticking {
|
||||
self.states.get(state as usize).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -382,10 +382,6 @@ impl Shulker {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `getProgress(a)`: `Mth.lerp(a, progressOld, progress)`.
|
||||
pub fn get_progress(&self, a: f32) -> f32 {
|
||||
self.progress_old + a * (self.progress - self.progress_old)
|
||||
}
|
||||
/// `ShulkerBoxBlock.canOpen`: a closed box only opens when its lid has room
|
||||
/// (`level.noCollision` of the half-block lid box, deflated by 1e-6).
|
||||
pub fn lid_box(pos: [i32; 3], facing: u8) -> [f64; 6] {
|
||||
@@ -668,14 +664,6 @@ impl Skull {
|
||||
self.animating = false;
|
||||
}
|
||||
}
|
||||
/// `getAnimation(a)`.
|
||||
pub fn get_animation(&self, a: f32) -> f32 {
|
||||
if self.animating {
|
||||
self.tick_count as f32 + a
|
||||
} else {
|
||||
self.tick_count as f32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `DecoratedPotBlockEntity.WobbleStyle`.
|
||||
|
||||
@@ -875,13 +875,6 @@ impl BlockEntityRenderer {
|
||||
visibility: None,
|
||||
}
|
||||
}
|
||||
/// The standing banner's pole-and-bar and flag models (`BannerRenderer.submitSpecial`).
|
||||
pub fn banner_models(&self) -> Option<(&ModelPart, &ModelPart)> {
|
||||
Some((
|
||||
self.models.get("standing_banner")?,
|
||||
self.models.get("standing_banner_flag")?,
|
||||
))
|
||||
}
|
||||
fn kind(&self, state: u32) -> &Kind {
|
||||
self.kinds.get(state as usize).unwrap_or(&Kind::None)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ impl BossBar {
|
||||
style: "progress".into(),
|
||||
value: 0,
|
||||
max: 100,
|
||||
visible: false,
|
||||
visible: true,
|
||||
player: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,10 +169,6 @@ impl TickRateManager {
|
||||
self.frozen_ticks_to_run -= 1;
|
||||
}
|
||||
}
|
||||
/// `isEntityFrozen` for an entity that is not (and does not carry) a player.
|
||||
pub fn is_entity_frozen(&self, is_player_or_carries_player: bool) -> bool {
|
||||
!self.runs_normally() && !is_player_or_carries_player
|
||||
}
|
||||
}
|
||||
|
||||
/// What `/tick sprint` reports when it ends.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Entity-dependent rules use the defaults for players and the supported mobs.
|
||||
use crate::{Result, world::World};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3, VoxelShape},
|
||||
geometry::{Aabb, Vec3, VoxelShape, border::BorderGeometry},
|
||||
position::{BlockPos, ChunkPos},
|
||||
};
|
||||
use horizon_world::{
|
||||
@@ -64,6 +64,9 @@ pub struct SceneCollisions<'w, 'r> {
|
||||
pub struct EntityCollisions<'a, W> {
|
||||
pub blocks: W,
|
||||
pub entities: &'a [Aabb],
|
||||
/// The world border and the moving entity's x and z
|
||||
/// (`CollisionGetter.borderCollision`); None: the blocks' answer.
|
||||
pub border: Option<(&'a BorderGeometry, [f64; 2])>,
|
||||
}
|
||||
impl<W: BlockCollisionWorld> BlockCollisionWorld for EntityCollisions<'_, W> {
|
||||
type Error = W::Error;
|
||||
@@ -114,12 +117,20 @@ impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCo
|
||||
}
|
||||
Ok(shapes)
|
||||
}
|
||||
/// `WorldBorder.isInsideCloseToBorder`: an entity inside the border and
|
||||
/// near it collides with its walls.
|
||||
fn movement_border(
|
||||
&mut self,
|
||||
entity: EntityIdentity,
|
||||
bounds: Aabb,
|
||||
) -> io::Result<Option<VoxelShape>> {
|
||||
self.blocks.movement_border(entity, bounds)
|
||||
let Some((border, [x, z])) = self.border else {
|
||||
return self.blocks.movement_border(entity, bounds);
|
||||
};
|
||||
if !border.is_inside_close_to_border(x, z, bounds) {
|
||||
return Ok(None);
|
||||
}
|
||||
border.collision_shape().map(Some).map_err(io::Error::other)
|
||||
}
|
||||
}
|
||||
impl SceneCollisions<'_, '_> {
|
||||
|
||||
@@ -812,14 +812,6 @@ pub fn java_parse_int(s: &str) -> Option<i32> {
|
||||
}
|
||||
s.parse().ok()
|
||||
}
|
||||
/// Long.parseLong.
|
||||
pub fn java_parse_long(s: &str) -> Option<i64> {
|
||||
let digits = s.strip_prefix(['-', '+']).unwrap_or(s);
|
||||
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok()
|
||||
}
|
||||
/// Double.parseDouble over the characters StringReader reads as a number.
|
||||
pub fn java_parse_double(s: &str) -> Option<f64> {
|
||||
let t = s.trim_matches(|c: char| c <= ' ');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! ComponentSerialization.CODEC and Style.Serializer.MAP_CODEC decoding of
|
||||
//! SNBT text components, with the data-fixer-upper error messages
|
||||
//! (ComponentArgument, StyleArgument and component-valued item data).
|
||||
use super::codec::{get, no_key, not_list, not_map, snbt};
|
||||
use super::codec::{get, no_key, not_list, not_map};
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
|
||||
/// A DataResult error: its message and whether it carries a partial result.
|
||||
@@ -362,7 +362,3 @@ pub fn decode_style(t: &Tag) -> Result<(), String> {
|
||||
_ => Err(not_map(t)),
|
||||
}
|
||||
}
|
||||
#[allow(unused)]
|
||||
fn _keep(t: &Tag) -> String {
|
||||
snbt(t)
|
||||
}
|
||||
@@ -632,10 +632,6 @@ pub fn parse_predicate(reader: &mut Reader, reg: &Registries) -> CmdResult<ItemI
|
||||
pub fn suggest_predicate(builder: &mut SuggestionsBuilder, reg: &Registries) {
|
||||
PREDICATE.with(|g| g.parse_for_suggestions_with(builder, Some(reg)));
|
||||
}
|
||||
#[allow(unused)]
|
||||
fn _unused(t: &Tag) -> String {
|
||||
snbt(t)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -183,9 +183,6 @@ impl<'a> Reader<'a> {
|
||||
pub fn read_int(&mut self) -> CmdResult<i32> {
|
||||
self.number("int")
|
||||
}
|
||||
pub fn read_long(&mut self) -> CmdResult<i64> {
|
||||
self.number("long")
|
||||
}
|
||||
pub fn read_double(&mut self) -> CmdResult<f64> {
|
||||
self.number("double")
|
||||
}
|
||||
@@ -222,21 +219,6 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
Err(CommandError::tr_at("parsing.quote.expected.end", &[], self))
|
||||
}
|
||||
pub fn read_quoted_string(&mut self) -> CmdResult<String> {
|
||||
if !self.can_read() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let next = self.peek();
|
||||
if next != '"' && next != '\'' {
|
||||
return Err(CommandError::tr_at(
|
||||
"parsing.quote.expected.start",
|
||||
&[],
|
||||
self,
|
||||
));
|
||||
}
|
||||
self.skip();
|
||||
self.read_string_until(next)
|
||||
}
|
||||
pub fn read_string(&mut self) -> CmdResult<String> {
|
||||
if !self.can_read() {
|
||||
return Ok(String::new());
|
||||
@@ -539,18 +521,6 @@ impl<'a> SuggestionsBuilder<'a> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The same with a tooltip per candidate.
|
||||
pub fn suggest_matching_tooltip<'b>(
|
||||
&mut self,
|
||||
candidates: impl IntoIterator<Item = (&'b str, String)>,
|
||||
) {
|
||||
let lower = self.remaining_lower();
|
||||
for (c, tip) in candidates {
|
||||
if args::matches_sub_str(&lower, &c.to_lowercase()) {
|
||||
self.suggest_tooltip(c, tip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// String.compareToIgnoreCase.
|
||||
pub fn compare_ignore_case(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! SnbtGrammar and TagParser: vanilla's packrat SNBT grammar, with its
|
||||
//! error messages and completion suggestions, producing NBT tags.
|
||||
use super::packrat::{
|
||||
Grammar, Reason, Rule, State, Sugg, Term, Value, alt, ch, chs, look, opt, seq,
|
||||
};
|
||||
use super::packrat::{Grammar, Reason, State, Sugg, Term, Value, alt, ch, chs, look, opt, seq};
|
||||
use super::{CmdResult, CommandError, Reader, SuggestionsBuilder};
|
||||
use horizon_world::nbt::{Compound, JavaString, Tag};
|
||||
use std::rc::Rc;
|
||||
@@ -981,5 +979,3 @@ pub fn rule_in<V: super::packrat::Value>(
|
||||
}
|
||||
}
|
||||
}
|
||||
#[allow(unused)]
|
||||
fn _rule_kind(_: Rule<SV>) {}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! mode keywords are read back from the parsed literals.
|
||||
use super::{
|
||||
Dispatcher,
|
||||
args::{ArgType, GameMode, Resource, integer},
|
||||
args::{ArgType, Resource, integer},
|
||||
};
|
||||
use crate::gamerules::{RULES, Value};
|
||||
|
||||
@@ -509,10 +509,6 @@ fn clone_mode(d: &mut Dispatcher, node: usize) {
|
||||
d.executes(l, "clone");
|
||||
}
|
||||
}
|
||||
/// Game mode names for selector `gamemode=` and the argument.
|
||||
pub fn game_modes() -> [&'static str; 4] {
|
||||
GameMode::ALL.map(GameMode::name)
|
||||
}
|
||||
/// The shared vanilla tree.
|
||||
/// Reorder node children as vanilla registers them (Brigadier keeps
|
||||
/// children in registration order; smart usage lists them that way).
|
||||
@@ -559,5 +555,3 @@ pub fn release_dispatcher() -> &'static Dispatcher {
|
||||
static TREE: std::sync::OnceLock<Dispatcher> = std::sync::OnceLock::new();
|
||||
TREE.get_or_init(|| build_with(false))
|
||||
}
|
||||
#[allow(unused)]
|
||||
const _: (u8, u8) = (ALL, GAMEMASTERS);
|
||||
@@ -167,6 +167,7 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
style.underlined = flag("underlined");
|
||||
style.strikethrough = flag("strikethrough");
|
||||
style.obfuscated = flag("obfuscated");
|
||||
style.font = o.get("font").and_then(crate::text::font_id);
|
||||
style
|
||||
}
|
||||
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
|
||||
@@ -266,4 +267,26 @@ mod tests {
|
||||
let (v, _) = parse_snbt("[\"a\",{\"text\":\"b\"}]").unwrap();
|
||||
assert_eq!(to_text(&v, &mut |_| Text::empty()).plain(), "ab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_is_inherited_and_kept() {
|
||||
let (v, _) =
|
||||
parse_snbt(r#"{text:"a",font:"alt",extra:["b",{text:"c",font:"minecraft:uniform"}]}"#)
|
||||
.unwrap();
|
||||
let t = to_text(&v, &mut |_| Text::empty());
|
||||
let fonts: Vec<_> = t.chars().map(|(_, s)| s.font.clone()).collect();
|
||||
let (alt, uniform) = (
|
||||
Some("minecraft:alt".into()),
|
||||
Some("minecraft:uniform".into()),
|
||||
);
|
||||
assert_eq!(fonts, [alt.clone(), alt, uniform]);
|
||||
// Back to JSON with the field, which a re-read keeps.
|
||||
let again = to_text(&t.component(), &mut |_| Text::empty());
|
||||
let kept: Vec<_> = again.chars().map(|(_, s)| s.font.clone()).collect();
|
||||
assert_eq!(kept, fonts);
|
||||
// Not an identifier: no font.
|
||||
let (v, _) = parse_snbt(r#"{text:"a",font:"Bad Font"}"#).unwrap();
|
||||
let t = to_text(&v, &mut |_| Text::empty());
|
||||
assert_eq!(t.spans[0].style.font, None);
|
||||
}
|
||||
}
|
||||
@@ -252,24 +252,6 @@ impl BlockEntity {
|
||||
};
|
||||
Some(Self::Chest(vec![None; size]))
|
||||
}
|
||||
/// The vanilla block entity type id of a container block.
|
||||
pub fn id_for_block(name: &str) -> Option<&'static str> {
|
||||
Some(match name {
|
||||
"minecraft:chest" => "minecraft:chest",
|
||||
n if n.ends_with("copper_chest") => "minecraft:chest",
|
||||
"minecraft:trapped_chest" => "minecraft:trapped_chest",
|
||||
"minecraft:barrel" => "minecraft:barrel",
|
||||
"minecraft:hopper" => "minecraft:hopper",
|
||||
"minecraft:dispenser" => "minecraft:dispenser",
|
||||
"minecraft:dropper" => "minecraft:dropper",
|
||||
"minecraft:furnace" => "minecraft:furnace",
|
||||
"minecraft:blast_furnace" => "minecraft:blast_furnace",
|
||||
"minecraft:smoker" => "minecraft:smoker",
|
||||
n if n.ends_with("shulker_box") => "minecraft:shulker_box",
|
||||
"minecraft:crafter" => "minecraft:crafter",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Chest(items) => match items.len() {
|
||||
@@ -528,7 +510,7 @@ impl Furnace {
|
||||
let has_fuel = self.items[1].is_some();
|
||||
if lit || has_fuel && has_ingredient {
|
||||
if let Some(ingredient) = self.items[0].clone() {
|
||||
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient).cloned() {
|
||||
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
|
||||
if self.cooking_timer == 0 {
|
||||
self.cooking_total = total_time(recipe.time, self.speed);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@ use std::{
|
||||
io::{self, BufRead, BufReader, Read, Write},
|
||||
net::{Ipv4Addr, TcpListener, TcpStream},
|
||||
path::{Path, PathBuf},
|
||||
sync::mpsc,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
thread,
|
||||
};
|
||||
|
||||
@@ -151,6 +155,12 @@ pub struct Server {
|
||||
path: PathBuf,
|
||||
requests: mpsc::Receiver<Request>,
|
||||
sender: mpsc::Sender<Request>,
|
||||
/// Clients connected now (an MCP server keeps one open; horizon-ctl one per command).
|
||||
clients: Arc<AtomicUsize>,
|
||||
/// Set when the server is dropped: the listener stops accepting and exits.
|
||||
closed: Arc<AtomicBool>,
|
||||
/// The TCP port, to wake the listener when closing.
|
||||
port: Option<u16>,
|
||||
}
|
||||
|
||||
/// `$HORIZON_CONTROL`, else `horizon-control.sock` in the user's runtime
|
||||
@@ -174,10 +184,15 @@ impl Server {
|
||||
}
|
||||
let (sender, requests) = mpsc::channel();
|
||||
let local = sender.clone();
|
||||
let clients = Arc::new(AtomicUsize::new(0));
|
||||
let counted = clients.clone();
|
||||
let closed = Arc::new(AtomicBool::new(false));
|
||||
let mut tcp_port = None;
|
||||
if uses_tcp(path) {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
let token = random_token();
|
||||
let port = listener.local_addr()?.port();
|
||||
tcp_port = Some(port);
|
||||
write_private(
|
||||
path,
|
||||
&serde_json::json!({ "transport": "tcp", "port": port, "token": token })
|
||||
@@ -186,6 +201,8 @@ impl Server {
|
||||
spawn_listener(
|
||||
move || listener.accept().map(|(s, _)| s),
|
||||
sender,
|
||||
counted,
|
||||
closed.clone(),
|
||||
move |stream: TcpStream| {
|
||||
let _ = stream.set_nodelay(true);
|
||||
let mut stream = Stream::Tcp(stream);
|
||||
@@ -199,6 +216,8 @@ impl Server {
|
||||
spawn_listener(
|
||||
move || listener.accept().map(|(s, _)| s),
|
||||
sender,
|
||||
counted,
|
||||
closed.clone(),
|
||||
|s| Some(Stream::Unix(s)),
|
||||
)?;
|
||||
}
|
||||
@@ -207,6 +226,9 @@ impl Server {
|
||||
path: path.to_owned(),
|
||||
requests,
|
||||
sender: local,
|
||||
clients,
|
||||
closed,
|
||||
port: tcp_port,
|
||||
})
|
||||
}
|
||||
/// Commands from inside the process only (`--benchmark` without `--control`).
|
||||
@@ -216,8 +238,15 @@ impl Server {
|
||||
path: PathBuf::new(),
|
||||
requests,
|
||||
sender,
|
||||
clients: Arc::default(),
|
||||
closed: Arc::default(),
|
||||
port: None,
|
||||
}
|
||||
}
|
||||
/// How many clients are connected.
|
||||
pub fn clients(&self) -> usize {
|
||||
self.clients.load(Ordering::Relaxed)
|
||||
}
|
||||
/// Sends commands as a socket client would, from another thread.
|
||||
pub fn sender(&self) -> mpsc::Sender<Request> {
|
||||
self.sender.clone()
|
||||
@@ -234,6 +263,8 @@ impl Server {
|
||||
fn spawn_listener<S: Send + 'static>(
|
||||
mut accept_next: impl FnMut() -> io::Result<S> + Send + 'static,
|
||||
sender: mpsc::Sender<Request>,
|
||||
clients: Arc<AtomicUsize>,
|
||||
closed: Arc<AtomicBool>,
|
||||
accept: impl Fn(S) -> Option<Stream> + Send + Sync + 'static,
|
||||
) -> io::Result<()> {
|
||||
let accept = std::sync::Arc::new(accept);
|
||||
@@ -241,17 +272,24 @@ fn spawn_listener<S: Send + 'static>(
|
||||
.name("horizon-control".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
let Ok(stream) = accept_next() else {
|
||||
let next = accept_next();
|
||||
if closed.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let Ok(stream) = next else {
|
||||
thread::sleep(std::time::Duration::from_millis(10));
|
||||
continue;
|
||||
};
|
||||
let sender = sender.clone();
|
||||
let accept = accept.clone();
|
||||
let clients = clients.clone();
|
||||
let _ = thread::Builder::new()
|
||||
.name("horizon-control-client".into())
|
||||
.spawn(move || {
|
||||
if let Some(stream) = accept(stream) {
|
||||
clients.fetch_add(1, Ordering::Relaxed);
|
||||
serve(stream, &sender);
|
||||
clients.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -287,6 +325,18 @@ fn authenticate(stream: &mut Stream, token: &str) -> bool {
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
if self.path.as_os_str().is_empty() {
|
||||
return;
|
||||
}
|
||||
// Wake the listener so it sees `closed` and exits (agent control turned off in-game).
|
||||
self.closed.store(true, Ordering::Relaxed);
|
||||
match self.port {
|
||||
Some(port) => drop(TcpStream::connect((Ipv4Addr::LOCALHOST, port))),
|
||||
#[cfg(unix)]
|
||||
None => drop(UnixStream::connect(&self.path)),
|
||||
#[cfg(not(unix))]
|
||||
None => {}
|
||||
}
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1102,7 +1102,11 @@ pub struct Recipes {
|
||||
facts: HashMap<Arc<str>, Facts>,
|
||||
/// The `#minecraft:curse` enchantments.
|
||||
curses: HashSet<String>,
|
||||
/// The first cooking recipe of each kind taking each item, built at the
|
||||
/// first lookup (furnaces look theirs up every tick).
|
||||
cook_index: std::sync::OnceLock<CookIndex>,
|
||||
}
|
||||
type CookIndex = HashMap<String, crate::fast_hash::FxHashMap<String, usize>>;
|
||||
/// `MinMaxBounds.Ints` as (min, max) with the given defaults.
|
||||
fn bounds(v: Option<&Value>, default: (i64, i64), open: (i64, i64)) -> (i64, i64) {
|
||||
match v {
|
||||
@@ -1421,6 +1425,7 @@ impl Recipes {
|
||||
meta,
|
||||
facts,
|
||||
curses,
|
||||
cook_index: Default::default(),
|
||||
})
|
||||
}
|
||||
/// Match a `size`x`size` grid (row-major) against the crafting recipes.
|
||||
@@ -1606,6 +1611,22 @@ impl Recipes {
|
||||
out
|
||||
}
|
||||
pub fn cook(&self, kind: &str, input: &ItemStack) -> Option<&Cooking> {
|
||||
let index = self.cook_index.get_or_init(|| {
|
||||
let mut index = CookIndex::new();
|
||||
for (i, c) in self.cooking.iter().enumerate() {
|
||||
let of_kind = index.entry(c.kind.clone()).or_default();
|
||||
for item in &c.ingredient.0 {
|
||||
of_kind.entry(item.clone()).or_insert(i);
|
||||
}
|
||||
}
|
||||
index
|
||||
});
|
||||
let i = *index.get(kind)?.get(&*input.item)?;
|
||||
self.cooking.get(i)
|
||||
}
|
||||
/// `cook` by scanning every recipe, as it was before the index.
|
||||
#[cfg(test)]
|
||||
fn cook_scan(&self, kind: &str, input: &ItemStack) -> Option<&Cooking> {
|
||||
self.cooking
|
||||
.iter()
|
||||
.find(|c| c.kind == kind && c.ingredient.test(input))
|
||||
@@ -1625,6 +1646,80 @@ mod tests {
|
||||
s.data_mut().banner_patterns = vec![BannerLayer::new("minecraft:cross", "red")];
|
||||
s
|
||||
}
|
||||
/// About vanilla's count of cooking recipes: four kinds, ingredients of
|
||||
/// one to twelve items, overlapping (several recipes take some items).
|
||||
fn cooking_book() -> Recipes {
|
||||
let kinds = ["smelting", "blasting", "smoking", "campfire_cooking"];
|
||||
let cooking = (0..320usize)
|
||||
.map(|i| Cooking {
|
||||
id: format!("r{i}"),
|
||||
category: "misc".into(),
|
||||
group: String::new(),
|
||||
kind: kinds[i % 4].into(),
|
||||
ingredient: ingredient(
|
||||
&(0..1 + i % 12)
|
||||
.map(|k| format!("minecraft:item_{}", (i * 7 + k * 13) % 400))
|
||||
.collect::<Vec<_>>()
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
result: ItemStack::new("minecraft:stone", 1),
|
||||
experience: 0.1,
|
||||
time: 200,
|
||||
})
|
||||
.collect();
|
||||
Recipes {
|
||||
crafting: Vec::new(),
|
||||
cooking,
|
||||
meta: HashMap::new(),
|
||||
facts: HashMap::new(),
|
||||
curses: HashSet::new(),
|
||||
cook_index: Default::default(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn cook_finds_the_first_recipe_the_scan_finds() {
|
||||
let recipes = cooking_book();
|
||||
for kind in [
|
||||
"smelting",
|
||||
"blasting",
|
||||
"smoking",
|
||||
"campfire_cooking",
|
||||
"other",
|
||||
] {
|
||||
for n in 0..420 {
|
||||
let stack = ItemStack::new(&format!("minecraft:item_{n}"), 1);
|
||||
let a = recipes.cook(kind, &stack).map(|c| &c.id);
|
||||
let b = recipes.cook_scan(kind, &stack).map(|c| &c.id);
|
||||
assert_eq!(a, b, "{kind} item_{n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A furnace's recipe lookup, once per lit furnace per tick.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn cook_cost() {
|
||||
let recipes = cooking_book();
|
||||
let stacks: Vec<ItemStack> = (0..1000)
|
||||
.map(|n| ItemStack::new(&format!("minecraft:item_{}", n % 400), 1))
|
||||
.collect();
|
||||
let t = std::time::Instant::now();
|
||||
let a = stacks
|
||||
.iter()
|
||||
.filter(|s| recipes.cook_scan("smelting", s).cloned().is_some())
|
||||
.count();
|
||||
let before = t.elapsed().as_secs_f64() * 1e3;
|
||||
recipes.cook("smelting", &stacks[0]);
|
||||
let t = std::time::Instant::now();
|
||||
let b = stacks
|
||||
.iter()
|
||||
.filter(|s| recipes.cook("smelting", s).is_some())
|
||||
.count();
|
||||
let after = t.elapsed().as_secs_f64() * 1e3;
|
||||
assert_eq!(a, b);
|
||||
println!("1000 furnace lookups (scan and clone -> index): {before:.3} ms -> {after:.3} ms");
|
||||
}
|
||||
fn run(special: &Special, cells: &[ItemStack]) -> Option<ItemStack> {
|
||||
let recipes = Recipes {
|
||||
crafting: Vec::new(),
|
||||
@@ -1632,6 +1727,7 @@ mod tests {
|
||||
meta: HashMap::new(),
|
||||
facts: HashMap::new(),
|
||||
curses: HashSet::new(),
|
||||
cook_index: Default::default(),
|
||||
};
|
||||
let ctx = Context {
|
||||
recipes: &recipes,
|
||||
|
||||
@@ -44,6 +44,7 @@ pub struct Creative {
|
||||
search_index: Vec<SearchEntry>,
|
||||
}
|
||||
/// Display names from the pinned en_us language file.
|
||||
#[derive(Default)]
|
||||
pub struct Names {
|
||||
map: HashMap<String, String>,
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@ use std::{
|
||||
pub const CAPACITY: usize = 240;
|
||||
/// `AbstractDebugChart.CHART_HEIGHT`.
|
||||
pub const CHART_HEIGHT: i32 = 60;
|
||||
/// `TpsDebugDimensions`: a tick sample's values.
|
||||
pub const FULL_TICK: usize = 0;
|
||||
pub const TICK_SERVER_METHOD: usize = 1;
|
||||
pub const SCHEDULED_TASKS: usize = 2;
|
||||
pub const IDLE: usize = 3;
|
||||
@@ -252,10 +250,6 @@ impl Chart {
|
||||
pub fn width(max_width: i32) -> i32 {
|
||||
(CAPACITY as i32 + 2).min(max_width)
|
||||
}
|
||||
/// `getFullHeight`: the box and the min/avg/max line above it.
|
||||
pub fn full_height() -> i32 {
|
||||
CHART_HEIGHT + 9
|
||||
}
|
||||
/// `AbstractDebugChart.extractRenderState`.
|
||||
pub fn draw(
|
||||
self,
|
||||
|
||||
@@ -60,6 +60,7 @@ pub struct ItemProps {
|
||||
/// `minecraft:brewing_fuel` present.
|
||||
pub brewing_fuel: bool,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct Enchantments {
|
||||
pub list: Vec<Enchantment>,
|
||||
index: HashMap<Arc<str>, usize>,
|
||||
@@ -192,6 +193,8 @@ impl Enchantments {
|
||||
|| k.starts_with("item.minecraft.splash_potion")
|
||||
|| k.starts_with("item.minecraft.lingering_potion")
|
||||
|| k.starts_with("item.minecraft.tipped_arrow")
|
||||
|| k.starts_with("item.minecraft.firework_rocket.")
|
||||
|| k.starts_with("item.minecraft.firework_star.")
|
||||
|| k.starts_with("trim_")
|
||||
|| k.starts_with("potion.")
|
||||
|| k.starts_with("container.")
|
||||
@@ -356,10 +359,6 @@ impl Enchantments {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Whether a stack draws the enchantment glint.
|
||||
pub fn has_glint(&self, stack: &ItemStack) -> bool {
|
||||
stack.has_any_enchantments() || self.props(&stack.item).is_some_and(|p| p.glint)
|
||||
}
|
||||
|
||||
// ---- Enchanting table (EnchantmentHelper) ----
|
||||
|
||||
@@ -511,19 +510,6 @@ impl Enchantments {
|
||||
.is_some_and(|e| e.effects.get(component).is_some())
|
||||
})
|
||||
}
|
||||
/// The highest level among enchantments on `stack` that have `component`.
|
||||
pub fn component_level(&self, stack: &ItemStack, component: &str) -> u32 {
|
||||
stack
|
||||
.enchantments()
|
||||
.iter()
|
||||
.filter(|(id, _)| {
|
||||
self.get(id)
|
||||
.is_some_and(|e| e.effects.get(component).is_some())
|
||||
})
|
||||
.map(|(_, l)| *l)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
/// `minecraft:attributes` contributions of `stack`: (attribute, id, amount, operation).
|
||||
pub fn attributes(&self, stack: &ItemStack) -> Vec<(String, String, f64, String)> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -98,12 +98,73 @@ impl ItemEntity {
|
||||
lava_hurt,
|
||||
}
|
||||
}
|
||||
/// Returns false when the item should be removed.
|
||||
/// `ItemEntity.tick` on a server: an item whose box is inside a block
|
||||
/// (one placed where it lay) has no physics this tick
|
||||
/// (`level.noCollision(box.deflate(1.0E-7))`).
|
||||
pub fn inside_block(&self, world: &World, collision: &BlockCollisionRegistry<'_>) -> bool {
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
let inner = self.bounds().deflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
|
||||
!crate::collisions::no_collision_empty(&mut scene, inner)
|
||||
}
|
||||
/// `Entity.moveTowardsClosestSpace` from the middle of the item's box:
|
||||
/// towards the nearest side of its block (north, south, west, east or
|
||||
/// up) without a full collision cube, at `speed` (`nextFloat() * 0.2 +
|
||||
/// 0.1`).
|
||||
pub fn move_towards_closest_space(&mut self, world: &World, facts: &BlockFacts, speed: f32) {
|
||||
let bounds = self.bounds();
|
||||
let at = Vec3::new(
|
||||
self.position.x,
|
||||
(bounds.min_position().y + bounds.max_position().y) / 2.0,
|
||||
self.position.z,
|
||||
);
|
||||
let pos = [
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
];
|
||||
let delta = [
|
||||
at.x - f64::from(pos[0]),
|
||||
at.y - f64::from(pos[1]),
|
||||
at.z - f64::from(pos[2]),
|
||||
];
|
||||
// (axis, step): NORTH, SOUTH, WEST, EAST, UP.
|
||||
let mut closest = (1, 1);
|
||||
let mut nearest = f64::MAX;
|
||||
for (axis, step) in [(2, -1), (2, 1), (0, -1), (0, 1), (1, 1)] {
|
||||
let mut n = pos;
|
||||
n[axis] += step;
|
||||
if !facts.get(world.block(n[0], n[1], n[2])).full_collision {
|
||||
let d = if step > 0 {
|
||||
1.0 - delta[axis]
|
||||
} else {
|
||||
delta[axis]
|
||||
};
|
||||
if d < nearest {
|
||||
nearest = d;
|
||||
closest = (axis, step);
|
||||
}
|
||||
}
|
||||
}
|
||||
let push = f64::from(closest.1 as f32 * speed);
|
||||
let v = self.velocity.scale(0.75);
|
||||
self.velocity = match closest.0 {
|
||||
0 => Vec3::new(push, v.y, v.z),
|
||||
1 => Vec3::new(v.x, push, v.z),
|
||||
_ => Vec3::new(v.x, v.y, push),
|
||||
};
|
||||
}
|
||||
/// Returns false when the item should be removed. `no_physics`: the item
|
||||
/// moves through blocks this tick ([`ItemEntity::inside_block`]).
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
no_physics: bool,
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -140,15 +201,22 @@ impl ItemEntity {
|
||||
subject: None,
|
||||
};
|
||||
let wanted = self.velocity;
|
||||
let moved = scene
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO);
|
||||
// Entity.move with noPhysics: straight through, the ground as it was.
|
||||
let moved = if no_physics {
|
||||
wanted
|
||||
} else {
|
||||
scene
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
};
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
|
||||
if !no_physics {
|
||||
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
|
||||
}
|
||||
if moved.x != wanted.x {
|
||||
self.velocity.x = 0.0;
|
||||
}
|
||||
@@ -583,6 +651,10 @@ pub struct ArmorStand {
|
||||
pub no_base_plate: bool,
|
||||
pub invisible: bool,
|
||||
pub marker: bool,
|
||||
/// `CustomName`: the text of a hologram.
|
||||
pub custom_name: Option<crate::text::Text>,
|
||||
/// `CustomNameVisible`.
|
||||
pub name_visible: bool,
|
||||
/// Game time of the last hit (the wobble and the second-hit window).
|
||||
pub last_hit: i64,
|
||||
}
|
||||
@@ -603,6 +675,8 @@ impl ArmorStand {
|
||||
no_base_plate: false,
|
||||
invisible: false,
|
||||
marker: false,
|
||||
custom_name: None,
|
||||
name_visible: false,
|
||||
last_hit: i64::MIN / 2,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ struct Out {
|
||||
@location(8) back_color: vec4<f32>,
|
||||
// entity.vsh's lightMapColor, multiplied in after the overlay.
|
||||
@location(9) light_color: vec4<f32>,
|
||||
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
|
||||
}
|
||||
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
|
||||
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
|
||||
@@ -109,6 +110,13 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
|
||||
return entity_vertex(position, uv, overlay, color, light, normal);
|
||||
}
|
||||
// Retained bolt-local geometry, translated using the same f64 camera
|
||||
// subtraction as the former CPU path before adding each local f32 position.
|
||||
@vertex fn vs_lightning(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>,
|
||||
@location(6) offset: vec3<f32>) -> Out {
|
||||
return entity_vertex(offset + position, uv, overlay, color, light, normal);
|
||||
}
|
||||
struct CloudInfo {
|
||||
offset: vec4<f32>,
|
||||
colors: array<vec4<f32>, 4>,
|
||||
@@ -209,3 +217,135 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
@fragment fn fs_water_mask() -> @location(0) vec4<f32> {
|
||||
return vec4(0.0);
|
||||
}
|
||||
|
||||
// WEATHER_MATH_BEGIN
|
||||
// Unsigned 64-bit helpers (low word first). Snow adds a random double to a
|
||||
// float drift before casting to float. Retaining the 53-bit base avoids early
|
||||
// rounding, including ties, without requiring optional GPU float64 support.
|
||||
fn weather_shr_jam(v: vec2<u32>, n: u32) -> vec2<u32> {
|
||||
if n == 0u { return v; }
|
||||
if n < 32u {
|
||||
return vec2((v.x >> n) | (v.y << (32u - n)) | select(0u, 1u, (v.x << (32u - n)) != 0u), v.y >> n);
|
||||
}
|
||||
if n == 32u { return vec2(v.y | select(0u, 1u, v.x != 0u), 0u); }
|
||||
if n < 64u {
|
||||
return vec2((v.y >> (n - 32u)) | select(0u, 1u, v.x != 0u || (v.y << (64u - n)) != 0u), 0u);
|
||||
}
|
||||
return vec2(select(0u, 1u, any(v != vec2(0u))), 0u);
|
||||
}
|
||||
fn weather_add64(a: vec2<u32>, b: vec2<u32>) -> vec2<u32> {
|
||||
let lo = a.x + b.x;
|
||||
return vec2(lo, a.y + b.y + select(0u, 1u, lo < a.x));
|
||||
}
|
||||
fn weather_sub64(a: vec2<u32>, b: vec2<u32>) -> vec2<u32> {
|
||||
return vec2(a.x - b.x, a.y - b.y - select(0u, 1u, a.x < b.x));
|
||||
}
|
||||
fn weather_double_plus_float(base: vec2<u32>, delta: f32) -> f32 {
|
||||
// The random base is nonnegative and either zero or >= 2^-53.
|
||||
if all(base == vec2(0u)) { return select(delta, 0.0, delta == 0.0); }
|
||||
let db = bitcast<u32>(delta);
|
||||
let mag = db & 0x7fffffffu;
|
||||
let be = i32((base.y >> 20u) & 2047u) - 1023;
|
||||
var de = i32(mag >> 23u) - 127;
|
||||
var dm = (mag & 0x7fffffu) | 0x800000u;
|
||||
if mag < 0x800000u {
|
||||
dm = mag;
|
||||
if dm == 0u { de = -200; }
|
||||
else {
|
||||
let shift = countLeadingZeros(dm) - 8u;
|
||||
dm <<= shift;
|
||||
de = -126 - i32(shift);
|
||||
}
|
||||
}
|
||||
let e = max(be, de);
|
||||
// Leading significand bit at 61, with room for addition and guard bits.
|
||||
var a = vec2(base.x << 9u, ((base.y & 0xfffffu) | 0x100000u) << 9u | (base.x >> 23u));
|
||||
var b = vec2(0u, dm << 6u);
|
||||
a = weather_shr_jam(a, u32(e - be));
|
||||
b = weather_shr_jam(b, u32(e - de));
|
||||
var sign = 0u;
|
||||
var value: vec2<u32>;
|
||||
if (db >> 31u) == 0u { value = weather_add64(a, b); }
|
||||
else if a.y > b.y || (a.y == b.y && a.x >= b.x) { value = weather_sub64(a, b); }
|
||||
else { value = weather_sub64(b, a); sign = 0x80000000u; }
|
||||
if all(value == vec2(0u)) { return 0.0; }
|
||||
let leading = select(31u - countLeadingZeros(value.x), 63u - countLeadingZeros(value.y), value.y != 0u);
|
||||
var exponent = e + i32(leading) - 61;
|
||||
// Normalize the leading bit to 62. After jamming, bit 38 is the guard
|
||||
// bit and bits 0..37 distinguish a tie from a value above the midpoint.
|
||||
let shift = 62u - leading;
|
||||
if shift >= 32u { value = vec2(0u, value.x << (shift - 32u)); }
|
||||
else if shift != 0u { value = vec2(value.x << shift, (value.y << shift) | (value.x >> (32u - shift))); }
|
||||
// Vanilla first rounds the addition to double, then casts to float.
|
||||
// Round at bit 10 (53 retained bits) before the 24-bit rounding below;
|
||||
// otherwise a double-rounding midpoint can select the wrong float.
|
||||
let double_guard = (value.x & 512u) != 0u;
|
||||
let double_sticky = (value.x & 511u) != 0u;
|
||||
let double_odd = (value.x & 1024u) != 0u;
|
||||
value.x &= 0xfffffc00u;
|
||||
if double_guard && (double_sticky || double_odd) {
|
||||
value = weather_add64(value, vec2(1024u, 0u));
|
||||
}
|
||||
if (value.y & 0x80000000u) != 0u {
|
||||
value = weather_shr_jam(value, 1u);
|
||||
exponent += 1;
|
||||
}
|
||||
var mantissa = value.y >> 7u;
|
||||
let guard = (value.y & 64u) != 0u;
|
||||
let sticky = (value.y & 63u) != 0u || value.x != 0u;
|
||||
if guard && (sticky || (mantissa & 1u) != 0u) { mantissa += 1u; }
|
||||
if mantissa >= 0x1000000u { mantissa >>= 1u; exponent += 1; }
|
||||
return bitcast<f32>(sign | (u32(exponent + 127) << 23u) | (mantissa & 0x7fffffu));
|
||||
}
|
||||
fn weather_phase(animation: vec4<f32>, bases: vec4<u32>, snow: u32, time: vec4<f32>) -> vec2<f32> {
|
||||
if snow == 0u {
|
||||
let ticks = time.x + animation.y;
|
||||
let t = ticks + time.z;
|
||||
let step = -t / 32.0;
|
||||
let offset = step * animation.x;
|
||||
return vec2(0.0, offset % 32.0);
|
||||
}
|
||||
let t = time.x + time.z;
|
||||
let u_time = t * 0.01;
|
||||
let du = u_time * animation.z;
|
||||
let v_time = t * animation.w;
|
||||
let dv = v_time * 0.001;
|
||||
let u = weather_double_plus_float(bases.xy, du);
|
||||
let v = weather_double_plus_float(bases.zw, dv);
|
||||
let fall_time = time.y + time.z;
|
||||
let fall = -fall_time / 512.0;
|
||||
return vec2(u, fall + v);
|
||||
}
|
||||
// Atlas sampler is nearest with no mipmaps. Clamp the integer texel inside
|
||||
// the sprite even when a fractional coordinate rounds up at its far edge.
|
||||
fn weather_texel(uv: vec2<f32>, rect: vec4<f32>, dimensions: vec2<u32>) -> vec2<i32> {
|
||||
let size = vec2<f32>(dimensions);
|
||||
let origin = vec2<i32>(round(rect.xy * size));
|
||||
let extent = max(vec2<i32>(round(rect.zw * size)), vec2(1));
|
||||
let local = min(vec2<i32>(floor(fract(uv) * vec2<f32>(extent))), extent - vec2(1));
|
||||
return origin + local;
|
||||
}
|
||||
// WEATHER_MATH_END
|
||||
|
||||
struct WeatherTime { values: vec4<f32> }
|
||||
@group(1) @binding(1) var<uniform> weather_time: WeatherTime;
|
||||
@vertex fn vs_weather(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) rect: vec4<f32>, @location(3) animation: vec4<f32>,
|
||||
@location(4) bases: vec4<u32>, @location(5) color: vec4<f32>,
|
||||
@location(6) light: vec4<u32>, @location(7) snow: u32) -> Out {
|
||||
let phase = weather_phase(animation, bases, snow, weather_time.values);
|
||||
var out = entity_vertex(position, uv + phase, 0u, color, light, vec4(0.0));
|
||||
out.weather_rect = rect;
|
||||
return out;
|
||||
}
|
||||
@fragment fn fs_weather(in: Out) -> @location(0) vec4<f32> {
|
||||
// Repeat within this sprite, never across the atlas. The atlas uses nearest
|
||||
// filtering and no mipmaps, so fractional UVs have no seam derivatives.
|
||||
let texel = weather_texel(in.uv, in.weather_rect, textureDimensions(atlas));
|
||||
var color = textureLoad(atlas, texel, 0) * in.color;
|
||||
if color.a < 0.1 { discard; }
|
||||
color *= in.light_color;
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use crate::{
|
||||
doubles, float, floats, item_from_nbt, item_to_nbt, number, put, read_doubles, text,
|
||||
uuid_from_tag,
|
||||
},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
@@ -88,6 +89,47 @@ impl Base<'_> {
|
||||
}
|
||||
|
||||
/// `LivingEntity` + `Mob` (+ `AgeableMob`, creeper and skeleton specifics).
|
||||
/// The mobs whose class extends vanilla's `Animal` (and so save `InLove`):
|
||||
/// not allays, villagers, golems, dolphins or fish.
|
||||
fn is_animal(id: &str) -> bool {
|
||||
matches!(
|
||||
id.strip_prefix("minecraft:").unwrap_or(id),
|
||||
"pig"
|
||||
| "cow"
|
||||
| "mooshroom"
|
||||
| "sheep"
|
||||
| "chicken"
|
||||
| "rabbit"
|
||||
| "horse"
|
||||
| "donkey"
|
||||
| "mule"
|
||||
| "zombie_horse"
|
||||
| "skeleton_horse"
|
||||
| "llama"
|
||||
| "trader_llama"
|
||||
| "goat"
|
||||
| "panda"
|
||||
| "polar_bear"
|
||||
| "fox"
|
||||
| "wolf"
|
||||
| "cat"
|
||||
| "ocelot"
|
||||
| "parrot"
|
||||
| "bee"
|
||||
| "turtle"
|
||||
| "armadillo"
|
||||
| "frog"
|
||||
| "axolotl"
|
||||
| "hoglin"
|
||||
| "strider"
|
||||
| "camel"
|
||||
| "camel_husk"
|
||||
| "sniffer"
|
||||
| "happy_ghast"
|
||||
| "nautilus"
|
||||
| "zombie_nautilus"
|
||||
)
|
||||
}
|
||||
pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let base = Base {
|
||||
id: &m.kind.id,
|
||||
@@ -174,12 +216,15 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
for (key, value) in [
|
||||
("Age", Tag::Int(0)),
|
||||
("ForcedAge", Tag::Int(0)),
|
||||
("InLove", Tag::Int(0)),
|
||||
("AgeLocked", Tag::Byte(0)),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
|
||||
if is_animal(id) {
|
||||
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
|
||||
}
|
||||
match id {
|
||||
"minecraft:creeper" => {
|
||||
for (key, value) in [("Fuse", Tag::Short(30)), ("ExplosionRadius", Tag::Byte(3))] {
|
||||
@@ -444,10 +489,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
// Sheep.addAdditionalSaveData: its wool as it is now.
|
||||
"minecraft:sheep" => {
|
||||
for (key, value) in [("Color", Tag::Byte(0)), ("Sheared", Tag::Byte(0))] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
map.insert("Color".into(), Tag::Byte((m.extra.color & 15) as i8));
|
||||
map.insert("Sheared".into(), Tag::Byte(i8::from(m.extra.sheared)));
|
||||
}
|
||||
// CopperGolem.addAdditionalSaveData.
|
||||
"minecraft:copper_golem" => {
|
||||
@@ -555,6 +600,14 @@ fn custom_name(tag: &Tag) -> Option<String> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
|
||||
/// else is a component (`ComponentSerialization`).
|
||||
pub fn name_text(tag: &Tag) -> Text {
|
||||
match tag {
|
||||
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
|
||||
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
|
||||
}
|
||||
}
|
||||
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)?;
|
||||
@@ -606,11 +659,15 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.persistent = flag(tag, "PersistenceRequired");
|
||||
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.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");
|
||||
}
|
||||
if is_animal(&m.kind.id) {
|
||||
m.extra.in_love = number(tag.get("InLove")).unwrap_or(0) as i32;
|
||||
}
|
||||
m.name_visible = flag(tag, "CustomNameVisible");
|
||||
m.no_ai = flag(tag, "NoAI");
|
||||
m.no_gravity = flag(tag, "NoGravity");
|
||||
@@ -822,6 +879,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
}
|
||||
m.extra.egg_time = number(tag.get("life_ticks")).map_or(-1, |v| v as i32);
|
||||
}
|
||||
// Sheep.readAdditionalSaveData: white and unsheared when absent.
|
||||
if m.species() == crate::mobs::Species::Sheep {
|
||||
m.extra.color = number(tag.get("Color")).map_or(0, |c| (c as u8) & 15);
|
||||
m.extra.sheared = flag(tag, "Sheared");
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Shulker {
|
||||
m.extra.variant3 = number(tag.get("AttachFace")).unwrap_or(0).clamp(0, 5) as u8;
|
||||
m.extra.counter = number(tag.get("Peek")).unwrap_or(0).clamp(0, 100) as i32;
|
||||
@@ -1266,6 +1328,21 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"Marker".into());
|
||||
}
|
||||
if a.name_visible {
|
||||
put(&mut tag, "CustomNameVisible", Tag::Byte(1));
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"CustomNameVisible".into());
|
||||
}
|
||||
// A name from the save keeps its own shape unless it changed.
|
||||
if let Some(name) = &a.custom_name
|
||||
&& !tag.get("CustomName").is_some_and(|t| name_text(t) == *name)
|
||||
{
|
||||
put(
|
||||
&mut tag,
|
||||
"CustomName",
|
||||
crate::scoreboard::json_to_tag(&name.component()),
|
||||
);
|
||||
}
|
||||
put(&mut tag, "Health", Tag::Float(a.health.to_bits()));
|
||||
put(&mut tag, "Fire", Tag::Short(a.fire_ticks as i16));
|
||||
// LivingEntity.addAdditionalSaveData and ArmorStand's own fields.
|
||||
@@ -1294,6 +1371,8 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
|
||||
a.velocity = motion(tag);
|
||||
a.health = float(tag.get("Health")).unwrap_or(20.0) as f32;
|
||||
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
|
||||
a.custom_name = tag.get("CustomName").map(name_text);
|
||||
a.name_visible = flag(tag, "CustomNameVisible");
|
||||
if let Some(eq) = tag.get("equipment") {
|
||||
for (i, slot) in ArmorStand::SLOTS.iter().enumerate() {
|
||||
a.equipment[i] = eq.get(slot).and_then(item_from_nbt);
|
||||
@@ -1574,6 +1653,31 @@ mod tests {
|
||||
assert!(ominous_item_spawner_from_nbt(&raw).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armor_stand_names_keep_their_formatting_across_a_save() {
|
||||
let tag = Tag::compound([
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double(0.0f64.to_bits()),
|
||||
Tag::Double(64.0f64.to_bits()),
|
||||
Tag::Double(0.0f64.to_bits()),
|
||||
]),
|
||||
),
|
||||
("CustomName", string("§6Hello")),
|
||||
("CustomNameVisible", Tag::Byte(1)),
|
||||
]);
|
||||
let a = armor_stand_from_nbt(&tag, 7).unwrap();
|
||||
let name = a.custom_name.as_ref().unwrap();
|
||||
assert_eq!(name.plain(), "Hello");
|
||||
assert!(name.spans[0].style.color.is_some());
|
||||
assert!(a.name_visible);
|
||||
let saved = armor_stand_to_nbt(&a, [0; 4], Some(&tag));
|
||||
let restored = armor_stand_from_nbt(&saved, 7).unwrap();
|
||||
assert_eq!(restored.custom_name, a.custom_name);
|
||||
assert!(restored.name_visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falling_block_round_trip_keeps_impact_distance_and_settings() {
|
||||
let state = Tag::compound([("Name", string("minecraft:damaged_anvil"))]);
|
||||
@@ -1999,3 +2103,39 @@ mod axolotl_nbt_tests {
|
||||
assert_eq!(mm.temptation_cooldown_ticks.value(), Some(42));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sheep_nbt_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn a_sheep_keeps_its_dyed_and_sheared_wool() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
Arc::new(data.types["minecraft:sheep"].clone()),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
// Summoned white, then dyed red and sheared: the save has it now.
|
||||
let raw = Tag::compound([("Color", Tag::Byte(0)), ("Sheared", Tag::Byte(0))]);
|
||||
(m.extra.color, m.extra.sheared) = (14, true);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw));
|
||||
assert_eq!(
|
||||
(number(tag.get("Color")), number(tag.get("Sheared"))),
|
||||
(Some(14), Some(1))
|
||||
);
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,11 @@ pub struct Extras {
|
||||
pub sort: bool,
|
||||
/// A vanilla key name: sorts the part under the cursor.
|
||||
pub sort_key: String,
|
||||
/// No AI: every AI feature hidden and off (`horizon_core::no_ai`; the launcher writes it too).
|
||||
pub no_ai: bool,
|
||||
/// Agents may control the game the player starts: it opens its control socket for
|
||||
/// horizon-ctl and MCP clients (off by default; an agent's own launch always opens it).
|
||||
pub agent_control: bool,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -135,6 +140,8 @@ impl Default for Extras {
|
||||
mouse_tweaks_wheel: true,
|
||||
sort: false,
|
||||
sort_key: "key.keyboard.unknown".into(),
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,6 +205,8 @@ impl Extras {
|
||||
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
|
||||
"sort" => e.sort = flag(e.sort),
|
||||
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -205,7 +214,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -234,6 +243,9 @@ impl Extras {
|
||||
bool_text(self.mouse_tweaks_wheel),
|
||||
bool_text(self.sort),
|
||||
self.sort_key,
|
||||
horizon_core::no_ai::KEY,
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
|
||||
@@ -451,6 +463,9 @@ pub enum Item {
|
||||
MouseTweaksWheel,
|
||||
Sort,
|
||||
SortKey,
|
||||
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
|
||||
NoAi,
|
||||
AgentControl,
|
||||
}
|
||||
/// The Extras page: headers and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
@@ -529,6 +544,8 @@ impl Item {
|
||||
Item::FpsLows => "Minimum and 1% low",
|
||||
Item::Keystrokes => "Keystrokes",
|
||||
Item::KeystrokesCps => "Clicks per second",
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
}
|
||||
}
|
||||
/// The switch this setting belongs to: it does nothing while that is off.
|
||||
@@ -542,7 +559,7 @@ impl Item {
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort
|
||||
| AutoReconnect => {
|
||||
| AutoReconnect | NoAi | AgentControl => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -586,6 +603,12 @@ impl Item {
|
||||
}
|
||||
Item::Keystrokes => "Movement keys, jump and mouse buttons as you press them.",
|
||||
Item::KeystrokesCps => "Clicks in the last second under each mouse button.",
|
||||
Item::NoAi => {
|
||||
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
|
||||
}
|
||||
Item::AgentControl => {
|
||||
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The frame-time cost the main switch of an extra shows.
|
||||
@@ -616,6 +639,8 @@ impl Item {
|
||||
Item::MouseTweaks => e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort,
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -671,6 +696,8 @@ impl Item {
|
||||
}
|
||||
Item::MinimapShape => e.minimap_round = !e.minimap_round,
|
||||
Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back),
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::ZoomKey | Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ pub enum ItemPose {
|
||||
Trident {
|
||||
ticks_used: f32,
|
||||
},
|
||||
/// Riptide's spin (`isAutoSpinAttack`): the item held up like a fork.
|
||||
SpinAttack,
|
||||
Bow {
|
||||
ticks_used: f32,
|
||||
},
|
||||
@@ -67,6 +69,12 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
|
||||
if matches!(pose, ItemPose::Using) {
|
||||
return m;
|
||||
}
|
||||
if matches!(pose, ItemPose::SpinAttack) {
|
||||
return m
|
||||
* Mat4::from_translation(Vec3::new(invert * -0.4, 0.8, 0.3))
|
||||
* Mat4::from_rotation_y((invert * 65.0f32).to_radians())
|
||||
* Mat4::from_rotation_z((invert * -85.0f32).to_radians());
|
||||
}
|
||||
let charging = match pose {
|
||||
ItemPose::Bow { ticks_used } => {
|
||||
let power = ticks_used / 20.0;
|
||||
@@ -281,15 +289,6 @@ impl Default for HandHeights {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What `FirstPersonHandsAndItems.tick` decides about a hand's visible item.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct HandChange {
|
||||
/// The visible item is not the hand's item after the instant replacement
|
||||
/// (`mainHandItem != nextMainHand`): the hand lowers.
|
||||
pub changed: bool,
|
||||
/// The visible item becomes the hand's item this tick.
|
||||
pub replace: bool,
|
||||
}
|
||||
impl HandHeights {
|
||||
/// `FirstPersonHandsAndItems.tick`'s heights. `changed` says per hand
|
||||
/// whether the visible item differs from the held one once
|
||||
|
||||
+824
-225
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,303 @@
|
||||
//! Play with Friends: Horizon's own sharing of the open world behind an
|
||||
//! invite code (`game/friends.rs`). The pause menu's Friends button, the icon
|
||||
//! vanilla's PauseScreen already has, opens it. It is drawn the way a mod adds
|
||||
//! a screen to vanilla: the menu background, a centred title and text,
|
||||
//! vanilla's buttons and Done in the footer.
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
multiplayer::wrap,
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
|
||||
pub const TITLE: &str = "Play with Friends";
|
||||
/// What the screen shows (game/friends.rs fills it).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FriendsView {
|
||||
/// A singleplayer world (not a server): sharing is offered.
|
||||
pub shown: bool,
|
||||
pub sharing: bool,
|
||||
pub code: Option<String>,
|
||||
/// Who can join and what to do when friends on the internet can't (or,
|
||||
/// before the code, what the game is doing).
|
||||
pub status: String,
|
||||
/// Friends on the internet can reach the host.
|
||||
pub reachable: bool,
|
||||
/// Where the troubleshooting is, when they can't.
|
||||
pub help: Option<String>,
|
||||
/// The players in the world besides the host.
|
||||
pub guests: Vec<String>,
|
||||
pub notice: Option<String>,
|
||||
/// The code was just copied.
|
||||
pub copied: bool,
|
||||
}
|
||||
|
||||
/// A button's action.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
Share,
|
||||
Copy,
|
||||
Stop,
|
||||
/// Open the troubleshooting page.
|
||||
Help,
|
||||
Done,
|
||||
}
|
||||
|
||||
fn grey() -> u32 {
|
||||
rgba(160, 160, 160, 255)
|
||||
}
|
||||
const WHITE: u32 = u32::MAX;
|
||||
const HINT: &str = "Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.";
|
||||
|
||||
struct Button {
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
label: &'static str,
|
||||
action: Action,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
/// The centred lines (text, colour, y) and the buttons.
|
||||
fn layout(
|
||||
atlas: &HudAtlas,
|
||||
(w, h): (f32, f32),
|
||||
view: &FriendsView,
|
||||
) -> (Vec<(String, u32, f32)>, Vec<Button>) {
|
||||
let width = (w - 40.0).min(310.0);
|
||||
let cx = (w / 2.0).floor();
|
||||
let mut lines = Vec::new();
|
||||
let mut y = 40.0;
|
||||
let add = |lines: &mut Vec<(String, u32, f32)>, text: &str, color: u32, y: &mut f32| {
|
||||
for line in wrap(text, width, atlas) {
|
||||
lines.push((line, color, *y));
|
||||
*y += 10.0;
|
||||
}
|
||||
};
|
||||
let mut buttons = Vec::new();
|
||||
if view.sharing {
|
||||
lines.push(("Invite Code".to_owned(), grey(), y));
|
||||
y += 12.0;
|
||||
match &view.code {
|
||||
Some(code) => {
|
||||
for line in code_lines(atlas, code, width) {
|
||||
lines.push((line, WHITE, y));
|
||||
y += 10.0;
|
||||
}
|
||||
}
|
||||
None => add(&mut lines, &view.status, WHITE, &mut y),
|
||||
}
|
||||
y += 6.0;
|
||||
buttons.push(Button {
|
||||
x: cx - 154.0,
|
||||
y,
|
||||
w: 150.0,
|
||||
label: if view.copied { "Copied" } else { "Copy Code" },
|
||||
action: Action::Copy,
|
||||
active: view.code.is_some(),
|
||||
});
|
||||
buttons.push(Button {
|
||||
x: cx + 4.0,
|
||||
y,
|
||||
w: 150.0,
|
||||
label: "Stop Sharing",
|
||||
action: Action::Stop,
|
||||
active: true,
|
||||
});
|
||||
y += 30.0;
|
||||
if view.code.is_some() {
|
||||
// Who can join: grey when friends anywhere can, white (it asks
|
||||
// for something) when only the LAN and VPNs can.
|
||||
let tint = if view.reachable { grey() } else { WHITE };
|
||||
add(&mut lines, &view.status, tint, &mut y);
|
||||
y += 4.0;
|
||||
}
|
||||
let here = if view.guests.is_empty() {
|
||||
"No friends here yet.".to_owned()
|
||||
} else {
|
||||
format!("Here now: {}", view.guests.join(", "))
|
||||
};
|
||||
add(&mut lines, &here, grey(), &mut y);
|
||||
if let Some(notice) = &view.notice {
|
||||
add(&mut lines, notice, WHITE, &mut y);
|
||||
}
|
||||
// Where friends paste the code, while it fits above the footer.
|
||||
if y + 10.0 * wrap(HINT, width, atlas).len() as f32 <= h - 33.0 {
|
||||
add(&mut lines, HINT, grey(), &mut y);
|
||||
}
|
||||
} else {
|
||||
add(
|
||||
&mut lines,
|
||||
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
|
||||
WHITE,
|
||||
&mut y,
|
||||
);
|
||||
y += 4.0;
|
||||
add(&mut lines, HINT, grey(), &mut y);
|
||||
buttons.push(Button {
|
||||
x: cx - 100.0,
|
||||
y: y + 8.0,
|
||||
w: 200.0,
|
||||
label: "Start Sharing",
|
||||
action: Action::Share,
|
||||
active: view.shown,
|
||||
});
|
||||
}
|
||||
// HeaderAndFooterLayout's footer: Done, beside Troubleshooting when
|
||||
// friends on the internet can't reach the host.
|
||||
if view.sharing && view.help.is_some() {
|
||||
buttons.push(Button {
|
||||
x: cx - 154.0,
|
||||
y: h - 27.0,
|
||||
w: 150.0,
|
||||
label: "Troubleshooting...",
|
||||
action: Action::Help,
|
||||
active: true,
|
||||
});
|
||||
buttons.push(Button {
|
||||
x: cx + 4.0,
|
||||
y: h - 27.0,
|
||||
w: 150.0,
|
||||
label: "gui.done",
|
||||
action: Action::Done,
|
||||
active: true,
|
||||
});
|
||||
} else {
|
||||
buttons.push(Button {
|
||||
x: cx - 100.0,
|
||||
y: h - 27.0,
|
||||
w: 200.0,
|
||||
label: "gui.done",
|
||||
action: Action::Done,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
(lines, buttons)
|
||||
}
|
||||
/// The code's groups packed into lines of at most `width`.
|
||||
fn code_lines(atlas: &HudAtlas, code: &str, width: f32) -> Vec<String> {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for group in code.split('-') {
|
||||
match lines.last_mut() {
|
||||
Some(l) if HudBatch::text_width(atlas, &format!("{l}-{group}")) <= width => {
|
||||
l.push('-');
|
||||
l.push_str(group);
|
||||
}
|
||||
_ => lines.push(group.to_owned()),
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
fn label(key: &str) -> Text {
|
||||
if key.starts_with("gui.") {
|
||||
Text::translate(key, &[])
|
||||
} else {
|
||||
Text::literal(key)
|
||||
}
|
||||
}
|
||||
pub fn draw(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
view: &FriendsView,
|
||||
in_world: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
let title = Text::literal(TITLE);
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
|
||||
let (lines, buttons) = layout(atlas, (w, h), view);
|
||||
for (line, color, y) in lines {
|
||||
let lw = HudBatch::text_width(atlas, &line);
|
||||
batch.text(atlas, &line, ((w - lw) / 2.0).floor(), y, color, true);
|
||||
}
|
||||
for b in buttons {
|
||||
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
b.x,
|
||||
b.y,
|
||||
b.w,
|
||||
20.0,
|
||||
&label(b.label),
|
||||
b.active,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// The button under a click, if it is active.
|
||||
pub fn click(
|
||||
atlas: &HudAtlas,
|
||||
size: (f32, f32),
|
||||
cursor: (f32, f32),
|
||||
view: &FriendsView,
|
||||
) -> Option<Action> {
|
||||
layout(atlas, size, view)
|
||||
.1
|
||||
.into_iter()
|
||||
.find(|b| b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0))
|
||||
.map(|b| b.action)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_then_copy_and_stop() {
|
||||
let atlas = atlas();
|
||||
let size = (427.0, 240.0);
|
||||
let idle = FriendsView {
|
||||
shown: true,
|
||||
..FriendsView::default()
|
||||
};
|
||||
let (_, buttons) = layout(&atlas, size, &idle);
|
||||
let share = buttons.iter().find(|b| b.action == Action::Share).unwrap();
|
||||
assert_eq!(
|
||||
click(&atlas, size, (share.x + 1.0, share.y + 1.0), &idle),
|
||||
Some(Action::Share)
|
||||
);
|
||||
let sharing = FriendsView {
|
||||
shown: true,
|
||||
sharing: true,
|
||||
status: "Finding how friends can reach you…".into(),
|
||||
..FriendsView::default()
|
||||
};
|
||||
let (_, buttons) = layout(&atlas, size, &sharing);
|
||||
let copy = buttons.iter().find(|b| b.action == Action::Copy).unwrap();
|
||||
assert!(!copy.active, "nothing to copy before the code");
|
||||
let with_code = FriendsView {
|
||||
code: Some("0B33K-Q405Q-1Z5CV-X6NHY-R12N0-VK8T1-60N00-RA134-952PE-1HA08-910P0-K5M01-ST1NB-JDTB6-K4W1P-0".into()),
|
||||
status: "Friends on your network or VPN can join. For friends on the internet: turn on UPnP on your router, or forward TCP port 25580 to 10.0.0.2, or use a relay, or a VPN such as Tailscale or ZeroTier (the code already holds your VPN address).".into(),
|
||||
help: Some("https://example.invalid/help".into()),
|
||||
..sharing
|
||||
};
|
||||
let (lines, buttons) = layout(&atlas, size, &with_code);
|
||||
assert!(
|
||||
lines.iter().all(|(_, _, y)| y + 9.0 <= size.1 - 27.0),
|
||||
"the text stays above the footer"
|
||||
);
|
||||
for action in [Action::Copy, Action::Stop, Action::Help, Action::Done] {
|
||||
let b = buttons.iter().find(|b| b.action == action).unwrap();
|
||||
assert_eq!(
|
||||
click(&atlas, size, (b.x + 1.0, b.y + 1.0), &with_code),
|
||||
Some(action)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_offers_no_sharing() {
|
||||
let atlas = atlas();
|
||||
let view = FriendsView::default();
|
||||
let (_, buttons) = layout(&atlas, (427.0, 240.0), &view);
|
||||
let share = buttons.iter().find(|b| b.action == Action::Share).unwrap();
|
||||
assert!(!share.active);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+675
-329
File diff suppressed because it is too large.
Load diff
@@ -403,7 +403,9 @@ impl Game<'_> {
|
||||
Screen::Stats => "stats",
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
}
|
||||
}
|
||||
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
|
||||
@@ -458,8 +460,11 @@ impl Game<'_> {
|
||||
"sneaking": self.keys.contains(&KeyCode::ShiftLeft),
|
||||
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
|
||||
"health": p.health.health(),
|
||||
"absorption": p.health.absorption(),
|
||||
"air": p.air,
|
||||
"food": p.health.food.food,
|
||||
"saturation": p.health.food.saturation,
|
||||
"shield_cooldown": self.shield_cooldown,
|
||||
"xp": { "level": self.experience.level, "progress": self.experience.progress, "total": self.experience.total },
|
||||
"effects": self.potions.effects.list.iter().map(|e| json!({ "id": e.id, "duration_ticks": e.duration, "amplifier": e.amplifier })).collect::<Vec<_>>(),
|
||||
})
|
||||
@@ -477,6 +482,8 @@ impl Game<'_> {
|
||||
"sleeping": self.sleeping,
|
||||
// The item in use: its slot and for how many ticks.
|
||||
"using": self.using.map(|(slot, ticks)| json!({ "slot": slot, "ticks": ticks })),
|
||||
// The block being dug: how far along, after how many ticks.
|
||||
"mining": self.mining.as_ref().map(|m| json!({ "pos": m.pos, "progress": m.progress, "ticks": m.ticks })),
|
||||
// The arm swings started (LivingEntity.swing).
|
||||
"swings": self.swings_started,
|
||||
"time": { "day_time": self.world.day_time, "day": self.world.day_time.div_euclid(24000), "time_of_day": self.world.day_time.rem_euclid(24000) },
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Agent control as a setting (Horizon Settings > AI Agent): when the control socket is open, who
|
||||
//! is connected, and the details an agent needs. The socket opens when the game was started for
|
||||
//! an agent (`--control`, `--control-socket`: horizon-ctl launch, the launcher's MCP `play`) or
|
||||
//! when the player turned Agent Control on, and never under No AI (`horizon_core::no_ai`).
|
||||
//! Turning either switch in the game opens or closes it at once.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, control};
|
||||
use std::path::PathBuf;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The endpoint: the one the command line gave, else the default horizon-ctl looks for.
|
||||
fn agent_socket_path(&self) -> PathBuf {
|
||||
self.options
|
||||
.control
|
||||
.clone()
|
||||
.unwrap_or_else(control::default_path)
|
||||
}
|
||||
|
||||
/// At startup: open the socket if the game was started for an agent or Agent Control is on.
|
||||
pub(super) fn start_agent_control(&mut self) -> Result<()> {
|
||||
self.agent_control_on = self.settings.extras.agent_control;
|
||||
if self.options.benchmark.is_some() && self.options.control.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.settings.extras.no_ai {
|
||||
if self.options.control.is_some() {
|
||||
log::warn!(
|
||||
"Horizon: control socket not opened: {}",
|
||||
horizon_core::no_ai::REFUSAL
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(path) = &self.options.control {
|
||||
// Asked for on the command line: failing to listen is an error.
|
||||
let server = control::Server::start(path)
|
||||
.map_err(|e| format!("control socket {}: {e}", path.display()))?;
|
||||
log::info!("Horizon: control socket {}", server.path().display());
|
||||
self.control = Some(server);
|
||||
} else if self.agent_control_on {
|
||||
self.open_agent_socket();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The setting's socket: a failure (another game holds the default endpoint) is logged and
|
||||
/// shown on the AI Agent page, not fatal.
|
||||
fn open_agent_socket(&mut self) {
|
||||
let path = self.agent_socket_path();
|
||||
match control::Server::start(&path) {
|
||||
Ok(server) => {
|
||||
log::info!("Horizon: control socket {}", server.path().display());
|
||||
self.control = Some(server);
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: control socket {}: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
/// After a settings change: No AI closes the socket; Agent Control turned on or off opens or
|
||||
/// closes it.
|
||||
pub(super) fn sync_agent_control(&mut self) {
|
||||
let wanted = self.settings.extras.agent_control;
|
||||
let changed = wanted != self.agent_control_on;
|
||||
self.agent_control_on = wanted;
|
||||
let open = self
|
||||
.control
|
||||
.as_ref()
|
||||
.is_some_and(|c| !c.path().as_os_str().is_empty());
|
||||
if self.settings.extras.no_ai || (changed && !wanted) {
|
||||
if open {
|
||||
log::info!("Horizon: control socket closed");
|
||||
self.control = None;
|
||||
}
|
||||
} else if changed && wanted && self.control.is_none() {
|
||||
self.open_agent_socket();
|
||||
}
|
||||
}
|
||||
|
||||
/// An agent's command arrived.
|
||||
pub(super) fn agent_command_seen(&mut self) {
|
||||
self.agent_last = Some(std::time::Instant::now());
|
||||
}
|
||||
|
||||
/// The AI Agent page's lines: the state, then where agents connect.
|
||||
pub(super) fn agent_status_lines(&self) -> Vec<String> {
|
||||
let server = self
|
||||
.control
|
||||
.as_ref()
|
||||
.filter(|c| !c.path().as_os_str().is_empty());
|
||||
let mut lines = vec![match server {
|
||||
Some(s) if s.clients() > 0 => {
|
||||
let ago = self
|
||||
.agent_last
|
||||
.map(|t| format!(", last command {} s ago", t.elapsed().as_secs()))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"Connected: {} agent connection{}{ago}",
|
||||
s.clients(),
|
||||
if s.clients() == 1 { "" } else { "s" }
|
||||
)
|
||||
}
|
||||
Some(_) => "Waiting for an agent.".into(),
|
||||
None => "Off: agents can't reach this game.".into(),
|
||||
}];
|
||||
if let Some(s) = server {
|
||||
if self.options.control.is_some() && !self.agent_control_on {
|
||||
lines.push("Opened for this session by the program that started the game.".into());
|
||||
}
|
||||
// The full path is too wide for the page: Copy Connection Details has it.
|
||||
let name = s.path().file_name().unwrap_or_default().to_string_lossy();
|
||||
lines.push(format!(
|
||||
"Socket: {name} (copy the details for its full path)"
|
||||
));
|
||||
}
|
||||
lines.push("Local only: agents on this computer, never the network.".into());
|
||||
lines
|
||||
}
|
||||
|
||||
/// What Copy Connection Details puts on the clipboard: the socket, a horizon-ctl line to try,
|
||||
/// and an MCP client's config for this game.
|
||||
pub(super) fn agent_details(&self) -> String {
|
||||
let socket = self.agent_socket_path();
|
||||
let ctl = std::env::current_exe()
|
||||
.unwrap_or_default()
|
||||
.with_file_name(if cfg!(windows) {
|
||||
"horizon-ctl.exe"
|
||||
} else {
|
||||
"horizon-ctl"
|
||||
});
|
||||
let config = serde_json::json!({ "mcpServers": { "horizon-game": {
|
||||
"command": ctl, "args": ["--socket", socket, "mcp"] } } });
|
||||
format!(
|
||||
"Horizon agent control (this computer only)\nSocket: {}\nTry it: \"{}\" --socket \"{}\" state\nMCP config:\n{}\n",
|
||||
socket.display(),
|
||||
ctl.display(),
|
||||
socket.display(),
|
||||
serde_json::to_string_pretty(&config).unwrap_or_default()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,18 +9,28 @@ impl Game<'_> {
|
||||
pub(super) fn tick_allays(&mut self) {
|
||||
let griefing = self.ui.rules.bool("mob_griefing");
|
||||
let player = self.player.as_ref().map(|p| p.position);
|
||||
// The playing jukeboxes, found at the first allay: no allay starts
|
||||
// or stops one, and the block entities run into the thousands.
|
||||
let mut jukeboxes: Option<Vec<(i32, i32, i32)>> = None;
|
||||
for i in 0..self.mobs.len() {
|
||||
if self.mobs[i].species() != Species::Allay || !self.mobs[i].alive() {
|
||||
continue;
|
||||
}
|
||||
let pos = self.mobs[i].position;
|
||||
// Dancing: a playing jukebox within 10 blocks.
|
||||
let dancing = self.world.block_entities.iter().any(|(k, e)| {
|
||||
matches!(e, BlockEntity::Jukebox(j) if j.playing)
|
||||
&& (f64::from(k.0) + 0.5 - pos.x).powi(2)
|
||||
+ (f64::from(k.1) + 0.5 - pos.y).powi(2)
|
||||
+ (f64::from(k.2) + 0.5 - pos.z).powi(2)
|
||||
<= 100.0
|
||||
let jukeboxes = jukeboxes.get_or_insert_with(|| {
|
||||
self.world
|
||||
.block_entities
|
||||
.iter()
|
||||
.filter(|(_, e)| matches!(e, BlockEntity::Jukebox(j) if j.playing))
|
||||
.map(|(k, _)| *k)
|
||||
.collect()
|
||||
});
|
||||
let dancing = jukeboxes.iter().any(|k| {
|
||||
(f64::from(k.0) + 0.5 - pos.x).powi(2)
|
||||
+ (f64::from(k.1) + 0.5 - pos.y).powi(2)
|
||||
+ (f64::from(k.2) + 0.5 - pos.z).powi(2)
|
||||
<= 100.0
|
||||
});
|
||||
self.mobs[i].extra.flag =
|
||||
dancing && !horizon_client::mobs::goals::should_panic(&self.mobs[i]);
|
||||
|
||||
@@ -8,12 +8,13 @@ use horizon_client::{
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Mob, Part, anim::Poses},
|
||||
trims::{TrimOverride, TrimRegistry, TrimTexture},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::RandomSource,
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc};
|
||||
|
||||
/// Armor stand models and the humanoid layers of every equipment asset.
|
||||
pub(super) struct ArmorStandAssets {
|
||||
@@ -25,6 +26,11 @@ pub(super) struct ArmorStandAssets {
|
||||
pub player: HashMap<&'static str, Part>,
|
||||
/// asset id -> layer kind (humanoid / humanoid_leggings) -> (texture, undyed colour).
|
||||
pub equipment: HashMap<String, HashMap<String, (String, Option<u32>)>>,
|
||||
/// asset id -> `trim_overrides`.
|
||||
pub trim_overrides: HashMap<String, Vec<TrimOverride>>,
|
||||
pub trims: TrimRegistry,
|
||||
/// The resources the trim textures are made from.
|
||||
pub root: PathBuf,
|
||||
}
|
||||
impl ArmorStandAssets {
|
||||
pub fn load(
|
||||
@@ -48,6 +54,7 @@ impl ArmorStandAssets {
|
||||
player.insert("elytra", part);
|
||||
}
|
||||
let mut equipment = HashMap::new();
|
||||
let mut trim_overrides = HashMap::new();
|
||||
if let Ok(entries) = std::fs::read_dir(root.join("assets/minecraft/equipment")) {
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
@@ -76,6 +83,7 @@ impl ArmorStandAssets {
|
||||
);
|
||||
}
|
||||
}
|
||||
trim_overrides.insert(format!("minecraft:{name}"), TrimOverride::list(&v));
|
||||
equipment.insert(format!("minecraft:{name}"), kinds);
|
||||
}
|
||||
}
|
||||
@@ -85,30 +93,67 @@ impl ArmorStandAssets {
|
||||
layers,
|
||||
player,
|
||||
equipment,
|
||||
trim_overrides,
|
||||
trims: TrimRegistry::load(root),
|
||||
root: root.to_owned(),
|
||||
}
|
||||
}
|
||||
/// Trim data of reloaded resources (the models stay).
|
||||
pub fn reload_trims(&mut self, root: &std::path::Path) {
|
||||
let fresh = Self::load(root, None);
|
||||
self.trim_overrides = fresh.trim_overrides;
|
||||
self.trims = fresh.trims;
|
||||
self.root = fresh.root;
|
||||
}
|
||||
/// `TrimTextureKey`: the trim texture of `stack` on its humanoid
|
||||
/// layer (`humanoid` or `humanoid_leggings`), if it has a trim.
|
||||
fn trim_texture(&self, asset: &str, kind: &str, stack: &ItemStack) -> Option<TrimTexture> {
|
||||
let (material, pattern) = stack.extra.as_ref()?.trim.as_ref()?;
|
||||
let overrides = self
|
||||
.trim_overrides
|
||||
.get(asset)
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
TrimTexture::resolve(&self.trims, overrides, material, pattern, kind)
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `HumanoidArmorLayer` for the player: (model, texture rect, colour) of
|
||||
/// each worn piece, head to feet.
|
||||
pub(super) fn player_armor_layers(&self) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
self.armor_layers(self.armor.each_ref().map(|s| s.as_ref().map(|s| &*s.item)))
|
||||
self.stack_armor_layers(&self.armor)
|
||||
}
|
||||
/// `HumanoidArmorLayer` for worn stacks (head to feet), trims included.
|
||||
pub(super) fn stack_armor_layers(
|
||||
&self,
|
||||
armor: &[Option<ItemStack>; 4],
|
||||
) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
self.armor_layers(
|
||||
armor
|
||||
.each_ref()
|
||||
.map(|s| s.as_ref().map(|s| (&*s.item, Some(s)))),
|
||||
)
|
||||
}
|
||||
/// `HumanoidArmorLayer` for another player's model: the armour it wears
|
||||
/// (`extra.head` .. `extra.feet`).
|
||||
pub(super) fn worn_armor_layers(&self, m: &Mob) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
let e = &m.extra;
|
||||
self.armor_layers([&e.head, &e.chest, &e.legs, &e.feet].map(|s| s.as_deref()))
|
||||
self.armor_layers(
|
||||
[&e.head, &e.chest, &e.legs, &e.feet].map(|s| s.as_deref().map(|i| (i, None))),
|
||||
)
|
||||
}
|
||||
/// The layers of the armour items `worn`, head to feet.
|
||||
fn armor_layers(&self, worn: [Option<&str>; 4]) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
/// The layers of the armour items `worn` (with their stacks when known,
|
||||
/// for trims), head to feet.
|
||||
fn armor_layers(
|
||||
&self,
|
||||
worn: [Option<(&str, Option<&ItemStack>)>; 4],
|
||||
) -> Vec<(&Part, [f32; 4], u32)> {
|
||||
let assets = &self.armor_stand_assets;
|
||||
let mut out = Vec::new();
|
||||
for (item, layer) in worn
|
||||
.into_iter()
|
||||
.zip(["helmet", "chestplate", "leggings", "boots"])
|
||||
{
|
||||
let Some(item) = item else {
|
||||
let Some((item, stack)) = item else {
|
||||
continue;
|
||||
};
|
||||
let Some(asset) = self
|
||||
@@ -138,9 +183,117 @@ impl Game<'_> {
|
||||
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
|
||||
});
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], color));
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
if let Some(r) = stack
|
||||
.and_then(|s| assets.trim_texture(&asset, kind, s))
|
||||
.and_then(|t| self.gui.atlas.sprites.get(&t.sprite()))
|
||||
{
|
||||
out.push((part, [r.u0, r.v0, r.u1, r.v1], 0xFFFF_FFFF));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `PalettedTextureManager.getOrPrepare` for the trims drawn this frame
|
||||
/// (the player's armour and the armour stands'): each generated texture
|
||||
/// goes into an atlas trim slot, a free one, else the least recently
|
||||
/// drawn.
|
||||
pub(super) fn prepare_trims(&mut self) {
|
||||
use horizon_client::hud::TRIM_SLOTS;
|
||||
let assets = &self.armor_stand_assets;
|
||||
let items = self.res.items();
|
||||
let mut wanted: Vec<TrimTexture> = Vec::new();
|
||||
let guests = self.lan.iter().flat_map(|host| host.hub.guests.iter());
|
||||
let stacks = self
|
||||
.armor
|
||||
.iter()
|
||||
.chain(guests.flat_map(|g| g.armor.iter()))
|
||||
.zip(
|
||||
["helmet", "chestplate", "leggings", "boots"]
|
||||
.into_iter()
|
||||
.cycle(),
|
||||
)
|
||||
.chain(self.armor_stands.iter().flat_map(|a| {
|
||||
[5usize, 4, 3, 2]
|
||||
.map(|slot| &a.equipment[slot])
|
||||
.into_iter()
|
||||
.zip(["helmet", "chestplate", "leggings", "boots"])
|
||||
}));
|
||||
for (stack, layer) in stacks {
|
||||
let Some(stack) = stack.as_ref().filter(|s| s.extra.is_some()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(asset) = items
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.equip_asset.as_deref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let kind = if layer == "leggings" {
|
||||
"humanoid_leggings"
|
||||
} else {
|
||||
"humanoid"
|
||||
};
|
||||
if let Some(t) = assets.trim_texture(asset, kind, stack)
|
||||
&& !wanted.contains(&t)
|
||||
{
|
||||
wanted.push(t);
|
||||
}
|
||||
}
|
||||
for t in wanted {
|
||||
let name = t.sprite();
|
||||
// Entries the atlas no longer has (rebuilt by a resource reload) are free.
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
self.trim_slots.retain(|(n, _)| sprites.contains_key(n));
|
||||
if let Some(i) = self.trim_slots.iter().position(|e| e.0 == name) {
|
||||
let e = self.trim_slots.remove(i);
|
||||
self.trim_slots.push(e);
|
||||
continue;
|
||||
}
|
||||
let Some((w, h, rgba)) = t.generate(&self.armor_stand_assets.root) else {
|
||||
continue;
|
||||
};
|
||||
let used: Vec<usize> = self.trim_slots.iter().map(|e| e.1).collect();
|
||||
let slot = match (0..TRIM_SLOTS).find(|i| !used.contains(i)) {
|
||||
Some(free) => free,
|
||||
None => {
|
||||
let (old, slot) = self.trim_slots.remove(0);
|
||||
self.gui.atlas.sprites.remove(&old);
|
||||
slot
|
||||
}
|
||||
};
|
||||
let Some(area) = self
|
||||
.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.get(&format!("horizon/trim{slot}"))
|
||||
.copied()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if w > area.w || h > area.h {
|
||||
continue;
|
||||
}
|
||||
let (aw, ah) = (self.gui.atlas.width as f32, self.gui.atlas.height as f32);
|
||||
let (x, y) = ((area.u0 * aw).round(), (area.v0 * ah).round());
|
||||
self.map_patches.push(horizon_client::font::Patch {
|
||||
x: x as u32,
|
||||
y: y as u32,
|
||||
width: w,
|
||||
height: h,
|
||||
rgba,
|
||||
});
|
||||
let rect = horizon_client::hud::Rect {
|
||||
u0: area.u0,
|
||||
v0: area.v0,
|
||||
u1: (x + w as f32) / aw,
|
||||
v1: (y + h as f32) / ah,
|
||||
w,
|
||||
h,
|
||||
};
|
||||
self.gui.atlas.sprites.insert(name.clone(), rect);
|
||||
self.trim_slots.push((name, slot));
|
||||
}
|
||||
}
|
||||
/// `ArmorStandItem.useOn`.
|
||||
pub(super) fn place_armor_stand(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:armor_stand" || hit.face == [0, -1, 0] {
|
||||
@@ -432,6 +585,10 @@ impl Game<'_> {
|
||||
(false, false) => (0.5, 1.975),
|
||||
};
|
||||
let edge = (width + height + width) / 3.0 * 4.0;
|
||||
// 26.3 compares the size with RenderSystem.DEFAULT_DEPTH_CLEAR_VALUE,
|
||||
// which is 0.0 (reverse Z; the bytecode is `dconst_0`): an empty
|
||||
// marker box falls back to 4, so a visible marker stand draws.
|
||||
// Holograms stay hidden through the invisible flag instead.
|
||||
let range = if edge == 0.0 { 4.0 } else { edge } * 64.0;
|
||||
let dragon = a.equipment[5]
|
||||
.as_ref()
|
||||
@@ -560,6 +717,22 @@ impl Game<'_> {
|
||||
0,
|
||||
out,
|
||||
);
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
if let Some(trim) = assets
|
||||
.trim_texture(&asset, kind, stack)
|
||||
.and_then(|t| uv_of(&t.sprite()))
|
||||
{
|
||||
horizon_client::mobs::render::draw(
|
||||
part,
|
||||
base,
|
||||
&layer_poses,
|
||||
trim,
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
@@ -700,9 +873,15 @@ impl Game<'_> {
|
||||
self.dim == super::Dim::Nether,
|
||||
)),
|
||||
};
|
||||
let profile = stack
|
||||
.extra
|
||||
.as_deref()
|
||||
.and_then(|d| d.other.iter().find(|(k, _)| k == "minecraft:profile"))
|
||||
.map(|(_, t)| horizon_client::scoreboard::tag_to_json(t))
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
self.pack.item_renderer.submit_special(
|
||||
&special,
|
||||
&serde_json::Value::Null,
|
||||
&profile,
|
||||
false,
|
||||
pose,
|
||||
&self.gui.atlas.sprites,
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
//! The live title backdrop (Menu style: Horizon): a frozen landscape that
|
||||
//! Horizon generates and renders itself behind the title screens, seen
|
||||
//! through a slow orbiting camera, falling snow, an overcast sky and the cold
|
||||
//! blur and grade of `gfx::PostGrade`. Nothing is bundled: the chunks come
|
||||
//! from the vanilla generator for a fixed seed and are cached in
|
||||
//! `saves/.horizon-title/backdrop` (no level.dat, so no world list entry).
|
||||
//! The ice-spikes spot is found once with `/locate biome`'s search and kept
|
||||
//! in `spot.txt` there. The generator is built and the spot found on a
|
||||
//! worker, so the title keeps drawing its panorama until both are ready.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
save::{Persistent, SaveDir},
|
||||
streaming::Streaming,
|
||||
vanillagen::VanillaTerrain,
|
||||
world::{Weather, World},
|
||||
};
|
||||
use horizon_core::biome::{BiomeZoom, obfuscate_seed};
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
/// The world the backdrop is cut from (HORIZON_BACKDROP_SEED overrides it).
|
||||
const SEED: i64 = 2_026_031_337;
|
||||
/// The biome the camera looks for.
|
||||
const BIOME: &str = "minecraft:ice_spikes";
|
||||
/// Late morning: the snow reads clearly; the grade (`PostGrade::FROST`) makes it cold.
|
||||
const DAY_TIME: i64 = 4_000;
|
||||
|
||||
/// The worker's result: the generator for [`SEED`], or why it failed.
|
||||
type Built = std::result::Result<Arc<VanillaTerrain>, String>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Backdrop {
|
||||
pub active: bool,
|
||||
/// The generator, once the worker has built it; taken when it is installed.
|
||||
terrain: Arc<Mutex<Option<Built>>>,
|
||||
/// The generator streams the backdrop's chunks.
|
||||
installed: bool,
|
||||
/// The search for the spot, then the spot (block x, z).
|
||||
spot: Arc<Mutex<Option<Option<(i32, i32)>>>>,
|
||||
centred: bool,
|
||||
/// Camera position once the ground under it has loaded.
|
||||
pub camera: Option<glam::DVec3>,
|
||||
pub ready: Option<Instant>,
|
||||
pub started: Option<Instant>,
|
||||
}
|
||||
fn seed() -> i64 {
|
||||
std::env::var("HORIZON_BACKDROP_SEED")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(SEED)
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// How visible the live backdrop is (0 until its first chunks are drawn).
|
||||
pub(super) fn backdrop_fade(&self) -> f32 {
|
||||
let Some(at) = self.backdrop.ready else {
|
||||
return 0.0;
|
||||
};
|
||||
let x = (at.elapsed().as_secs_f32() / 1.6).clamp(0.0, 1.0);
|
||||
x * x * (3.0 - 2.0 * x)
|
||||
}
|
||||
/// Whether the frame should draw the backdrop world instead of the panorama.
|
||||
pub(super) fn backdrop_live(&self) -> bool {
|
||||
self.title.is_some() && self.backdrop.active && self.backdrop.camera.is_some()
|
||||
}
|
||||
/// The orbiting camera: position, yaw and pitch.
|
||||
pub(super) fn backdrop_view(&self) -> Option<(glam::DVec3, f32, f32)> {
|
||||
let c = self.backdrop.camera?;
|
||||
let t = self
|
||||
.backdrop
|
||||
.started
|
||||
.map_or(0.0, |s| s.elapsed().as_secs_f32());
|
||||
// A full turn in about six minutes.
|
||||
let yaw = (200.0 + t * 1.0) % 360.0;
|
||||
let pitch = 6.0 + (t * 0.05).sin() * 2.0;
|
||||
Some((c, yaw, pitch))
|
||||
}
|
||||
/// Called every frame while the title is up.
|
||||
pub(super) fn update_backdrop(&mut self) -> Result<()> {
|
||||
let wanted = self.pending_entry.is_none()
|
||||
&& self.title.is_some()
|
||||
&& horizon_client::ui_theme::horizon()
|
||||
&& self.world_renderer_ready
|
||||
&& self.renderer.is_some()
|
||||
&& self.options.frames.is_none();
|
||||
if !wanted {
|
||||
if self.backdrop.active && self.title.is_some() {
|
||||
self.stop_backdrop();
|
||||
}
|
||||
if self.title.is_none() && self.backdrop.active {
|
||||
// open_world / connect_server replaced the world and streaming.
|
||||
self.backdrop = Backdrop::default();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if !self.backdrop.active {
|
||||
self.start_backdrop()?;
|
||||
}
|
||||
if !self.backdrop.installed {
|
||||
let built = self
|
||||
.backdrop
|
||||
.terrain
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.take();
|
||||
match built {
|
||||
// Still building: the panorama stays up.
|
||||
None => return Ok(()),
|
||||
Some(Ok(terrain)) => self.install_backdrop(terrain)?,
|
||||
Some(Err(e)) => {
|
||||
// The panorama stays up for good.
|
||||
log::warn!("Horizon: title backdrop: {e}");
|
||||
self.backdrop.installed = true;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
let found = *self.backdrop.spot.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(Some((x, z))) = found else {
|
||||
return Ok(());
|
||||
};
|
||||
if !self.backdrop.centred {
|
||||
self.backdrop.centred = true;
|
||||
self.streaming.center = (x >> 4, z >> 4);
|
||||
self.streaming.paused = false;
|
||||
}
|
||||
self.update_world();
|
||||
if self.backdrop.camera.is_none()
|
||||
&& (-2..=2)
|
||||
.all(|dz| (-2..=2).all(|dx| self.streaming.is_lit((x >> 4) + dx, (z >> 4) + dz)))
|
||||
{
|
||||
// Above the tallest block nearby, so no spike pokes through the lens.
|
||||
let mut top = 64;
|
||||
for dz in (-12..=12).step_by(3) {
|
||||
for dx in (-12..=12).step_by(3) {
|
||||
if let Some(y) = self.surface_y(x + dx, z + dz) {
|
||||
top = top.max(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
let ground = self.surface_y(x, z).unwrap_or(top);
|
||||
let y = (ground + 22).max(top + 4);
|
||||
self.backdrop.camera = Some(glam::DVec3::new(
|
||||
f64::from(x) + 0.5,
|
||||
f64::from(y),
|
||||
f64::from(z) + 0.5,
|
||||
));
|
||||
}
|
||||
if self.backdrop.camera.is_some()
|
||||
&& self.backdrop.ready.is_none()
|
||||
&& self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.section_count() > 40)
|
||||
{
|
||||
self.backdrop.ready = Some(Instant::now());
|
||||
log::info!(
|
||||
"Horizon: title backdrop ready; world rest ready: {}",
|
||||
self.res.rest.get().is_some()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The world tick of the backdrop: time passes for clouds and snow only.
|
||||
pub(super) fn tick_backdrop(&mut self) {
|
||||
if self.backdrop.active {
|
||||
self.world.game_time += 1;
|
||||
// The snow's fall follows the client tick counter.
|
||||
self.ticks += 1;
|
||||
}
|
||||
}
|
||||
fn surface_y(&self, x: i32, z: i32) -> Option<i32> {
|
||||
let air = self.res.facts().air;
|
||||
(-60..320).rev().find(|&y| self.world.block(x, y, z) != air)
|
||||
}
|
||||
/// Start building the generator and finding the spot on a worker
|
||||
/// (`VanillaTerrain::new` takes seconds on a cold cache).
|
||||
fn start_backdrop(&mut self) -> Result<()> {
|
||||
log::info!(
|
||||
"Horizon: starting title backdrop after {} ms",
|
||||
self.started.elapsed().as_millis()
|
||||
);
|
||||
let seed = seed();
|
||||
let dir = self.backdrop_root()?.join(format!("{seed}"));
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
// The streaming is replaced once the generator is built.
|
||||
self.streaming.paused = true;
|
||||
let mut world = World::new(self.res.facts().air, seed);
|
||||
world.day_time = DAY_TIME;
|
||||
world.game_time = 6000;
|
||||
// Light snow: the weather renders as snow in frozen biomes.
|
||||
world.weather = Weather {
|
||||
clear_time: 0,
|
||||
rain_time: i32::MAX / 2,
|
||||
thunder_time: i32::MAX / 2,
|
||||
raining: true,
|
||||
thundering: false,
|
||||
rain_level: (0.35, 0.35),
|
||||
thunder_level: (0.0, 0.0),
|
||||
};
|
||||
self.world = world;
|
||||
self.dim = super::Dim::Overworld;
|
||||
if let Some(r) = self.renderer.as_mut() {
|
||||
r.clear_sections();
|
||||
}
|
||||
self.backdrop = Backdrop {
|
||||
active: true,
|
||||
started: Some(Instant::now()),
|
||||
..Default::default()
|
||||
};
|
||||
let (terrain, spot) = (self.backdrop.terrain.clone(), self.backdrop.spot.clone());
|
||||
let worldgen = self.options.resources.join("worldgen");
|
||||
let registry = self.pack.assets.registry;
|
||||
let biomes = self.res.biomes().clone();
|
||||
let cache = dir.join("spot.txt");
|
||||
std::thread::Builder::new()
|
||||
.name("horizon-backdrop".into())
|
||||
.spawn(move || {
|
||||
let built = Instant::now();
|
||||
let inner = match VanillaTerrain::new(&worldgen, registry, &biomes, seed) {
|
||||
Ok(t) => Arc::new(t),
|
||||
Err(e) => {
|
||||
*terrain.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||
Some(Err(e.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
log::info!(
|
||||
"Horizon: title backdrop generator built in {} ms",
|
||||
built.elapsed().as_millis()
|
||||
);
|
||||
*terrain.lock().unwrap_or_else(|e| e.into_inner()) = Some(Ok(inner.clone()));
|
||||
// Find (or recall) the spot.
|
||||
let known = std::fs::read_to_string(&cache).ok().and_then(|t| {
|
||||
let mut it = t.split_whitespace().map(|v| v.parse::<i32>().ok());
|
||||
Some((it.next()??, it.next()??))
|
||||
});
|
||||
let found = known.or_else(|| {
|
||||
let allowed = |name: &str| name == BIOME;
|
||||
let search = horizon_world::worldgen::generator::BiomeSearch {
|
||||
allowed: &allowed,
|
||||
origin: [0, 64, 0],
|
||||
radius: 6400,
|
||||
horizontal: 32,
|
||||
vertical: 64,
|
||||
min_y: -64,
|
||||
max_y: 320,
|
||||
};
|
||||
let found = inner.find_closest_biome(&search);
|
||||
log::info!("Horizon: title backdrop: {BIOME} at {found:?}");
|
||||
let (_, p) = found?;
|
||||
let _ = std::fs::write(&cache, format!("{} {}\n", p[0], p[2]));
|
||||
Some((p[0], p[2]))
|
||||
});
|
||||
*spot.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||
Some(Some(found.unwrap_or((0, 0))));
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
fn backdrop_root(&self) -> Result<std::path::PathBuf> {
|
||||
Ok(std::env::current_dir()?
|
||||
.join("saves")
|
||||
.join(".horizon-title")
|
||||
.join("backdrop"))
|
||||
}
|
||||
/// The built generator streams the backdrop's chunks.
|
||||
fn install_backdrop(&mut self, inner: Arc<VanillaTerrain>) -> Result<()> {
|
||||
let seed = seed();
|
||||
let save = Arc::new(SaveDir::new(self.backdrop_root()?.join(format!("{seed}"))));
|
||||
let (res, pack) = (&*self.res, &self.pack);
|
||||
let mut streaming = Streaming::new(
|
||||
Arc::new(Persistent {
|
||||
inner,
|
||||
save: save.clone(),
|
||||
registry: pack.assets.clone(),
|
||||
biomes: res.biomes().clone(),
|
||||
}),
|
||||
pack.context.clone(),
|
||||
res.threads().min(6),
|
||||
);
|
||||
streaming.radius = self.settings.render_distance.clamp(4, 10);
|
||||
streaming.biome_blend = self.settings.biome_blend;
|
||||
streaming.smooth_lighting = self.settings.smooth_lighting;
|
||||
streaming.cutout_leaves = self.settings.cutout_leaves;
|
||||
streaming.cull_leaves = self.settings.horizon.cull_leaves;
|
||||
streaming.biome_zoom = BiomeZoom::new(obfuscate_seed(seed));
|
||||
streaming.paused = true;
|
||||
self.streaming = streaming;
|
||||
self.save = save;
|
||||
self.backdrop.installed = true;
|
||||
Ok(())
|
||||
}
|
||||
fn stop_backdrop(&mut self) {
|
||||
self.streaming.paused = true;
|
||||
self.world = World::new(self.res.facts().air, 0);
|
||||
if let Some(r) = self.renderer.as_mut() {
|
||||
r.clear_sections();
|
||||
}
|
||||
self.backdrop = Backdrop::default();
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use horizon_client::{
|
||||
mesher::Vertex,
|
||||
mobs::render::{DYE, DYE_NAMES},
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
|
||||
/// The beacon screen is 54 px wider than the 176-px menus it shares an origin with.
|
||||
pub(super) const SHIFT: f32 = -27.0;
|
||||
@@ -35,9 +36,6 @@ const PAYMENT: [&str; 5] = [
|
||||
"minecraft:diamond",
|
||||
"minecraft:netherite_ingot",
|
||||
];
|
||||
pub(super) fn is_payment(item: &str) -> bool {
|
||||
PAYMENT.contains(&item)
|
||||
}
|
||||
/// `BeaconMenu.PaymentSlot` and the player inventory, relative to the 176 origin.
|
||||
pub(super) fn slot_position(i: usize) -> Option<(f32, f32)> {
|
||||
Some(match i {
|
||||
@@ -171,7 +169,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn beacon_sound(&mut self, p: [i32; 3], sound: &str) {
|
||||
pub(super) fn beacon_sound(&mut self, p: [i32; 3], sound: &str) {
|
||||
let at = [
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]) + 0.5,
|
||||
@@ -255,9 +253,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
fn beacon_levels(&self) -> i32 {
|
||||
let Some(pos) = self.station.as_ref().map(|s| s.pos) else {
|
||||
let Some(s) = self.station.as_ref() else {
|
||||
return 0;
|
||||
};
|
||||
if let Some(levels) = s.beacon_levels {
|
||||
return levels;
|
||||
}
|
||||
let pos = s.pos;
|
||||
match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
|
||||
Some(BlockEntity::Beacon(b)) => b.levels,
|
||||
_ => 0,
|
||||
@@ -346,8 +348,16 @@ impl Game<'_> {
|
||||
self.screen = super::Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
/// `BeaconMenu.updateEffects`: spend the payment and set the powers.
|
||||
/// `BeaconMenu.updateEffects`: spend the payment and set the powers. On
|
||||
/// a server, `BeaconScreen`'s confirm button sends them
|
||||
/// (`ServerboundSetBeaconPacket`) and the server's menu does it.
|
||||
fn confirm_beacon(&mut self, sel: [Option<usize>; 2]) {
|
||||
if self.remote.is_some() {
|
||||
let id = |e: Option<usize>| e.and_then(|e| self.remote_effect_network_id(EFFECTS[e].0));
|
||||
let (primary, secondary) = (id(sel[0]), id(sel[1]));
|
||||
self.send(S::SetBeacon(play::SetBeacon { primary, secondary }));
|
||||
return;
|
||||
}
|
||||
let Some(s) = self.station.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ impl Game<'_> {
|
||||
let ticking = self.block_ticking_area();
|
||||
let mut spawners = Vec::new();
|
||||
let mut trial_spawners = Vec::new();
|
||||
self.item_columns = Some(Default::default());
|
||||
for slot in self.world.block_entity_tickers() {
|
||||
let pos = slot.0;
|
||||
// Ticks before this one can remove it (`TickingBlockEntity.isRemoved`).
|
||||
@@ -56,6 +57,8 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Items move between passes: the columns hold for this one only.
|
||||
self.item_columns = None;
|
||||
self.update_song_volumes();
|
||||
self.scan_for_hearts();
|
||||
// The client ticks (particles, sounds, the turning mob) are for a
|
||||
|
||||
@@ -57,7 +57,7 @@ pub(super) const CHEST_SLOTS: usize = 27;
|
||||
const WIDTH: f64 = 1.375;
|
||||
const HEIGHT: f64 = 0.5625;
|
||||
/// Player.DEFAULT_VEHICLE_ATTACHMENT minus the boat's passenger attachment.
|
||||
const SEAT: f64 = 0.1875 - 0.6;
|
||||
pub(super) const SEAT: f64 = 0.1875 - 0.6;
|
||||
impl Boat {
|
||||
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
@@ -295,7 +295,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `AbstractBoat.tick` for one boat (`controlled` when the player rides it).
|
||||
fn tick_boat(&mut self, mut b: Boat, controlled: bool) -> Boat {
|
||||
pub(super) fn tick_boat(&mut self, mut b: Boat, controlled: bool) -> Boat {
|
||||
b.previous = b.position;
|
||||
b.previous_yaw = b.yaw;
|
||||
b.old_status = b.status;
|
||||
@@ -448,9 +448,12 @@ impl Game<'_> {
|
||||
pub(super) fn tick_boats(&mut self) {
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
let ticking = self.entity_ticking_area();
|
||||
// A server player's boat is driven by its client (lan_vehicles.rs).
|
||||
let driven = self.guest_vehicles();
|
||||
for i in 0..self.boats.len() {
|
||||
let p = self.boats[i].position;
|
||||
if Some(self.boats[i].id) == self.riding
|
||||
|| driven.contains(&self.boats[i].id)
|
||||
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
|
||||
{
|
||||
continue;
|
||||
@@ -659,7 +662,11 @@ impl Game<'_> {
|
||||
self.right = false;
|
||||
return true;
|
||||
}
|
||||
if sneaking || self.boats[i].out_of_control >= 60.0 || self.riding.is_some() {
|
||||
if sneaking
|
||||
|| self.boats[i].out_of_control >= 60.0
|
||||
|| self.riding.is_some()
|
||||
|| self.ridden_by_guest(self.boats[i].id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.riding = Some(self.boats[i].id);
|
||||
|
||||
@@ -9,6 +9,7 @@ use horizon_client::{
|
||||
hud::{HudBatch, rgba},
|
||||
item_data::WrittenBook,
|
||||
items::ItemStack,
|
||||
menu::{Kind, Menu},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
@@ -89,6 +90,17 @@ impl Game<'_> {
|
||||
self.capture(false);
|
||||
}
|
||||
pub(super) fn close_book(&mut self) {
|
||||
// A server's lectern: LecternScreen's menu closes (closeContainer).
|
||||
if matches!(
|
||||
self.book,
|
||||
Some(BookScreen::View {
|
||||
lectern: Some(_),
|
||||
..
|
||||
})
|
||||
) && self.remote.is_some()
|
||||
{
|
||||
self.remote_close_container();
|
||||
}
|
||||
self.book = None;
|
||||
self.capture(true);
|
||||
}
|
||||
@@ -319,38 +331,25 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some(BookScreen::View {
|
||||
pages,
|
||||
page,
|
||||
lectern,
|
||||
}) => {
|
||||
let before = *page;
|
||||
lectern: Some(key), ..
|
||||
}) => lectern_page = Some(*key),
|
||||
Some(BookScreen::View { pages, page, .. }) => {
|
||||
if forward && *page + 1 < pages.len() {
|
||||
*page += 1;
|
||||
} else if !forward && *page > 0 {
|
||||
*page -= 1;
|
||||
}
|
||||
if *page != before
|
||||
&& let Some(pos) = *lectern
|
||||
{
|
||||
lectern_page = Some((pos, *page));
|
||||
}
|
||||
}
|
||||
None => return,
|
||||
}
|
||||
// LecternScreen's page buttons click the menu's (1 back, 2 forward);
|
||||
// the page turns with the server's data slot and level event 1043.
|
||||
if let Some(key) = lectern_page {
|
||||
self.lectern_button(key, if forward { 2 } else { 1 });
|
||||
return;
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:item.book.page_turn", None, 1.0, 1.0);
|
||||
if let Some((pos, page)) = lectern_page {
|
||||
self.set_lectern_page(pos, page as i32);
|
||||
}
|
||||
}
|
||||
/// `LecternBlockEntity.setPage` and `LecternBlock.signalPageChange`.
|
||||
fn set_lectern_page(&mut self, key: (i32, i32, i32), page: i32) {
|
||||
if let Some(BlockEntity::Lectern(l)) = self.world.block_entities.get_mut(&key) {
|
||||
l.page = page;
|
||||
}
|
||||
self.set_lectern_powered(key, true);
|
||||
self.lectern_pulses.push((key, 2));
|
||||
self.container_changed(key);
|
||||
}
|
||||
fn set_lectern_powered(&mut self, key: (i32, i32, i32), powered: bool) {
|
||||
self.set_lectern_property(key, "powered", powered);
|
||||
@@ -380,6 +379,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// The 2-tick pulses of turned lecterns.
|
||||
pub(super) fn tick_lecterns(&mut self) {
|
||||
self.follow_lectern();
|
||||
let mut done = Vec::new();
|
||||
for (key, left) in &mut self.lectern_pulses {
|
||||
*left -= 1;
|
||||
@@ -452,6 +452,7 @@ impl Game<'_> {
|
||||
.or_else(|| data.map(|d| d.writable_pages.clone()))
|
||||
.unwrap_or_default();
|
||||
let page = (l.page.max(0) as usize).min(pages.len().saturating_sub(1));
|
||||
self.stats.custom("interact_with_lectern", 1);
|
||||
self.open_book(BookScreen::View {
|
||||
pages,
|
||||
page,
|
||||
@@ -459,24 +460,189 @@ impl Game<'_> {
|
||||
});
|
||||
true
|
||||
}
|
||||
/// `LecternMenu` "Take Book".
|
||||
fn take_lectern_book(&mut self, key: (i32, i32, i32)) {
|
||||
let book = match self.world.block_entities.get_mut(&key) {
|
||||
Some(BlockEntity::Lectern(l)) => {
|
||||
l.page = 0;
|
||||
l.book.take()
|
||||
/// A `LecternMenu` button of the open lectern screen: on a server it is
|
||||
/// sent (`handleInventoryButtonClick`); here the lectern's menu takes it
|
||||
/// with the player's inventory, and the screen follows the block entity.
|
||||
fn lectern_button(&mut self, key: (i32, i32, i32), id: i32) {
|
||||
if self.remote.is_some() {
|
||||
if let Some((container, _)) = self.remote.as_ref().and_then(|r| r.menu.as_ref()) {
|
||||
self.send(
|
||||
horizon_protocol::packets::play::Serverbound::ContainerButtonClick(
|
||||
horizon_protocol::packets::play::ContainerButtonClick {
|
||||
container: *container,
|
||||
button: id,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
_ => None,
|
||||
return;
|
||||
}
|
||||
let Some(mut menu) = self.lectern_menu(key) else {
|
||||
return;
|
||||
};
|
||||
if let Some(book) = book
|
||||
&& let Some(rest) = self.give(book)
|
||||
&& let Some(eye) = self.eye_position()
|
||||
for i in 0..36 {
|
||||
menu.set_player_item(i, self.inventory[i].clone());
|
||||
}
|
||||
menu.set_player_item(40, self.offhand.clone());
|
||||
menu.creative = self.creative;
|
||||
menu.selected = self.selected;
|
||||
menu.may_build = self.may_build();
|
||||
if !menu.button(&self.menu_env(), id) {
|
||||
return;
|
||||
}
|
||||
for i in 0..36 {
|
||||
self.inventory[i] = menu.player_item(i).cloned();
|
||||
}
|
||||
self.offhand = menu.player_item(40).cloned();
|
||||
for stack in std::mem::take(&mut menu.dropped) {
|
||||
self.player_drop(stack);
|
||||
}
|
||||
self.lectern_menu_done(key, &menu);
|
||||
self.follow_lectern();
|
||||
}
|
||||
/// The `book` control command: a button of the open book or lectern
|
||||
/// screen (the page arrows, Take Book, Done), then its page.
|
||||
pub(super) fn book_control(
|
||||
&mut self,
|
||||
action: Option<&str>,
|
||||
) -> horizon_client::Result<serde_json::Value> {
|
||||
let lectern = match &self.book {
|
||||
Some(BookScreen::View { lectern, .. }) => *lectern,
|
||||
Some(BookScreen::Edit { .. }) => None,
|
||||
None => return Err("no book is open".into()),
|
||||
};
|
||||
match action {
|
||||
None => {}
|
||||
Some("next") => self.turn_page(true),
|
||||
Some("back") => self.turn_page(false),
|
||||
Some("take") => match lectern {
|
||||
Some(key) => self.lectern_button(key, 3),
|
||||
None => return Err("not a lectern".into()),
|
||||
},
|
||||
Some("done") => self.close_book(),
|
||||
Some(other) => return Err(format!("unknown book button {other}").into()),
|
||||
}
|
||||
Ok(match &self.book {
|
||||
Some(BookScreen::View {
|
||||
pages,
|
||||
page,
|
||||
lectern,
|
||||
}) => serde_json::json!({
|
||||
"page": page, "pages": pages.len(), "lectern": lectern.map(|k| [k.0, k.1, k.2]),
|
||||
}),
|
||||
Some(BookScreen::Edit { pages, page, .. }) => {
|
||||
serde_json::json!({ "page": page, "pages": pages.len(), "lectern": null })
|
||||
}
|
||||
None => serde_json::json!({ "closed": true }),
|
||||
})
|
||||
}
|
||||
/// A server's `minecraft:lectern` menu: LecternScreen, its book and
|
||||
/// page to come (`remote_lectern_book`, `remote_lectern_page`).
|
||||
pub(super) fn remote_open_lectern(&mut self, key: (i32, i32, i32)) {
|
||||
self.open_book(BookScreen::View {
|
||||
pages: Vec::new(),
|
||||
page: 0,
|
||||
lectern: Some(key),
|
||||
});
|
||||
}
|
||||
/// `LecternScreen.bookChanged`: the menu's book slot.
|
||||
pub(super) fn remote_lectern_book(&mut self, book: Option<ItemStack>) {
|
||||
if let Some(BookScreen::View {
|
||||
lectern: Some(_),
|
||||
pages,
|
||||
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();
|
||||
*page = (*page).min(pages.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
/// `LecternScreen.pageChanged`: the menu's page data slot.
|
||||
pub(super) fn remote_lectern_page(&mut self, value: i32) {
|
||||
if let Some(BookScreen::View {
|
||||
lectern: Some(_),
|
||||
pages,
|
||||
page,
|
||||
}) = self.book.as_mut()
|
||||
{
|
||||
self.spawn_item(rest, [eye[0], eye[1] - 0.3, eye[2]], [0.0, 0.1, 0.0], 40);
|
||||
*page = (value.max(0) as usize).min(pages.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
/// The server closed its lectern menu; false when none is open.
|
||||
pub(super) fn remote_lectern_closed(&mut self) -> bool {
|
||||
if !matches!(
|
||||
self.book,
|
||||
Some(BookScreen::View {
|
||||
lectern: Some(_),
|
||||
..
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.menu = None;
|
||||
r.menu_title = None;
|
||||
}
|
||||
self.book = None;
|
||||
self.capture(true);
|
||||
true
|
||||
}
|
||||
/// `LecternMenu` over the lectern at `key` (the menu engine, for the
|
||||
/// local player and LAN guests alike): its book and page.
|
||||
pub(super) fn lectern_menu(&self, key: (i32, i32, i32)) -> Option<Menu> {
|
||||
let Some(BlockEntity::Lectern(l)) = self.world.block_entities.get(&key) else {
|
||||
return None;
|
||||
};
|
||||
let mut menu = Menu::new(Kind::Lectern);
|
||||
menu.set_slot(&self.menu_env(), 0, l.book.clone());
|
||||
menu.data[0] = l.page;
|
||||
Some(menu)
|
||||
}
|
||||
/// The lectern menu's book and page back in the block entity: a new page
|
||||
/// is `LecternBlock.signalPageChange` (the 2-tick pulse, level event
|
||||
/// 1043), a book gone `onBookItemRemove` (`resetBookState`).
|
||||
pub(super) fn lectern_menu_done(&mut self, key: (i32, i32, i32), menu: &Menu) {
|
||||
let Some(BlockEntity::Lectern(l)) = self.world.block_entities.get_mut(&key) else {
|
||||
return;
|
||||
};
|
||||
let (had, before) = (l.book.is_some(), l.page);
|
||||
l.book = menu.slot(0).cloned();
|
||||
l.page = menu.data[0];
|
||||
if had && l.book.is_none() {
|
||||
self.set_lectern_powered(key, false);
|
||||
self.set_lectern_property(key, "has_book", false);
|
||||
self.container_changed(key);
|
||||
} else if menu.data[0] != before {
|
||||
self.set_lectern_powered(key, true);
|
||||
self.lectern_pulses.push((key, 2));
|
||||
self.level_event_at(1043, [key.0, key.1, key.2], 0);
|
||||
self.container_changed(key);
|
||||
}
|
||||
}
|
||||
/// The open lectern screen follows its block entity (`LecternScreen`'s
|
||||
/// menu listener): the page, and closing once the book is gone.
|
||||
pub(super) fn follow_lectern(&mut self) {
|
||||
let Some(BookScreen::View {
|
||||
lectern: Some(key),
|
||||
page,
|
||||
pages,
|
||||
}) = self.book.as_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
match self.world.block_entities.get(key) {
|
||||
Some(BlockEntity::Lectern(l)) if l.book.is_some() => {
|
||||
*page = (l.page.max(0) as usize).min(pages.len().saturating_sub(1));
|
||||
}
|
||||
_ => self.close_book(),
|
||||
}
|
||||
self.set_lectern_property(key, "has_book", false);
|
||||
self.container_changed(key);
|
||||
self.close_book();
|
||||
}
|
||||
pub(super) fn book_click(&mut self, size: [u32; 2]) {
|
||||
let b = HudBatch::new(size);
|
||||
@@ -537,7 +703,8 @@ impl Game<'_> {
|
||||
self.close_book();
|
||||
} else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) {
|
||||
click(self);
|
||||
self.take_lectern_book(key);
|
||||
// LecternScreen's Take Book: the menu's button 3.
|
||||
self.lectern_button(key, 3);
|
||||
}
|
||||
} else if inside((w - 200.0) / 2.0, 196.0, 200.0, 20.0) {
|
||||
click(self);
|
||||
|
||||
@@ -1,70 +1,80 @@
|
||||
//! The cartography table (`CartographyTableMenu`, `CartographyTableScreen`):
|
||||
//! zooming a map out with paper, cloning it with an empty map, locking it
|
||||
//! with a glass pane, the post-processing on take (`MapItem.scaleMap`,
|
||||
//! `lockMap`) and the preview with its error and lock sprites.
|
||||
//! The cartography table (`CartographyTableMenu` on the menu engine,
|
||||
//! `CartographyTableScreen`): zooming a map out with paper, cloning it with
|
||||
//! an empty map, locking it with a glass pane. The engine reads the level's
|
||||
//! maps through `LevelMaps`; the scaled and locked copies a take makes
|
||||
//! (`MapItem.scaleMap`, `lockMap`) are registered after the click. The
|
||||
//! screen draws the preview with its error and lock sprites.
|
||||
use super::{
|
||||
Game,
|
||||
maps::{MapData, map_id},
|
||||
stations::Station,
|
||||
};
|
||||
use horizon_client::{hud::HudBatch, items::ItemStack};
|
||||
use horizon_client::{hud::HudBatch, items::ItemStack, menu::Maps};
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
collections::HashMap,
|
||||
};
|
||||
|
||||
/// The level's maps as a menu sees them (`ContainerLevelAccess`): the
|
||||
/// states of the maps the click can reach, and the copies it makes.
|
||||
pub(super) struct LevelMaps {
|
||||
states: RefCell<HashMap<i32, (bool, u8)>>,
|
||||
next_id: Cell<i32>,
|
||||
/// (new id, source id, scaled) for each copy made.
|
||||
made: RefCell<Vec<(i32, i32, bool)>>,
|
||||
}
|
||||
|
||||
impl Maps for LevelMaps {
|
||||
fn state(&self, id: i32) -> Option<(bool, u8)> {
|
||||
self.states.borrow().get(&id).copied()
|
||||
}
|
||||
|
||||
fn derive(&self, id: i32, scale: bool) -> Option<i32> {
|
||||
let (_, s) = self.state(id)?;
|
||||
let new = self.next_id.get();
|
||||
self.next_id.set(new + 1);
|
||||
let made = if scale {
|
||||
(false, (s + 1).min(4))
|
||||
} else {
|
||||
(true, s)
|
||||
};
|
||||
self.states.borrow_mut().insert(new, made);
|
||||
self.made.borrow_mut().push((new, id, scale));
|
||||
Some(new)
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `setupResultSlot`.
|
||||
pub(super) fn cartography_result(&self, s: &Station) -> Option<ItemStack> {
|
||||
let (map, extra) = (s.inputs[0].as_ref()?, s.inputs[1].as_ref()?);
|
||||
let data = self.maps.data.get(&map_id(map)?)?;
|
||||
match &*extra.item {
|
||||
"minecraft:paper" if !data.locked && data.scale < 4 => Some(ItemStack {
|
||||
count: 1,
|
||||
..map.clone()
|
||||
}),
|
||||
"minecraft:glass_pane" if !data.locked => Some(ItemStack {
|
||||
count: 1,
|
||||
..map.clone()
|
||||
}),
|
||||
"minecraft:map" => Some(ItemStack {
|
||||
count: 2,
|
||||
..map.clone()
|
||||
}),
|
||||
_ => None,
|
||||
/// The maps of `stacks` (loaded as needed) for a menu over this level.
|
||||
pub(super) fn level_maps<'s>(
|
||||
&mut self,
|
||||
stacks: impl IntoIterator<Item = &'s ItemStack>,
|
||||
) -> LevelMaps {
|
||||
let ids: Vec<i32> = stacks.into_iter().filter_map(map_id).collect();
|
||||
let mut states = HashMap::new();
|
||||
for id in ids {
|
||||
if let Some(m) = self.map(id) {
|
||||
states.insert(id, (m.locked, m.scale));
|
||||
}
|
||||
}
|
||||
LevelMaps {
|
||||
states: RefCell::new(states),
|
||||
next_id: Cell::new(self.peek_map_id()),
|
||||
made: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
/// `onCraftedPostProcess` for the taken result: a scaled or locked copy
|
||||
/// under a new map id. Also spends the inputs' extra item kind check.
|
||||
pub(super) fn cartography_post(&mut self, mut result: ItemStack) -> ItemStack {
|
||||
let extra = self
|
||||
.station
|
||||
.as_ref()
|
||||
.and_then(|s| s.inputs[1].as_ref())
|
||||
.map(|e| e.item.clone());
|
||||
let Some(id) = map_id(&result) else {
|
||||
return result;
|
||||
};
|
||||
let new = match extra.as_deref() {
|
||||
Some("minecraft:paper") => self.map(id).map(|m| m.scaled()),
|
||||
Some("minecraft:glass_pane") => self.map(id).map(|m| m.locked_copy()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(data) = new {
|
||||
let new_id = self.new_map_id(data);
|
||||
result.data_mut().map_id = Some(new_id);
|
||||
|
||||
/// The scaled and locked copies a menu made (`level.setMapData`).
|
||||
pub(super) fn register_made_maps(&mut self, maps: LevelMaps) {
|
||||
for (id, source, scale) in maps.made.into_inner() {
|
||||
let Some(data) = self
|
||||
.map(source)
|
||||
.map(|m| if scale { m.scaled() } else { m.locked_copy() })
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let new = self.new_map_id(data);
|
||||
debug_assert_eq!(new, id, "map ids follow peek_map_id");
|
||||
}
|
||||
result
|
||||
}
|
||||
/// The take sound (once per tick).
|
||||
pub(super) fn cartography_taken(&mut self, pos: [i32; 3]) {
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
self.audio.play(
|
||||
"minecraft:ui.cartography_table.take_result",
|
||||
Some(at),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
/// `CartographyTableScreen.extractBackground` after the background.
|
||||
pub(super) fn draw_cartography(&mut self, batch: &mut HudBatch, ox: f32, oy: f32) {
|
||||
|
||||
@@ -5,7 +5,7 @@ use horizon_client::{
|
||||
chat::{Chat, ChatAction, ChatScreen, Commands, SYSTEM_INDICATOR},
|
||||
command::Registries,
|
||||
gamerules::GameRules,
|
||||
hud::{HudBatch, rgba},
|
||||
hud::HudBatch,
|
||||
screen_draw::Measure,
|
||||
text::{ClickEvent, Style, Text},
|
||||
};
|
||||
@@ -883,10 +883,6 @@ impl Game<'_> {
|
||||
.chat
|
||||
.add(&self.gui.atlas, message, None, self.ticks, focused);
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
fn chat_debug_fill(&self, batch: &mut HudBatch) {
|
||||
batch.fill(&self.gui.atlas, 0.0, 0.0, 1.0, 1.0, rgba(0, 0, 0, 0));
|
||||
}
|
||||
}
|
||||
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, local time
|
||||
/// approximated as UTC).
|
||||
|
||||
@@ -294,6 +294,18 @@ impl Game<'_> {
|
||||
}
|
||||
// ---- entities -------------------------------------------------------
|
||||
|
||||
/// The level `t` is in (`Entity.level`): a server's players have their
|
||||
/// own, which need not be the one in place; everything else is in the
|
||||
/// level in place.
|
||||
pub(super) fn target_level(&self, t: Target) -> Dim {
|
||||
let conn = match t {
|
||||
Target::Player => self.lan_acting,
|
||||
Target::Guest(conn) => Some(conn),
|
||||
_ => None,
|
||||
};
|
||||
conn.and_then(|c| self.command_guest(c))
|
||||
.map_or(self.dim, |g| Dim::from_id(&g.dimension))
|
||||
}
|
||||
pub(super) fn target_position(&self, t: Target) -> Option<[f64; 3]> {
|
||||
match t {
|
||||
Target::Player => self
|
||||
@@ -1191,15 +1203,12 @@ impl Game<'_> {
|
||||
pos: [f64; 3],
|
||||
rotation: Option<[f32; 2]>,
|
||||
) -> bool {
|
||||
let (conn, at) = match t {
|
||||
Target::Player => (self.lan_acting, self.dim),
|
||||
Target::Guest(conn) => (
|
||||
Some(conn),
|
||||
self.command_guest(conn)
|
||||
.map_or(self.dim, |g| super::Dim::from_id(&g.dimension)),
|
||||
),
|
||||
_ => (None, self.dim),
|
||||
let conn = match t {
|
||||
Target::Player => self.lan_acting,
|
||||
Target::Guest(conn) => Some(conn),
|
||||
_ => None,
|
||||
};
|
||||
let at = self.target_level(t);
|
||||
let Some(conn) = conn.filter(|_| at != level && self.options.dedicated.is_some()) else {
|
||||
return false;
|
||||
};
|
||||
@@ -1222,12 +1231,7 @@ impl Game<'_> {
|
||||
let dest = self.entities(ctx, "destination", false)?[0];
|
||||
let pos = self.target_position(dest).unwrap_or(self.source_position());
|
||||
// Into the destination's level.
|
||||
let level = match dest {
|
||||
Target::Guest(conn) => self
|
||||
.command_guest(conn)
|
||||
.map_or(self.dim, |g| super::Dim::from_id(&g.dimension)),
|
||||
_ => self.dim,
|
||||
};
|
||||
let level = self.target_level(dest);
|
||||
// TeleportCommand.teleportToEntity: the destination's rotation too.
|
||||
let rotation = Some(self.target_rotation(dest));
|
||||
for &t in &targets {
|
||||
@@ -2281,12 +2285,23 @@ impl Game<'_> {
|
||||
};
|
||||
let yaw = ((rot[0] + 180.0).rem_euclid(360.0)) - 180.0;
|
||||
let pitch = rot[1].clamp(-90.0, 90.0);
|
||||
self.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
));
|
||||
self.respawn_config = None;
|
||||
// SetSpawnCommand: a forced point in the source's level.
|
||||
if self.dim == Dim::Overworld {
|
||||
self.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
));
|
||||
self.respawn_config = None;
|
||||
} else {
|
||||
self.spawn = None;
|
||||
self.respawn_config = Some(super::respawn_anchor::RespawnConfig {
|
||||
dim: self.dim,
|
||||
pos,
|
||||
yaw,
|
||||
forced: true,
|
||||
});
|
||||
}
|
||||
let dim = Text::literal(self.dim.id());
|
||||
let p = pos.map(|v| Text::literal(v.to_string()));
|
||||
let mut args = vec![
|
||||
@@ -2322,7 +2337,20 @@ impl Game<'_> {
|
||||
Some(Arg::Coords(c)) => c.rotation(self.source_rotation()),
|
||||
_ => [0.0, 0.0],
|
||||
};
|
||||
self.level.spawn = Some([f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])]);
|
||||
// LevelData.setRespawnData: the world's spawn (level.dat `spawn`).
|
||||
let old = self.level.world_spawn.replace(pos);
|
||||
self.level.world_spawn_angle = rot;
|
||||
// A player whose fallback is the world spawn (no spawnpoint of its
|
||||
// own; a bed or an anchor that is gone falls back to it) respawns at
|
||||
// the new one.
|
||||
let floor = |s: Vec3| [s.x.floor() as i32, s.y.floor() as i32, s.z.floor() as i32];
|
||||
if self.spawn.map(floor) == old {
|
||||
self.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
));
|
||||
}
|
||||
let args = [
|
||||
Text::literal(pos[0].to_string()),
|
||||
Text::literal(pos[1].to_string()),
|
||||
|
||||
@@ -155,14 +155,20 @@ impl Game<'_> {
|
||||
_ => s.pos,
|
||||
}
|
||||
}
|
||||
/// Evaluate `f` with `source` as the command source.
|
||||
/// Evaluate `f` with `source` as the command source, in the source's
|
||||
/// level (`CommandSourceStack.getLevel`): a server puts that level in
|
||||
/// place for it.
|
||||
pub(in crate::game) fn with_source<T>(
|
||||
&mut self,
|
||||
source: Source,
|
||||
f: impl FnOnce(&mut Self) -> T,
|
||||
) -> T {
|
||||
let previous = self.command_source.replace(source);
|
||||
let back = self.enter_command_level(source.dimension);
|
||||
let out = f(self);
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
self.command_source = previous;
|
||||
out
|
||||
}
|
||||
@@ -447,11 +453,13 @@ impl Game<'_> {
|
||||
"at" => targets(self)?
|
||||
.into_iter()
|
||||
.filter_map(|t| {
|
||||
// CommandSourceStack.withLevel(entity.level()).
|
||||
let level = self.target_level(t);
|
||||
Some(Source {
|
||||
pos: self.target_position(t)?,
|
||||
rot: self.target_rotation(t),
|
||||
dimension: self.dim,
|
||||
level: None,
|
||||
dimension: level,
|
||||
level: (level != self.dim).then_some(level),
|
||||
..s
|
||||
})
|
||||
})
|
||||
|
||||
@@ -810,7 +810,10 @@ impl Game<'_> {
|
||||
None => break,
|
||||
},
|
||||
};
|
||||
if self.slot_get(slot) != item && self.slot_set(slot, item) {
|
||||
// ItemStack.isEmpty: air (or a count of 0) is an empty slot.
|
||||
// SlotAccess.set counts every slot it sets, the same item too.
|
||||
let item = item.filter(|s| &*s.item != "minecraft:air" && s.count > 0);
|
||||
if self.slot_set(slot, item) {
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,8 +248,32 @@ impl Game<'_> {
|
||||
|
||||
/// Every tick: the size change, the wall the player cannot pass and
|
||||
/// damage outside the border.
|
||||
/// `BorderChangeListener.DelegateBorderChangeListener`'s packets: what
|
||||
/// changed on the border since the guests were last told of it.
|
||||
pub(in crate::game) fn lan_sync_border(&mut self, host: &mut crate::game::lan::LanHost) {
|
||||
let now = horizon_client::lan::border::Sent::of(&self.border);
|
||||
if let Some(sent) = host.border_sent.replace(now) {
|
||||
for packet in sent.changes(&self.border) {
|
||||
host.hub.broadcast(&packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(in crate::game) fn tick_worldborder(&mut self) {
|
||||
self.border.tick();
|
||||
self.player_border();
|
||||
self.border_damage();
|
||||
}
|
||||
/// The player's movement collides with the border as it is now
|
||||
/// (`CollisionGetter.borderCollision`): from inside, its walls stop it.
|
||||
pub(in crate::game) fn player_border(&mut self) {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.border = Some(self.border.geometry().clone());
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.baseTick`: a player outside the border, further than
|
||||
/// its safe zone, is hurt by the blocks beyond it. The game's player, or
|
||||
/// a guest's in their turn (`as_guest`).
|
||||
pub(in crate::game) fn border_damage(&mut self) {
|
||||
let Some((px, pz, half)) = self.player.as_ref().map(|p| {
|
||||
(
|
||||
p.position.x,
|
||||
@@ -269,20 +293,6 @@ impl Game<'_> {
|
||||
let amount = (-dist * self.border.damage_per_block).floor().max(1.0) as f32;
|
||||
self.hurt_player_by("minecraft:outside_border", amount);
|
||||
}
|
||||
// From inside the border the wall stops movement.
|
||||
if self.border.distance(px, pz) >= 0.0 {
|
||||
let [x, z] = self.border.clamp(px, pz, half);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
if x != px {
|
||||
p.velocity.x = 0.0;
|
||||
}
|
||||
if z != pz {
|
||||
p.velocity.z = 0.0;
|
||||
}
|
||||
p.position.x = x;
|
||||
p.position.z = z;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn cmd_worldborder(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let float2 = |v: f64| format!("{v:.2}");
|
||||
|
||||
@@ -42,6 +42,7 @@ const HELP: &[&str] = &[
|
||||
"chat MESSAGE send chat or a /command to the server",
|
||||
"disconnect leave the server for the server list",
|
||||
"respawn respawn after death on a server",
|
||||
"credits [poem] [SCROLL] roll the credits (with the end poem), or hold them SCROLL pixels in",
|
||||
"quit save and exit",
|
||||
];
|
||||
|
||||
@@ -117,6 +118,7 @@ impl Game<'_> {
|
||||
self.answer(request, event_loop);
|
||||
}
|
||||
while let Some(request) = self.control.as_ref().and_then(|c| c.try_recv()) {
|
||||
self.agent_command_seen();
|
||||
self.answer(request, event_loop);
|
||||
if self.pending_save.is_some() {
|
||||
break;
|
||||
@@ -125,6 +127,11 @@ impl Game<'_> {
|
||||
if self.control.is_some() {
|
||||
self.watch_events();
|
||||
}
|
||||
if self.screen == Screen::Options
|
||||
&& self.ui.options.page() == horizon_client::options_ui::Page::Agent
|
||||
{
|
||||
self.ui.options.agent_status = self.agent_status_lines();
|
||||
}
|
||||
}
|
||||
|
||||
fn answer(&mut self, request: horizon_client::control::Request, event_loop: &ActiveEventLoop) {
|
||||
@@ -165,6 +172,9 @@ impl Game<'_> {
|
||||
if let Some(value) = self.events_command(&words, reply)? {
|
||||
return Ok(value);
|
||||
}
|
||||
if let Some(value) = self.saves_control(&words, line, reply)? {
|
||||
return Ok(value);
|
||||
}
|
||||
if let Some(value) = self.options_control(&words, line)? {
|
||||
return Ok(Some(value));
|
||||
}
|
||||
@@ -192,6 +202,7 @@ impl Game<'_> {
|
||||
.chain(super::ui_control::HELP)
|
||||
.chain(super::options::CONTROL_HELP)
|
||||
.chain(super::events::HELP)
|
||||
.chain(super::saves_control::HELP)
|
||||
.chain(super::pad::HELP)
|
||||
.collect::<Vec<_>>() }))),
|
||||
"setblock" | "fill" | "particle" | "signedit" => self.render_command(&words),
|
||||
@@ -523,13 +534,22 @@ impl Game<'_> {
|
||||
Screen::Inventory if self.recipe_search_focused() => {
|
||||
self.recipe_text(Some(text), false)
|
||||
}
|
||||
// The anvil's rename box.
|
||||
Screen::Inventory
|
||||
if self
|
||||
.station
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.kind == super::stations::Kind::Anvil) =>
|
||||
{
|
||||
self.station_text(Some(text), false)
|
||||
}
|
||||
Screen::Creative if self.creative_screen.tab().search => {
|
||||
// As typed: replaces a selected query, keeps the length limit.
|
||||
self.creative_search_edit(Some(text), false)
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"type needs the chat screen (key t tap), the language search, the creative search or World Options".into(),
|
||||
"type needs the chat screen (key t tap), the language search, the creative search, the anvil or World Options".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -555,6 +575,10 @@ impl Game<'_> {
|
||||
"graphics" => Page::Graphics,
|
||||
"graphics-advanced" => Page::GraphicsAdvanced,
|
||||
"extras" => Page::Extras,
|
||||
"credits" => Page::Credits,
|
||||
"horizon" => Page::Horizon,
|
||||
"agent" => Page::Agent,
|
||||
"shader-packs" => Page::ShaderPacks,
|
||||
other => return Err(format!("unknown options page {other}").into()),
|
||||
};
|
||||
if self.screen == Screen::Playing && self.title.is_none() {
|
||||
@@ -630,6 +654,25 @@ impl Game<'_> {
|
||||
self.connect_server(arg(1)?, &name)?;
|
||||
ok()
|
||||
}
|
||||
"credits" => {
|
||||
let poem = words.get(1) == Some(&"poem");
|
||||
let hold = words
|
||||
.get(1 + usize::from(poem))
|
||||
.map(|s| s.parse::<f32>())
|
||||
.transpose()?;
|
||||
if self.screen != Screen::Win {
|
||||
let finish = if self.screen == Screen::Options {
|
||||
super::win::Finish::Options
|
||||
} else {
|
||||
super::win::Finish::Closed
|
||||
};
|
||||
self.open_win_screen(poem, finish);
|
||||
}
|
||||
if let Some(scroll) = hold {
|
||||
self.hold_win_screen(scroll);
|
||||
}
|
||||
ok()
|
||||
}
|
||||
"respawn" => {
|
||||
if self.remote.is_none() || self.screen != Screen::Dead {
|
||||
return Err("respawn needs the death screen on a server".into());
|
||||
@@ -712,6 +755,9 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
fn screen_name(&self) -> &'static str {
|
||||
if self.screen == Screen::Win {
|
||||
return "win";
|
||||
}
|
||||
if self.title.is_some() {
|
||||
return "title";
|
||||
}
|
||||
@@ -727,7 +773,9 @@ impl Game<'_> {
|
||||
Screen::Stats => "stats",
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,6 +834,12 @@ impl Game<'_> {
|
||||
"velocity": [p.velocity.x, p.velocity.y, p.velocity.z],
|
||||
"on_ground": p.ground.on_ground,
|
||||
"health": p.health.health(),
|
||||
// The maximum the HUD's hearts last read (its cached value).
|
||||
"max_health": p
|
||||
.attributes
|
||||
.lookup(p.attributes.key("minecraft:max_health").as_ref())
|
||||
.and_then(|h| p.attributes.instance(&h).ok())
|
||||
.map(|i| i.cached_value()),
|
||||
"food": p.health.food.food,
|
||||
})
|
||||
});
|
||||
@@ -838,6 +892,8 @@ impl Game<'_> {
|
||||
"source_attempted": self.lod_source_attempted,
|
||||
"active_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().0),
|
||||
"cached_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().1),
|
||||
"far_detail": self.settings.horizon.far_detail.name(),
|
||||
"resident_quads": self.renderer.as_ref().map_or(0, |r| r.lod_quads()),
|
||||
"pending_observations": self.lod_observe.len(),
|
||||
"refined_samples": self.lod.as_ref().map_or(0, |lod| lod.refined_samples()),
|
||||
});
|
||||
@@ -888,13 +944,13 @@ impl Game<'_> {
|
||||
"chunks": self.world.chunks.len(),
|
||||
"mobs": self.mobs.len(),
|
||||
"block_entities": self.world.block_entities.len(),
|
||||
"pending_chunks": self.streaming.pending() + self.pending_uploads.len(),
|
||||
"pending_chunks": self.streaming.pending() + self.pending_uploads.len() + self.remote_backlog(),
|
||||
"generated_chunks": self.streaming.generated_total,
|
||||
"meshed_sections": self.streaming.meshed_total,
|
||||
"streaming": self.streaming.stages().into_iter().map(|(k, v)| (k.to_string(), v.into())).collect::<serde_json::Map<_, _>>(),
|
||||
"dirty_light": self.world.dirty_light.len(),
|
||||
"renderer": renderer,
|
||||
"display": self.window.as_ref().and_then(|w| w.current_monitor()).map(|m| json!({
|
||||
"display": self.window.as_deref().and_then(horizon_client::telemetry::monitor).map(|m| json!({
|
||||
"width": m.size().width,
|
||||
"height": m.size().height,
|
||||
"scale": m.scale_factor(),
|
||||
@@ -917,8 +973,16 @@ impl Game<'_> {
|
||||
status["heard"] = json!(self.audio.heard);
|
||||
// Boats, minecarts, armor stands, item frames and paintings.
|
||||
status["objects"] = json!(self.objects_status());
|
||||
status["lightning"] = json!({"bolts": self.bolts.len(), "struck": self.lightning_seen[0],
|
||||
"flashes": self.lightning_seen[1], "flash": self.flash});
|
||||
// Primed TNT, falling blocks, projectiles, tridents and end crystals.
|
||||
status["projectiles"] = json!(self.projectiles_status());
|
||||
// The particles made so far, by type.
|
||||
status["particles"] = json!(self.particles.made());
|
||||
// The world border this side knows of.
|
||||
status["border"] = json!({ "center": self.border.center(), "size": self.border.size() });
|
||||
// The scoreboard sidebar the HUD shows.
|
||||
status["sidebar"] = self.sidebar_status();
|
||||
status
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +330,10 @@ impl Game<'_> {
|
||||
"held": m.extra.main_hand.as_deref(),
|
||||
"mount": m.extra.mount,
|
||||
"rider": m.extra.rider,
|
||||
// A player's input this tick, and whether it steers.
|
||||
"ridden": m.rider.map(|r| r.steer),
|
||||
"controlled": horizon_client::mobs::equine::controlled(m),
|
||||
"saddled": m.extra.saddled,
|
||||
"enchants": horizon_client::mobs::gear::gear(m).map(|g| g.slots.iter().map(|s| s.iter().map(|(e, l)| format!("{e}:{l}")).collect::<Vec<_>>()).collect::<Vec<_>>()),
|
||||
"ints": m.extra.ints,
|
||||
"pois": [m.extra.home, m.extra.bed, m.extra.meeting],
|
||||
@@ -339,8 +343,12 @@ impl Game<'_> {
|
||||
"on_ground": m.on_ground,
|
||||
"variant": m.extra.variant,
|
||||
"color": m.extra.color,
|
||||
"sheared": m.extra.sheared,
|
||||
"burning": m.fire_ticks > 0,
|
||||
"size": m.extra.size,
|
||||
"leash": m.extra.leash.map(|h| format!("{h:?}")),
|
||||
"tame": m.extra.tame,
|
||||
"sitting": m.extra.sitting,
|
||||
"temper": m.extra.variant3,
|
||||
"chested": m.extra.chested,
|
||||
"head": m.extra.head.as_deref(),
|
||||
@@ -390,6 +398,7 @@ impl Game<'_> {
|
||||
player,
|
||||
others,
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
daylight: self.dim != Dim::Nether && self.sky_darken() < 4,
|
||||
game_time: self.world.game_time,
|
||||
day_time: self.world.day_time,
|
||||
@@ -402,7 +411,7 @@ impl Game<'_> {
|
||||
end: self.dim == Dim::End,
|
||||
world_seed: self.level.seed,
|
||||
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
|
||||
teams: self.mob_teams(),
|
||||
teams: std::sync::Arc::new(self.mob_teams()),
|
||||
items: Some(self.res.items()),
|
||||
difficulty: self.local_difficulty(
|
||||
self.player
|
||||
@@ -499,13 +508,26 @@ impl Game<'_> {
|
||||
// The level random, lent to the mobs (brain behaviours draw from
|
||||
// it) and taken back after their ticks.
|
||||
let mut scratch = self.world.random.clone();
|
||||
let mut pushes = Vec::new();
|
||||
let mut pushes = horizon_client::mobs::PendingPushes::default();
|
||||
let mut keep = Vec::with_capacity(mobs.len());
|
||||
let mut grid =
|
||||
horizon_client::mobs::seen_grid::SeenGrid::new(snapshot.iter().map(|s| s.bounds));
|
||||
// All but the snapshot is the same for every mob of the tick, so
|
||||
// build it once: the player view and the teams scan every UUID.
|
||||
let base = self.mob_world_base(&[]);
|
||||
// Each mob keeps its snapshot slot all tick; the first slot of an
|
||||
// id answers, as a scan would.
|
||||
let mut index = horizon_client::fast_hash::FxHashMap::<u32, u32>::default();
|
||||
for (i, s) in snapshot.iter().enumerate() {
|
||||
index.entry(s.id).or_insert(i as u32);
|
||||
}
|
||||
for i in 0..mobs.len() {
|
||||
let mut w = self.mob_world_base(&snapshot);
|
||||
w.others_grid = Some(&grid);
|
||||
let mut w = MobWorld {
|
||||
others: &snapshot,
|
||||
others_grid: Some(&grid),
|
||||
others_index: Some(&index),
|
||||
..base.clone()
|
||||
};
|
||||
let m = &mut mobs[i];
|
||||
w.nearest_player = super::natural_spawning::nearest_player(
|
||||
&players,
|
||||
@@ -589,7 +611,11 @@ impl Game<'_> {
|
||||
if let Some(host) = self.lan.as_mut()
|
||||
&& !guests.is_empty()
|
||||
{
|
||||
bindings.retain(|id, _| mobs.iter().any(|m| m.id == *id));
|
||||
// A set: a scan of every mob for each binding is quadratic, and a
|
||||
// server's loaded mobs run into the thousands.
|
||||
let live: horizon_client::fast_hash::FxHashSet<u32> =
|
||||
mobs.iter().map(|m| m.id).collect();
|
||||
bindings.retain(|id, _| live.contains(id));
|
||||
host.mob_players = bindings;
|
||||
}
|
||||
self.mobs.append(&mut mobs);
|
||||
@@ -2107,15 +2133,22 @@ impl Game<'_> {
|
||||
let from = pl.position;
|
||||
self.spawn_mob("minecraft:endermite", [from.x, from.y, from.z], false);
|
||||
}
|
||||
// To where the pearl started its last tick (oldPosition).
|
||||
let to = p.previous;
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.position = at;
|
||||
pl.position = to;
|
||||
pl.velocity = Vec3::ZERO;
|
||||
pl.fall_distance = 0.0;
|
||||
pl.teleported = true;
|
||||
}
|
||||
self.previous_position = at;
|
||||
self.previous_position = to;
|
||||
self.hurt_player_by("minecraft:ender_pearl", 5.0);
|
||||
self.audio
|
||||
.play("minecraft:entity.player.teleport", Some(atp), 1.0, 1.0);
|
||||
self.audio.play(
|
||||
"minecraft:entity.player.teleport",
|
||||
Some([to.x, to.y, to.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
(ProjectileKind::SmallFireball, hit) => match hit {
|
||||
@@ -2922,6 +2955,10 @@ impl Game<'_> {
|
||||
let scale = f64::from(self.settings.entity_distance);
|
||||
for mob in &self.mobs {
|
||||
let entity_start = out.len();
|
||||
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws.
|
||||
if mob.invisible {
|
||||
continue;
|
||||
}
|
||||
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
|
||||
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
|
||||
// mean edge, scaled by the entity distance option.
|
||||
|
||||
@@ -753,7 +753,7 @@ impl Game<'_> {
|
||||
let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a);
|
||||
let lerpf = |a: f32, b: f32| a + alpha * (b - a);
|
||||
for m in &self.mobs {
|
||||
if m.removed || m.has_effect("minecraft:invisibility") {
|
||||
if m.removed || m.invisible || m.has_effect("minecraft:invisibility") {
|
||||
continue;
|
||||
}
|
||||
let pos = [
|
||||
|
||||
@@ -291,12 +291,12 @@ impl Game<'_> {
|
||||
used = self.fixture_use_item(origin, op)?;
|
||||
} else if let Some(op) = case.get("respawn_at") {
|
||||
// ServerPlayer.findRespawnAndUseSpawnBlock for a respawn point at the origin.
|
||||
let config = super::respawn_anchor::RespawnConfig {
|
||||
dim: self.dim,
|
||||
pos: [origin.x, origin.y, origin.z],
|
||||
yaw: 0.0,
|
||||
forced: op["forced"].as_bool().unwrap_or(false),
|
||||
};
|
||||
let config = super::respawn_anchor::RespawnConfig::wrapped(
|
||||
self.dim,
|
||||
[origin.x, origin.y, origin.z],
|
||||
op["yaw"].as_f64().map_or(0.0, |v| v as f32),
|
||||
op["forced"].as_bool().unwrap_or(false),
|
||||
);
|
||||
let found =
|
||||
self.find_respawn_and_use(config, op["consume"].as_bool().unwrap_or(true))?;
|
||||
used.push(match found {
|
||||
|
||||
@@ -1736,7 +1736,9 @@ impl Game<'_> {
|
||||
mob.head_pitch = old.head_pitch;
|
||||
mob.persistent = old.persistent;
|
||||
mob.custom_name = old.custom_name.clone();
|
||||
mob.custom_name_text = old.custom_name_text.clone();
|
||||
mob.name_visible = old.name_visible;
|
||||
mob.invisible = old.invisible;
|
||||
mob.no_ai = old.no_ai;
|
||||
mob.silent = old.silent;
|
||||
mob.no_gravity = old.no_gravity;
|
||||
|
||||
@@ -2697,9 +2697,12 @@ impl Game<'_> {
|
||||
velocity: [d.velocity.x, d.velocity.y, d.velocity.z],
|
||||
data: 0,
|
||||
meta,
|
||||
defaults: Vec::new(),
|
||||
equipment: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
precise: false,
|
||||
needs_sync: false,
|
||||
leash: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,15 @@ use super::Game;
|
||||
use horizon_client::{enchant::EffectContext, items::ItemStack};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
|
||||
#[derive(Clone, Copy)]
|
||||
enum MendSlot {
|
||||
Main,
|
||||
Off,
|
||||
/// An `armor` index (head first).
|
||||
Armor(usize),
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Player.getDestroySpeed` after the tool speed: mining efficiency, haste
|
||||
/// and mining fatigue.
|
||||
@@ -314,36 +323,29 @@ impl Game<'_> {
|
||||
pub(super) fn mending(&mut self, amount: u32) -> u32 {
|
||||
let mut amount = amount;
|
||||
loop {
|
||||
// EnchantmentHelper.getRandomItemWith(REPAIR_WITH_XP, damaged): held and armor.
|
||||
let mut candidates: Vec<(bool, usize)> = Vec::new();
|
||||
if let Some(s) = &self.inventory[self.selected]
|
||||
&& s.damage > 0
|
||||
&& s.enchantment_level("minecraft:mending") > 0
|
||||
{
|
||||
candidates.push((false, self.selected));
|
||||
}
|
||||
for (i, s) in self.armor.iter().enumerate() {
|
||||
if let Some(s) = s
|
||||
&& s.damage > 0
|
||||
&& s.enchantment_level("minecraft:mending") > 0
|
||||
{
|
||||
candidates.push((true, i));
|
||||
}
|
||||
}
|
||||
// EnchantmentHelper.getRandomItemWith(REPAIR_WITH_XP, damaged), in
|
||||
// EquipmentSlot order: the main hand, the off hand, then feet, legs,
|
||||
// chest and head (`armor` holds head first).
|
||||
let mending = |s: &Option<ItemStack>| {
|
||||
s.as_ref()
|
||||
.is_some_and(|s| s.damage > 0 && s.enchantment_level("minecraft:mending") > 0)
|
||||
};
|
||||
let candidates: Vec<MendSlot> = [MendSlot::Main, MendSlot::Off]
|
||||
.into_iter()
|
||||
.chain((0..self.armor.len()).rev().map(MendSlot::Armor))
|
||||
.filter(|&slot| mending(self.mend_slot(slot)))
|
||||
.collect();
|
||||
if candidates.is_empty() {
|
||||
return amount;
|
||||
}
|
||||
let (armor, i) = candidates[self
|
||||
let slot = candidates[self
|
||||
.world
|
||||
.random
|
||||
.next_int_bound(candidates.len() as i32)
|
||||
.unwrap_or(0) as usize];
|
||||
let stack = if armor {
|
||||
self.armor[i].clone()
|
||||
} else {
|
||||
self.inventory[i].clone()
|
||||
let Some(stack) = self.mend_slot(slot).clone() else {
|
||||
return amount;
|
||||
};
|
||||
let Some(stack) = stack else { return amount };
|
||||
let ctx = EffectContext {
|
||||
items: Some(self.res.items()),
|
||||
..Default::default()
|
||||
@@ -356,12 +358,7 @@ impl Game<'_> {
|
||||
amount as f32,
|
||||
) as u32;
|
||||
let repaired = to_repair_xp.min(stack.damage);
|
||||
let slot = if armor {
|
||||
&mut self.armor[i]
|
||||
} else {
|
||||
&mut self.inventory[i]
|
||||
};
|
||||
if let Some(s) = slot.as_mut() {
|
||||
if let Some(s) = self.mend_slot_mut(slot).as_mut() {
|
||||
s.damage -= repaired;
|
||||
}
|
||||
if repaired == 0 || to_repair_xp == 0 {
|
||||
@@ -374,6 +371,20 @@ impl Game<'_> {
|
||||
amount = remaining;
|
||||
}
|
||||
}
|
||||
fn mend_slot(&self, slot: MendSlot) -> &Option<ItemStack> {
|
||||
match slot {
|
||||
MendSlot::Main => &self.inventory[self.selected],
|
||||
MendSlot::Off => &self.offhand,
|
||||
MendSlot::Armor(i) => &self.armor[i],
|
||||
}
|
||||
}
|
||||
fn mend_slot_mut(&mut self, slot: MendSlot) -> &mut Option<ItemStack> {
|
||||
match slot {
|
||||
MendSlot::Main => &mut self.inventory[self.selected],
|
||||
MendSlot::Off => &mut self.offhand,
|
||||
MendSlot::Armor(i) => &mut self.armor[i],
|
||||
}
|
||||
}
|
||||
/// Bow enchantments at release: (extra base damage, infinity keeps the arrow).
|
||||
pub(super) fn bow_enchantments(&mut self) -> (f64, bool) {
|
||||
let Some(bow) = self.inventory[self.selected].clone() else {
|
||||
|
||||
@@ -23,7 +23,7 @@ const GATEWAY_FILE: &str = "horizon-end-gateways.json";
|
||||
|
||||
/// A gateway teleport waiting for its destination chunks.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum GatewayJob {
|
||||
pub(super) enum GatewayJob {
|
||||
/// TheEndGatewayBlockEntity.findOrCreateValidTeleportPos from this gateway.
|
||||
Find { gateway: [i32; 3] },
|
||||
/// A known exit (exact, or findExitPosition around it).
|
||||
@@ -97,8 +97,58 @@ impl EndState {
|
||||
}
|
||||
}
|
||||
|
||||
impl EndState {
|
||||
/// The eyes' entity ids, for a server to number them.
|
||||
pub(super) fn eye_ids(&mut self) -> impl Iterator<Item = &mut u32> {
|
||||
self.eyes.iter_mut().map(|e| &mut e.id)
|
||||
}
|
||||
/// The numbered eyes as a server's clients track them: (id, position, motion).
|
||||
pub(super) fn eyes_tracked(&self) -> impl Iterator<Item = (u32, [f64; 3], [f64; 3])> {
|
||||
self.eyes
|
||||
.iter()
|
||||
.filter(|e| e.id != 0)
|
||||
.map(|e| (e.id, e.position.to_array(), e.velocity.to_array()))
|
||||
}
|
||||
/// `handleAddEntity` for an eye of ender on a server's client: it flies
|
||||
/// where the server says.
|
||||
pub(super) fn add_remote_eye(&mut self, id: u32, at: [f64; 3], velocity: [f64; 3]) {
|
||||
self.eyes.retain(|e| e.id != id);
|
||||
let at = glam::DVec3::from_array(at);
|
||||
self.eyes.push(EyeOfEnder {
|
||||
id,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: glam::DVec3::from_array(velocity),
|
||||
target: at,
|
||||
life: 0,
|
||||
survive: false,
|
||||
});
|
||||
}
|
||||
/// One tick of a server's client: `step` moves each toward where the
|
||||
/// server put it.
|
||||
pub(super) fn step_remote_eyes(&mut self, mut step: impl FnMut(u32, [f64; 3]) -> [f64; 3]) {
|
||||
for e in &mut self.eyes {
|
||||
e.previous = e.position;
|
||||
e.life += 1;
|
||||
e.position = glam::DVec3::from_array(step(e.id, e.position.to_array()));
|
||||
}
|
||||
}
|
||||
pub(super) fn remove_remote_eye(&mut self, id: u32) {
|
||||
self.eyes.retain(|e| e.id != id);
|
||||
}
|
||||
pub(super) fn clear_remote_eyes(&mut self) {
|
||||
self.eyes.clear();
|
||||
}
|
||||
/// Where each eye is, for `status`.
|
||||
pub(super) fn eye_positions(&self) -> impl Iterator<Item = (u32, [f64; 3])> {
|
||||
self.eyes.iter().map(|e| (e.id, e.position.to_array()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The EyeOfEnder entity.
|
||||
struct EyeOfEnder {
|
||||
/// Its entity id once a server's clients track it (0 until then).
|
||||
id: u32,
|
||||
position: glam::DVec3,
|
||||
previous: glam::DVec3,
|
||||
velocity: glam::DVec3,
|
||||
@@ -138,47 +188,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// SkyRenderer.buildEndSky: six faces of end_sky.png tiled 16 times,
|
||||
/// tinted 0x282828. The atlas does not wrap, so each face is 16×16 quads.
|
||||
pub(super) fn end_sky_geometry(&self) -> Vec<Vertex> {
|
||||
let mut out = Vec::with_capacity(6 * 256 * 4);
|
||||
let Some(r) = self.gui.atlas.sprites.get("environment/end_sky").copied() else {
|
||||
/// SkyRenderer.renderEndFlash, drawn after the renderer's resident sky mesh.
|
||||
pub(super) fn end_flash_geometry(&self) -> Vec<Vertex> {
|
||||
let mut out = Vec::new();
|
||||
// The old combined sky/flash draw also skipped the flash without a sky sprite.
|
||||
if !self.gui.atlas.sprites.contains_key("environment/end_sky") {
|
||||
return out;
|
||||
};
|
||||
use std::f32::consts::FRAC_PI_2;
|
||||
let faces = [
|
||||
glam::Mat4::IDENTITY,
|
||||
glam::Mat4::from_rotation_x(FRAC_PI_2),
|
||||
glam::Mat4::from_rotation_x(-FRAC_PI_2),
|
||||
glam::Mat4::from_rotation_x(std::f32::consts::PI),
|
||||
glam::Mat4::from_rotation_z(FRAC_PI_2),
|
||||
glam::Mat4::from_rotation_z(-FRAC_PI_2),
|
||||
];
|
||||
let step = 200.0 / 16.0;
|
||||
for pose in faces {
|
||||
for a in 0..16 {
|
||||
for b in 0..16 {
|
||||
let (x0, z0) = (-100.0 + step * a as f32, -100.0 + step * b as f32);
|
||||
let (x1, z1) = (x0 + step, z0 + step);
|
||||
let corners = [
|
||||
(x0, z0, [r.u0, r.v0]),
|
||||
(x0, z1, [r.u0, r.v1]),
|
||||
(x1, z1, [r.u1, r.v1]),
|
||||
(x1, z0, [r.u1, r.v0]),
|
||||
];
|
||||
for (x, z, uv) in corners {
|
||||
let p = pose.transform_point3(glam::Vec3::new(x, -100.0, z));
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: p.to_array(),
|
||||
uv,
|
||||
layer: 2,
|
||||
color: 0xFF28_2828,
|
||||
light: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// SkyRenderer.renderEndFlash: the end_flash sprite at height 100, scale 60.
|
||||
let intensity = self.end.flash.intensity(self.clock.fraction() as f32);
|
||||
@@ -377,7 +392,10 @@ impl Game<'_> {
|
||||
f64::from(p.dimensions.width),
|
||||
f64::from(p.dimensions.height),
|
||||
);
|
||||
if portal {
|
||||
if portal && self.dim == Dim::End && !self.level.seen_credits {
|
||||
// EndPortalBlock.entityInside: the first exit shows the credits.
|
||||
self.show_end_credits()?;
|
||||
} else if portal {
|
||||
if self.portals.player.cooldown == 0 {
|
||||
let target = if self.dim == Dim::End {
|
||||
Dim::Overworld
|
||||
@@ -448,7 +466,15 @@ impl Game<'_> {
|
||||
f64::from(END_SPAWN[2]) + 0.5,
|
||||
));
|
||||
}
|
||||
// Leaving the End: the respawn position (the world spawn here).
|
||||
// Leaving the End: the respawn position, a bed's found once it has
|
||||
// loaded (findRespawnPositionAndUseSpawnBlock, not using it up).
|
||||
if let Some(c) = self.respawn_config.filter(|c| c.dim == target) {
|
||||
return Some(Vec3::new(
|
||||
f64::from(c.pos[0]) + 0.5,
|
||||
f64::from(c.pos[1]),
|
||||
f64::from(c.pos[2]) + 0.5,
|
||||
));
|
||||
}
|
||||
let s = self.level.spawn.unwrap_or([0.0, 64.0, 0.0]);
|
||||
Some(Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5))
|
||||
}
|
||||
@@ -519,7 +545,16 @@ impl Game<'_> {
|
||||
// Direction.WEST.toYRot().
|
||||
return Ok((target, Some(90.0)));
|
||||
}
|
||||
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: onto the spawn column.
|
||||
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: where the bed or
|
||||
// anchor puts the player, facing it; else onto the spawn column.
|
||||
let mut target = target;
|
||||
if let Some(c) = self.respawn_config.filter(|c| c.dim == self.dim) {
|
||||
if let Some((at, yaw)) = self.find_respawn_and_use(c, false)? {
|
||||
return Ok((at, Some(yaw)));
|
||||
}
|
||||
let s = self.level.spawn.unwrap_or([0.0, 64.0, 0.0]);
|
||||
target = Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5);
|
||||
}
|
||||
let y = self
|
||||
.land_column(target.x.floor() as i32, target.z.floor() as i32)
|
||||
.map_or(target.y, f64::from);
|
||||
@@ -735,6 +770,20 @@ impl Game<'_> {
|
||||
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
|
||||
// TheEndGatewayBlockEntity.triggerCooldown: 40 ticks.
|
||||
self.end.gateway_cooldown = 40;
|
||||
let (job, destination) = self.gateway_job(gateway);
|
||||
let Some(player) = self.player.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
self.end.job = Some(job);
|
||||
self.end_travel = true;
|
||||
self.travelling = Some((player, destination));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where `gateway` leads (TheEndGatewayBlockEntity.getPortalPosition):
|
||||
/// its exit, or the search for one, and the spot to load before either
|
||||
/// is resolved ([`Game::resolve_gateway`]).
|
||||
pub(super) fn gateway_job(&mut self, gateway: [i32; 3]) -> (GatewayJob, Vec3) {
|
||||
let known = self.gateway_exits().get(&gateway).copied();
|
||||
let job = match known {
|
||||
Some((exit, exact)) => GatewayJob::Known { exit, exact },
|
||||
@@ -760,13 +809,7 @@ impl Game<'_> {
|
||||
Vec3::new(d.x, 75.0, d.y)
|
||||
}
|
||||
};
|
||||
let Some(player) = self.player.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
self.end.job = Some(job);
|
||||
self.end_travel = true;
|
||||
self.travelling = Some((player, destination));
|
||||
Ok(())
|
||||
(job, destination)
|
||||
}
|
||||
|
||||
fn chunk_empty(&self, x: f64, z: f64) -> Option<bool> {
|
||||
@@ -803,7 +846,7 @@ impl Game<'_> {
|
||||
|
||||
/// The teleport position of a gateway job, creating the exit gateway (and
|
||||
/// an island when the chunk has no landing spot) for new destinations.
|
||||
fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
|
||||
pub(super) fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
|
||||
let bottom_center = |p: [i32; 3]| {
|
||||
Vec3::new(
|
||||
f64::from(p[0]) + 0.5,
|
||||
@@ -1030,6 +1073,7 @@ impl Game<'_> {
|
||||
};
|
||||
let survive = self.world.random.next_int_bound(5).unwrap_or(0) > 0;
|
||||
self.end.eyes.push(EyeOfEnder {
|
||||
id: 0,
|
||||
position: start,
|
||||
previous: start,
|
||||
velocity: glam::DVec3::ZERO,
|
||||
|
||||
@@ -224,7 +224,7 @@ impl Game<'_> {
|
||||
}
|
||||
set.entries().map(|(k, _)| k.0).collect()
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT);
|
||||
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT, minecarts);
|
||||
/// the player takes `damage_type`.
|
||||
/// `ServerExplosion.hurtEntities` for the player: damage scaled by
|
||||
/// distance and exposure, through a raised shield (`hurtServer` blocks
|
||||
@@ -460,6 +460,34 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
self.tnts = tnts;
|
||||
// Minecarts: VehicleEntity.hurtServer (explosions ignite TNT carts),
|
||||
// then the push from their eyes (0.85 of their height).
|
||||
let ids: Vec<u32> = self.carts.iter().map(|c| c.id).collect();
|
||||
for id in ids {
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let bounds = self.carts[i].bounds();
|
||||
let feet = self.carts[i].position;
|
||||
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
self.hurt_cart(i, damage(dist, exposure), false, true);
|
||||
let Some(c) = self.carts.iter_mut().find(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let eye_height = f64::from((hi.y - lo.y) as f32 * 0.85f32);
|
||||
let dir = direction([feet.x, feet.y + eye_height, feet.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
c.velocity = Vec3::new(
|
||||
c.velocity.x + dir[0] * power,
|
||||
c.velocity.y + dir[1] * power,
|
||||
c.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `Level.explode` on the server: positions, entities, blocks, fire; then
|
||||
/// the client's explosion sound.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Play with friends, host side: "Invite friends" opens the world to LAN if
|
||||
//! Play with friends, host side: "Start Sharing" on the Play with Friends
|
||||
//! screen (`friends_screen`, the pause menu's Friends button) opens the world to LAN if
|
||||
//! it isn't (with Open to LAN's defaults) and shares that port behind an
|
||||
//! invite code (horizon-friends). The code holds this computer's addresses
|
||||
//! and the key, so friends join with no server of ours; a relay the player
|
||||
@@ -6,7 +7,12 @@
|
||||
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
|
||||
//! loopback address.
|
||||
use super::{Game, lan::LanSettings};
|
||||
use horizon_client::{Result, front::FriendsView, text::Text};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
friends_screen::{self, Action, FriendsView},
|
||||
hud::HudBatch,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
|
||||
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -52,10 +58,12 @@ impl Game<'_> {
|
||||
if self.friends.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.remote.is_some() || self.title.is_some() {
|
||||
// Single player on its server thread shares that server, as Open to
|
||||
// LAN publishes it; another server can't be shared.
|
||||
if self.title.is_some() || (self.remote.is_some() && self.integrated.is_none()) {
|
||||
return Err("open a singleplayer world first".into());
|
||||
}
|
||||
let opened_lan = self.lan.is_none();
|
||||
let opened_lan = self.lan_port().is_none();
|
||||
if opened_lan {
|
||||
// ShareToLanScreen's defaults, as the world options screen offers them.
|
||||
self.lan_open(LanSettings {
|
||||
@@ -153,7 +161,7 @@ impl Game<'_> {
|
||||
}
|
||||
if announce {
|
||||
self.system_message(Text::literal(
|
||||
"Your invite code is ready in the pause menu: copy it and send it to your friends.",
|
||||
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -169,16 +177,55 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the pause menu shows.
|
||||
/// Play with Friends. The world is paused under it in singleplayer, so
|
||||
/// new codes and sessions are read while it is drawn.
|
||||
pub(super) fn draw_friends_screen(&mut self, batch: &mut HudBatch) {
|
||||
self.tick_friends();
|
||||
let view = self.friends_view();
|
||||
friends_screen::draw(batch, &self.gui.atlas, self.cursor, &view, true);
|
||||
}
|
||||
pub(super) fn friends_screen_click(&mut self) {
|
||||
let view = self.friends_view();
|
||||
let Some(action) =
|
||||
friends_screen::click(&self.gui.atlas, self.gui_size(), self.cursor, &view)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.audio.play_button_click();
|
||||
match action {
|
||||
Action::Share => {
|
||||
if let Err(e) = self.friends_start() {
|
||||
self.system_message(Text::literal(format!("Couldn't share the world: {e}")));
|
||||
}
|
||||
}
|
||||
Action::Copy => self.friends_copy(),
|
||||
Action::Stop => self.friends_stop(),
|
||||
// The system browser, as the Shader Packs page opens its folder.
|
||||
Action::Help => {
|
||||
if let Some(url) = &view.help {
|
||||
super::packs::open_folder(std::path::Path::new(url));
|
||||
}
|
||||
}
|
||||
Action::Done => self.screen = super::Screen::Paused,
|
||||
}
|
||||
}
|
||||
|
||||
/// What Play with Friends shows.
|
||||
pub(super) fn friends_view(&self) -> FriendsView {
|
||||
let guests = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.map(|h| h.hub.guests.iter().map(|g| g.name.clone()).collect())
|
||||
.unwrap_or_default();
|
||||
let guests = match &self.lan {
|
||||
Some(h) => h.hub.guests.iter().map(|g| g.name.clone()).collect(),
|
||||
// On the server thread: the other players the server lists.
|
||||
None if self.integrated.is_some() => {
|
||||
let me = self.options.player_name();
|
||||
let mut names = self.remote_online_names().unwrap_or_default();
|
||||
names.retain(|n| *n != me);
|
||||
names
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
let Some(s) = &self.friends else {
|
||||
return FriendsView {
|
||||
shown: self.remote.is_none(),
|
||||
shown: self.remote.is_none() || self.integrated.is_some(),
|
||||
..FriendsView::default()
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
//! the world's options) with [`Integrated::call`], as vanilla's client calls
|
||||
//! into its `IntegratedServer`.
|
||||
//!
|
||||
//! On by default. `--no-server-thread` or `HORIZON_SERVER_THREAD=0` runs
|
||||
//! the world in the frame instead (the escape hatch while the network
|
||||
//! player's side catches up with the in-frame one).
|
||||
//! Opt-in again (`--server-thread` or `HORIZON_SERVER_THREAD=1`) until a
|
||||
//! server's players reach parity with the in-frame world: on the server
|
||||
//! thread, signs open no editor, books don't open, a totem doesn't save its
|
||||
//! holder and death keeps the inventory (#1380, #1366, #1292, #1284 and the
|
||||
//! other `[server-thread]` issues). LAN guests and dedicated servers use the
|
||||
//! same code either way.
|
||||
use super::{
|
||||
Game, Options, PackResources, Resources, UiResources,
|
||||
lan::{LanSettings, Owner},
|
||||
|
||||
@@ -9,12 +9,13 @@ use horizon_assets::item_model::{
|
||||
};
|
||||
use horizon_client::{
|
||||
block_entity_render::{diffuse_lights, vertex_light},
|
||||
hud::HudBatch,
|
||||
hud::{HudBatch, Rect},
|
||||
item_render::{self, ItemRenderState, SpecialHost, WorldSink},
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{cell::RefCell, collections::HashMap};
|
||||
|
||||
/// The game's answers to what the resolver cannot read from the stack.
|
||||
pub(super) struct Env<'a, 'g> {
|
||||
@@ -126,6 +127,8 @@ pub(super) fn local_time(pattern: &str) -> String {
|
||||
/// Banner and shield pattern textures from the banner pattern registry.
|
||||
pub(super) struct Host<'a> {
|
||||
banners: &'a horizon_client::banner::Banners,
|
||||
sprites: &'a HashMap<String, Rect>,
|
||||
skins: &'a RefCell<horizon_client::skins::Downloader>,
|
||||
}
|
||||
impl SpecialHost for Host<'_> {
|
||||
fn banner_texture(&self, pattern: &str) -> Option<String> {
|
||||
@@ -138,6 +141,18 @@ impl SpecialHost for Host<'_> {
|
||||
.pattern(pattern)
|
||||
.map(|_| self.banners.shield_texture(pattern))
|
||||
}
|
||||
fn player_skin(&self, profile: &Value) -> Option<String> {
|
||||
// SkullBlockRenderer.getPlayerSkin: SkinManager.getInsecureSkinInformation
|
||||
// accepts a texture property without a signature, unlike the tab list.
|
||||
let url = horizon_client::skins::profile_skin_url(profile)?;
|
||||
let location =
|
||||
horizon_client::skins::skin_location(&horizon_client::skins::texture_hash(&url)?);
|
||||
let _ = self
|
||||
.skins
|
||||
.borrow_mut()
|
||||
.state(horizon_client::skins::Kind::Skin, &url);
|
||||
self.sprites.contains_key(&location).then_some(location)
|
||||
}
|
||||
}
|
||||
|
||||
/// `ItemOwner` of an entity holding or showing an item.
|
||||
@@ -177,6 +192,8 @@ impl Game<'_> {
|
||||
pub(super) fn item_host(&self) -> Host<'_> {
|
||||
Host {
|
||||
banners: &self.res.stations().banners,
|
||||
sprites: &self.gui.atlas.sprites,
|
||||
skins: &self.skin_downloads,
|
||||
}
|
||||
}
|
||||
/// The client level as item models read it.
|
||||
|
||||
@@ -10,11 +10,11 @@ use horizon_client::{
|
||||
self, Lan, MAX_PLAYERS, NetConfig,
|
||||
chunks::ChunkEncoder,
|
||||
data::SyncData,
|
||||
entity::EntityState,
|
||||
entity::{EntityState, States},
|
||||
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
playerdata,
|
||||
},
|
||||
mobs::Mob,
|
||||
mobs::{Mob, leash::Holder},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
@@ -66,9 +66,14 @@ pub(super) struct LanHost {
|
||||
/// whose respawn replaces their waypoint.
|
||||
pub waypoints: std::collections::HashMap<String, lan::waypoints::Waypoints>,
|
||||
pub waypoint_dead: std::collections::HashSet<u128>,
|
||||
/// The world border as the guests were last told of it (lan/border.rs).
|
||||
pub border_sent: Option<lan::border::Sent>,
|
||||
/// Guests' commands waiting for the hub to be back in `self.lan`, where
|
||||
/// a command can reach every player (game/lan_actions.rs).
|
||||
pub commands: Vec<super::lan_actions::GuestCommand>,
|
||||
/// The entities the guests track, described again every tick
|
||||
/// (`lan_entities`) over last tick's states.
|
||||
states: lan::entity::States,
|
||||
}
|
||||
|
||||
/// The player who owns an integrated server's world: a guest whose state is
|
||||
@@ -88,7 +93,7 @@ pub(super) fn entity_uuid(id: u32) -> u128 {
|
||||
/// The SynchedEntityData index of the baby flag: AgeableMob.DATA_BABY_ID and
|
||||
/// Zombie.DATA_BABY_ID are both the first entry after Mob's (16); Piglin's
|
||||
/// comes after AbstractPiglin's zombification flag (17).
|
||||
fn baby_index(kind: &str) -> Option<u8> {
|
||||
pub(super) fn baby_index(kind: &str) -> Option<u8> {
|
||||
match kind {
|
||||
"minecraft:piglin" => Some(17),
|
||||
"minecraft:cow"
|
||||
@@ -136,8 +141,13 @@ fn baby_index(kind: &str) -> Option<u8> {
|
||||
/// A mob's synched data as ServerEntity sends it (only the values that differ
|
||||
/// from the defaults the client constructs), with vanilla's entry indices:
|
||||
/// Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses.
|
||||
fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> {
|
||||
let mut meta = Vec::new();
|
||||
/// `defaults` gets the defaults of the entries that can go back to theirs.
|
||||
fn mob_meta(
|
||||
m: &Mob,
|
||||
hub: &Hub,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
let kind = m.kind.id.as_str();
|
||||
if m.fire_ticks > 0 {
|
||||
meta.push((0, serializer::BYTE, Value::Byte(1)));
|
||||
@@ -157,15 +167,18 @@ fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> {
|
||||
if health != 1.0 {
|
||||
meta.push((9, serializer::FLOAT, Value::Float(health)));
|
||||
}
|
||||
defaults.push((9, serializer::FLOAT, Value::Float(1.0)));
|
||||
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2.
|
||||
let flags = i8::from(m.no_ai) | i8::from(m.extra.left_handed) << 1;
|
||||
if flags != 0 {
|
||||
meta.push((15, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
if m.is_baby()
|
||||
&& let Some(index) = baby_index(kind)
|
||||
{
|
||||
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
|
||||
// AgeableMob.DATA_BABY_ID (and Zombie's, Piglin's).
|
||||
if let Some(index) = baby_index(kind) {
|
||||
if m.is_baby() {
|
||||
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((index, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
let e = &m.extra;
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
@@ -176,6 +189,7 @@ fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> {
|
||||
if value != 0 {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(value as i8)));
|
||||
}
|
||||
defaults.push((18, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
"minecraft:cat" | "minecraft:wolf" => {
|
||||
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
|
||||
@@ -198,6 +212,13 @@ fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> {
|
||||
if let Some(v) = id(registry, name) {
|
||||
meta.push((index, serializer, Value::Id(v)));
|
||||
}
|
||||
// Wolf.DATA_COLLAR_COLOR and Cat's, red by default.
|
||||
let collar = if kind == "minecraft:cat" { 23 } else { 21 };
|
||||
let red = i32::from(horizon_client::mobs::tame::RED);
|
||||
if i32::from(e.color & 15) != red {
|
||||
meta.push((collar, serializer::INT, Value::Int(i32::from(e.color & 15))));
|
||||
}
|
||||
defaults.push((collar, serializer::INT, Value::Int(red)));
|
||||
}
|
||||
"minecraft:horse"
|
||||
| "minecraft:donkey"
|
||||
@@ -280,14 +301,16 @@ fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
meta
|
||||
}
|
||||
|
||||
/// The equipment a mob shows (ClientboundSetEquipmentPacket slots: main hand,
|
||||
/// off hand, feet, legs, chest, head, body, saddle).
|
||||
fn mob_equipment(m: &Mob, hub: &mut Hub) -> Vec<(u8, Option<horizon_protocol::item::Stack>)> {
|
||||
fn mob_equipment(
|
||||
m: &Mob,
|
||||
hub: &mut Hub,
|
||||
out: &mut Vec<(u8, Option<horizon_protocol::item::Stack>)>,
|
||||
) {
|
||||
let e = &m.extra;
|
||||
let mut out = Vec::new();
|
||||
let slots = [
|
||||
(0u8, &e.main_hand),
|
||||
(1, &e.off_hand),
|
||||
@@ -316,7 +339,6 @@ fn mob_equipment(m: &Mob, hub: &mut Hub) -> Vec<(u8, Option<horizon_protocol::it
|
||||
});
|
||||
out.push((7, hub.stack(&Some(stack))));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub const HELP: &[&str] = &[
|
||||
@@ -387,9 +409,14 @@ impl Game<'_> {
|
||||
"placed": g.placed,
|
||||
"loaded": g.loaded,
|
||||
"game_mode": g.game_mode,
|
||||
// ItemCooldowns: the ticks the shield an axe disabled stays down.
|
||||
"shield_cooldown": h.play.get(&g.conn).map_or(0, |p| p.shield_cooldown),
|
||||
"sneaking": g.sneaking,
|
||||
"sleeping": g.bed,
|
||||
"health": g.health,
|
||||
// The server's body: its air supply and golden hearts.
|
||||
"air": g.motion.as_ref().map(|m| m.air),
|
||||
"absorption": g.motion.as_ref().map(|m| m.health.absorption()),
|
||||
// FoodData: food level, saturation and exhaustion.
|
||||
"food": [
|
||||
f64::from(g.food),
|
||||
@@ -536,7 +563,9 @@ impl Game<'_> {
|
||||
owner: None,
|
||||
waypoints: Default::default(),
|
||||
waypoint_dead: Default::default(),
|
||||
border_sent: None,
|
||||
commands: Vec::new(),
|
||||
states: Default::default(),
|
||||
});
|
||||
self.audio.capture = Some(Vec::new());
|
||||
Ok(())
|
||||
@@ -740,77 +769,64 @@ impl Game<'_> {
|
||||
equipment,
|
||||
health: p.health.health(),
|
||||
attributes: horizon_client::lan::attributes::syncable(&p.attributes),
|
||||
living_flags: living_flags(self.using),
|
||||
// Bit 4: Riptide's spin (LivingEntity.isAutoSpinAttack).
|
||||
living_flags: living_flags(self.using)
|
||||
| if p.auto_spin_attack_ticks > 0 { 4 } else { 0 },
|
||||
bed: self.sleeping,
|
||||
})
|
||||
}
|
||||
|
||||
/// The entities guests track: mobs, items and the rest (game/lan_entities.rs).
|
||||
fn lan_entities(&self, host_entity: Option<i32>, hub: &mut Hub) -> Vec<EntityState> {
|
||||
let mut out = Vec::with_capacity(self.mobs.len() + self.dropped.len() + self.orbs.len());
|
||||
/// `mob_players` is the guest each mob sees as its player (game/lan_mobs.rs).
|
||||
fn lan_entities(
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
mob_players: &HashMap<u32, u32>,
|
||||
hub: &mut Hub,
|
||||
out: &mut States,
|
||||
) {
|
||||
out.clear();
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
let kind = m.kind.id.as_str();
|
||||
let meta = mob_meta(m, hub);
|
||||
let equipment = mob_equipment(m, hub);
|
||||
let attributes = self.mob_attributes(m);
|
||||
let (yaw, pitch) = (m.yaw, m.head_pitch);
|
||||
out.push(EntityState {
|
||||
id: m.id as i32,
|
||||
uuid: entity_uuid(m.id),
|
||||
kind: kind.to_owned(),
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
yaw,
|
||||
pitch,
|
||||
head_yaw: m.head_yaw,
|
||||
on_ground: m.on_ground,
|
||||
velocity: [m.velocity.x, m.velocity.y, m.velocity.z],
|
||||
data: 0,
|
||||
meta,
|
||||
equipment,
|
||||
attributes,
|
||||
precise: false,
|
||||
// The lead's holder: the player the mob sees (a guest, else the
|
||||
// host) or another mob. A fence knot isn't an entity here.
|
||||
let leash = m.extra.leash.and_then(|h| match h {
|
||||
Holder::Player => mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id)
|
||||
.or(host_entity),
|
||||
Holder::Mob(id) => Some(id as i32),
|
||||
Holder::Knot(_) => None,
|
||||
});
|
||||
let e = out.add(m.id as i32, entity_uuid(m.id), m.kind.id.as_str());
|
||||
e.position = [m.position.x, m.position.y, m.position.z];
|
||||
(e.yaw, e.pitch, e.head_yaw) = (m.yaw, m.head_pitch, m.head_yaw);
|
||||
e.on_ground = m.on_ground;
|
||||
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
|
||||
e.needs_sync = m.needs_sync;
|
||||
mob_meta(m, hub, &mut e.meta, &mut e.defaults);
|
||||
mob_equipment(m, hub, &mut e.equipment);
|
||||
self.mob_attributes(m, &mut e.attributes);
|
||||
e.leash = leash;
|
||||
}
|
||||
for d in &self.dropped {
|
||||
let stack = hub.stack(&Some(d.stack.clone()));
|
||||
out.push(EntityState {
|
||||
id: d.id as i32,
|
||||
uuid: entity_uuid(d.id),
|
||||
kind: "minecraft:item".to_owned(),
|
||||
position: [d.position.x, d.position.y, d.position.z],
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
head_yaw: 0.0,
|
||||
on_ground: d.on_ground,
|
||||
velocity: [d.velocity.x, d.velocity.y, d.velocity.z],
|
||||
data: 0,
|
||||
meta: vec![(8, serializer::ITEM_STACK, Value::Item(stack))],
|
||||
equipment: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
precise: false,
|
||||
});
|
||||
let e = out.add(d.id as i32, entity_uuid(d.id), "minecraft:item");
|
||||
e.position = [d.position.x, d.position.y, d.position.z];
|
||||
e.on_ground = d.on_ground;
|
||||
e.velocity = [d.velocity.x, d.velocity.y, d.velocity.z];
|
||||
e.meta.push((8, serializer::ITEM_STACK, Value::Item(stack)));
|
||||
}
|
||||
for o in &self.orbs {
|
||||
out.push(EntityState {
|
||||
id: o.id as i32,
|
||||
uuid: entity_uuid(o.id),
|
||||
kind: "minecraft:experience_orb".to_owned(),
|
||||
position: [o.position.x, o.position.y, o.position.z],
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
head_yaw: 0.0,
|
||||
on_ground: o.on_ground,
|
||||
velocity: [o.velocity.x, o.velocity.y, o.velocity.z],
|
||||
data: 0,
|
||||
// ExperienceOrb.DATA_VALUE.
|
||||
meta: vec![(8, serializer::INT, Value::Int(o.value as i32))],
|
||||
equipment: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
precise: false,
|
||||
});
|
||||
let e = out.add(o.id as i32, entity_uuid(o.id), "minecraft:experience_orb");
|
||||
e.position = [o.position.x, o.position.y, o.position.z];
|
||||
e.on_ground = o.on_ground;
|
||||
e.velocity = [o.velocity.x, o.velocity.y, o.velocity.z];
|
||||
// ExperienceOrb.DATA_VALUE.
|
||||
e.meta
|
||||
.push((8, serializer::INT, Value::Int(o.value as i32)));
|
||||
}
|
||||
self.lan_object_states(host_entity, hub, &mut out);
|
||||
out
|
||||
self.lan_object_states(host_entity, hub, out);
|
||||
}
|
||||
|
||||
/// Run the hub for this tick and apply what the guests did.
|
||||
@@ -829,24 +845,46 @@ impl Game<'_> {
|
||||
self.send_admin_feedback(&mut host, None);
|
||||
}
|
||||
let state = self.lan_host_state(&host);
|
||||
// An animal whose rider left the game (or the level) stops taking
|
||||
// its input.
|
||||
for m in &mut self.mobs {
|
||||
if m.rider.is_some()
|
||||
&& self.riding != Some(m.id)
|
||||
&& !host.hub.guests.iter().any(|g| g.vehicle == Some(m.id))
|
||||
{
|
||||
m.rider = None;
|
||||
}
|
||||
}
|
||||
// A dedicated server runs the hub without a host player.
|
||||
if state.is_some() || host.hub.config.dedicated {
|
||||
let has_guests = !host.hub.guests.is_empty();
|
||||
let entities = if has_guests {
|
||||
let mut states = std::mem::take(&mut host.states);
|
||||
if has_guests {
|
||||
let host_entity = state.as_ref().map(|s| s.entity_id);
|
||||
self.lan_assign_ids();
|
||||
self.lan_riding(host_entity, &mut host.hub.riding);
|
||||
self.lan_entities(host_entity, &mut host.hub)
|
||||
let guests: Vec<(u32, i32)> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
|
||||
.collect();
|
||||
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
|
||||
self.lan_entities(host_entity, &host.mob_players, &mut host.hub, &mut states);
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
states.clear();
|
||||
}
|
||||
// ServerEntity.sendChanges clears needsSync once it has looked.
|
||||
for m in &mut self.mobs {
|
||||
m.needs_sync = false;
|
||||
}
|
||||
let entities = states.as_slice();
|
||||
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();
|
||||
let mut ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
host: state.as_ref(),
|
||||
entities: &entities,
|
||||
entities,
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
difficulty: i32::from(self.ui.difficulty),
|
||||
@@ -856,6 +894,7 @@ impl Game<'_> {
|
||||
tick_frozen: self.clock.frozen(),
|
||||
tick_steps: self.clock.step_state(),
|
||||
collision: Some(self.collision),
|
||||
border: &self.border,
|
||||
};
|
||||
host.hub.movement_checks = (
|
||||
self.ui.rules.bool("player_movement_check"),
|
||||
@@ -887,7 +926,7 @@ impl Game<'_> {
|
||||
self.lan_log_event(event);
|
||||
}
|
||||
HostEvent::Joined(conn) => {
|
||||
self.lan_guest_joined(&mut host, conn, state.as_ref(), &entities, spawn);
|
||||
self.lan_guest_joined(&mut host, conn, state.as_ref(), entities, spawn);
|
||||
if self.options.dedicated.is_some()
|
||||
&& let Some(g) = host.hub.guest(conn)
|
||||
{
|
||||
@@ -952,6 +991,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
host.states = states;
|
||||
}
|
||||
self.lan_flush_effects(&mut host);
|
||||
self.tick_guests(&mut host);
|
||||
@@ -961,6 +1001,8 @@ impl Game<'_> {
|
||||
self.lan_update_streaming(&host);
|
||||
self.lan_update_models(&mut host);
|
||||
self.lan_update_waypoints(&mut host, state.as_ref());
|
||||
self.lan_sync_scoreboard(&mut host);
|
||||
self.lan_sync_border(&mut host);
|
||||
drop(holding);
|
||||
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
|
||||
self.lan = Some(host);
|
||||
@@ -1011,15 +1053,15 @@ impl Game<'_> {
|
||||
g.dimension = in_place;
|
||||
}
|
||||
self.load_guest_records(host, conn);
|
||||
let theirs;
|
||||
let mut theirs = States::default();
|
||||
let entities = if elsewhere {
|
||||
if let Err(e) = self.enter_level(Some(host), level) {
|
||||
log::error!("could not open {}: {e}", level.id());
|
||||
return;
|
||||
}
|
||||
// A dedicated server has no host player.
|
||||
theirs = self.lan_entities(None, &mut host.hub);
|
||||
&theirs[..]
|
||||
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs);
|
||||
theirs.as_slice()
|
||||
} else {
|
||||
entities
|
||||
};
|
||||
@@ -1037,6 +1079,7 @@ impl Game<'_> {
|
||||
tick_frozen: self.clock.frozen(),
|
||||
tick_steps: self.clock.step_state(),
|
||||
collision: Some(self.collision),
|
||||
border: &self.border,
|
||||
};
|
||||
host.hub.place_new_player(conn, &mut ctx);
|
||||
if elsewhere && let Err(e) = self.enter_level(Some(host), back) {
|
||||
@@ -1051,8 +1094,14 @@ impl Game<'_> {
|
||||
}
|
||||
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.
|
||||
let except = if *id == 2015 { None } else { self.lan_acting };
|
||||
// The acting guest does not predict the egg's teleport
|
||||
// particles, nor bone meal's growth (BoneMealItem.useOn's
|
||||
// levelEvent names no player).
|
||||
let except = if matches!(*id, 2015 | 1505) {
|
||||
None
|
||||
} else {
|
||||
self.lan_acting
|
||||
};
|
||||
self.lan_events
|
||||
.push((*id, [pos.x, pos.y, pos.z], *data, except));
|
||||
}
|
||||
@@ -1253,7 +1302,7 @@ impl Game<'_> {
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
for m in host.models.values() {
|
||||
for (conn, m) in &host.models {
|
||||
let entity_start = out.len();
|
||||
let light = self.world.light(
|
||||
m.position.x.floor() as i32,
|
||||
@@ -1268,7 +1317,10 @@ impl Game<'_> {
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
&self.worn_armor_layers(m),
|
||||
&host.hub.guest(*conn).map_or_else(
|
||||
|| self.worn_armor_layers(m),
|
||||
|g| self.stack_armor_layers(&g.armor),
|
||||
),
|
||||
out,
|
||||
);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! blocks, attacking and the commands all behave exactly as they do for the
|
||||
//! host, and every world change they cause reaches all players through the
|
||||
//! block log, the level events and the entity tracker.
|
||||
use super::{Game, GuestUse, Hit, Mining, Screen, lan::LanHost};
|
||||
use super::{Game, GuestUse, Hit, Mining, Screen, lan::LanHost, respawn_anchor::RespawnConfig};
|
||||
use horizon_client::{
|
||||
command::vanilla,
|
||||
containers::BlockEntity,
|
||||
@@ -34,6 +34,8 @@ pub(super) struct GuestPlay {
|
||||
destroy_delay: u32,
|
||||
using: Option<(usize, u32)>,
|
||||
use_delay: u32,
|
||||
/// The shield's ItemCooldowns entry: an axe disabled it for this many ticks.
|
||||
pub(super) shield_cooldown: u32,
|
||||
attack_ticks: u32,
|
||||
/// The main hand's item at the guest's last tick (`Game::attack_item`).
|
||||
attack_item: Option<std::sync::Arc<str>>,
|
||||
@@ -49,10 +51,18 @@ pub(super) struct GuestPlay {
|
||||
/// Swings the guest's actions started this tick, for the players around
|
||||
/// (`LivingEntity.swing` sends ClientboundSwingAnimationPacket).
|
||||
swings: u32,
|
||||
/// The guest's riding jump charge (`LocalPlayer.jumpRidingTicks`).
|
||||
mount: super::mounts::MountState,
|
||||
/// The trading screen the guest has open.
|
||||
pub(super) merchant: Option<super::lan_trading::GuestMerchant>,
|
||||
/// The inventory menu and the crafting table the guest crafts in.
|
||||
pub(super) crafting: super::lan_crafting::GuestCrafting,
|
||||
/// The workstation menu the guest has open (game/lan_stations.rs).
|
||||
pub(super) station: Option<super::lan_stations::GuestStation>,
|
||||
/// The mount inventory the guest has open (game/lan_mount_menu.rs).
|
||||
pub(super) mount_window: Option<super::lan_mount_menu::GuestMount>,
|
||||
/// The lectern the guest reads (game/lan_stations.rs).
|
||||
pub(super) lectern: Option<super::lan_stations::GuestLectern>,
|
||||
/// What the guest's body was given for its effects and enchantments.
|
||||
potions: super::potions::BodyPotions,
|
||||
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
|
||||
@@ -109,6 +119,7 @@ pub(super) struct GuestSwap {
|
||||
destroy_delay: u32,
|
||||
using: Option<(usize, u32)>,
|
||||
use_delay: u32,
|
||||
pub(super) shield_cooldown: u32,
|
||||
attack_ticks: u32,
|
||||
attack_item: Option<std::sync::Arc<str>>,
|
||||
swing: horizon_client::first_person::SwingState,
|
||||
@@ -209,6 +220,18 @@ impl Game<'_> {
|
||||
host.hub.guests[i].position[2],
|
||||
);
|
||||
host.hub.guests[i].motion = PlayerMotion::new(self.motion, p).ok().map(Box::new);
|
||||
// The first body after joining burns, breathes and falls on as saved.
|
||||
if let Some(saved) = host.hub.guests[i].loaded_body.take()
|
||||
&& let Some(m) = host.hub.guests[i].motion.as_mut()
|
||||
{
|
||||
saved.apply(m);
|
||||
}
|
||||
// The saved golden hearts go on the first body of a player who
|
||||
// has been here before.
|
||||
let saved = host.hub.guests[i].saved_absorption.take();
|
||||
if let (Some(m), Some(amount)) = (host.hub.guests[i].motion.as_mut(), saved) {
|
||||
let _ = m.internal_set_absorption(amount);
|
||||
}
|
||||
let kept = host.hub.guests[i].logs_kept.take();
|
||||
if let (Some(m), Some((hurts, attacks))) = (host.hub.guests[i].motion.as_mut(), kept) {
|
||||
m.hurt_log = hurts;
|
||||
@@ -282,13 +305,16 @@ impl Game<'_> {
|
||||
let destroy_delay = std::mem::replace(&mut self.destroy_delay, play.destroy_delay);
|
||||
let using = std::mem::replace(&mut self.using, play.using.take());
|
||||
let use_delay = std::mem::replace(&mut self.use_delay, play.use_delay);
|
||||
let shield_cooldown = std::mem::replace(&mut self.shield_cooldown, play.shield_cooldown);
|
||||
let attack_ticks = std::mem::replace(&mut self.attack_ticks, play.attack_ticks);
|
||||
let attack_item = std::mem::replace(&mut self.attack_item, play.attack_item.take());
|
||||
let swing = std::mem::replace(&mut self.swing, play.swing);
|
||||
std::mem::swap(&mut self.mount, &mut play.mount);
|
||||
let (left, right) = (self.left, self.right);
|
||||
self.left = play.digging.is_some();
|
||||
self.right = play.using.is_some() || self.using.is_some();
|
||||
let riding = self.riding.take();
|
||||
// The guest's vehicle (Entity.vehicle) for its turn.
|
||||
let riding = std::mem::replace(&mut self.riding, g.vehicle);
|
||||
let host_station = self.station.take();
|
||||
// The guest's bed, sleep counter and respawn point; the host's
|
||||
// respawn point, anchor and action bar stay theirs.
|
||||
@@ -299,6 +325,7 @@ impl Game<'_> {
|
||||
g.spawn.map(|s| Vec3::new(s[0], s[1], s[2])),
|
||||
);
|
||||
let respawn = (self.respawn_config.take(), self.level.spawn);
|
||||
self.respawn_config = g.respawn_elsewhere.as_ref().map(RespawnConfig::from_saved);
|
||||
let message = self.message.take();
|
||||
// The guest's own ender inventory; one the host has open goes back
|
||||
// into the host's while the guest acts.
|
||||
@@ -359,6 +386,7 @@ impl Game<'_> {
|
||||
destroy_delay,
|
||||
using,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
attack_ticks,
|
||||
attack_item,
|
||||
swing,
|
||||
@@ -411,6 +439,7 @@ impl Game<'_> {
|
||||
destroy_delay,
|
||||
using,
|
||||
use_delay,
|
||||
shield_cooldown,
|
||||
attack_ticks,
|
||||
attack_item,
|
||||
swing,
|
||||
@@ -460,29 +489,46 @@ impl Game<'_> {
|
||||
let bed = std::mem::replace(&mut self.sleeping, sleeping);
|
||||
let guest_sleep_timer = std::mem::replace(&mut self.sleep_timer, sleep_timer);
|
||||
let guest_spawn = std::mem::replace(&mut self.spawn, spawn).map(|s| [s.x, s.y, s.z]);
|
||||
(self.respawn_config, self.level.spawn) = respawn;
|
||||
let guest_respawn = std::mem::replace(&mut self.respawn_config, respawn.0);
|
||||
self.level.spawn = respawn.1;
|
||||
// ServerPlayer.displayClientMessage(_, true): the guest's action bar.
|
||||
let guest_message = std::mem::replace(&mut self.message, message);
|
||||
self.riding = riding;
|
||||
let guest_vehicle = std::mem::replace(&mut self.riding, riding);
|
||||
self.left = left;
|
||||
self.right = right;
|
||||
play.mining = std::mem::replace(&mut self.mining, mining);
|
||||
play.destroy_delay = std::mem::replace(&mut self.destroy_delay, destroy_delay);
|
||||
play.using = std::mem::replace(&mut self.using, using);
|
||||
play.use_delay = std::mem::replace(&mut self.use_delay, use_delay);
|
||||
play.shield_cooldown = std::mem::replace(&mut self.shield_cooldown, shield_cooldown);
|
||||
play.attack_ticks = std::mem::replace(&mut self.attack_ticks, attack_ticks);
|
||||
play.attack_item = std::mem::replace(&mut self.attack_item, attack_item);
|
||||
play.swing = std::mem::replace(&mut self.swing, swing);
|
||||
std::mem::swap(&mut self.mount, &mut play.mount);
|
||||
self.container = container;
|
||||
self.screen = screen;
|
||||
self.sign_edit = host_sign;
|
||||
self.book = host_book;
|
||||
// A lectern the guest's use opened (LecternBlock.openScreen).
|
||||
let guest_lectern = match std::mem::replace(&mut self.book, host_book) {
|
||||
Some(super::books::BookScreen::View {
|
||||
lectern: Some(key), ..
|
||||
}) => Some(key),
|
||||
_ => None,
|
||||
};
|
||||
self.command_block_screen = host_command_block;
|
||||
self.mount_menu = host_mount_menu;
|
||||
// A mount inventory the guest's action opened (shift + use, or the
|
||||
// inventory key while riding).
|
||||
let guest_mount = std::mem::replace(&mut self.mount_menu, host_mount_menu).opened_mob();
|
||||
self.keys = host_keys;
|
||||
let g = &mut host.hub.guests[i];
|
||||
let left_vehicle = g.vehicle.filter(|_| guest_vehicle.is_none());
|
||||
if guest_vehicle.is_none() {
|
||||
g.vehicle_good = None;
|
||||
}
|
||||
g.vehicle = guest_vehicle;
|
||||
let fell_asleep = bed.is_some() && g.bed.is_none();
|
||||
(g.bed, g.sleep_timer, g.spawn) = (bed, guest_sleep_timer, guest_spawn);
|
||||
g.respawn_elsewhere = guest_respawn.map(|c| c.saved());
|
||||
g.sleeping = g.bed.is_some();
|
||||
if let Some((text, _)) = guest_message {
|
||||
g.send(&C::SystemChat(play::SystemChat {
|
||||
@@ -532,12 +578,31 @@ impl Game<'_> {
|
||||
g.health = m.health.health();
|
||||
g.food = m.health.food.food;
|
||||
g.saturation = m.health.food.saturation;
|
||||
// A teleport (commands) moves the guest.
|
||||
// A teleport (commands, a pearl) moves the guest; a pearl's is
|
||||
// sent even to where they stand (ServerPlayer.teleport).
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
m.sprinting = false;
|
||||
let teleported = std::mem::take(&mut m.teleported);
|
||||
let moved: f64 = (0..3).map(|k| (at[k] - g.position[k]).powi(2)).sum();
|
||||
g.motion = Some(Box::new(m));
|
||||
if moved > 0.0001 || turned {
|
||||
// Entity.teleportTo takes a rider out of its vehicle.
|
||||
let left_vehicle = match g.vehicle.filter(|_| teleported) {
|
||||
Some(v) => {
|
||||
(g.vehicle, g.vehicle_good) = (None, None);
|
||||
Some(v)
|
||||
}
|
||||
None => left_vehicle,
|
||||
};
|
||||
// ServerPlayer.dismountTo: the client hears it is out of the
|
||||
// vehicle before the teleport beside it. A rider sits where its
|
||||
// vehicle puts it (lan_vehicles.rs), with no teleport.
|
||||
if let Some(vehicle) = left_vehicle {
|
||||
g.send(&C::SetPassengers(play::SetPassengers {
|
||||
vehicle: vehicle as i32,
|
||||
passengers: Vec::new(),
|
||||
}));
|
||||
}
|
||||
if g.vehicle.is_none() && (moved > 0.0001 || turned || teleported) {
|
||||
host.hub.teleport(i, Some(at));
|
||||
}
|
||||
}
|
||||
@@ -571,9 +636,15 @@ impl Game<'_> {
|
||||
if let Some(station) = opened_station {
|
||||
self.lan_station_opened(host, conn, station);
|
||||
}
|
||||
if let Some(key) = guest_lectern {
|
||||
self.lan_open_lectern(host, conn, key);
|
||||
}
|
||||
if opened_table {
|
||||
self.lan_open_crafting(host, conn);
|
||||
}
|
||||
if let Some(mob) = guest_mount {
|
||||
self.lan_open_mount(host, conn, mob);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerLevel.players()` in turn: the host's own player, then each
|
||||
@@ -637,6 +708,13 @@ impl Game<'_> {
|
||||
_ => ("minecraft:furnace", "container.furnace"),
|
||||
};
|
||||
(menu, title, Slots::Block(pos))
|
||||
} else if let Some(BlockEntity::Crafter(_)) = self.world.block_entities.get(&pos) {
|
||||
// CrafterBlock.useWithoutItem: CrafterMenu over the block entity.
|
||||
(
|
||||
"minecraft:crafter_3x3",
|
||||
"container.crafter",
|
||||
Slots::Block(pos),
|
||||
)
|
||||
} else if let Some(BlockEntity::Brewing(_)) = self.world.block_entities.get(&pos) {
|
||||
// BrewingStandBlock.useWithoutItem: BrewingStandMenu.
|
||||
(
|
||||
@@ -679,6 +757,7 @@ impl Game<'_> {
|
||||
};
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_container_closed(host, conn);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
host.hub.open_container(
|
||||
conn,
|
||||
menu,
|
||||
@@ -877,16 +956,22 @@ impl Game<'_> {
|
||||
}
|
||||
S::Attack(p) => self.guest_attack(host, conn, p.id),
|
||||
S::Interact(p) => {
|
||||
if let Some((position, eye)) = self.look_at_entity(host, gi, p.id) {
|
||||
let _ = (position, eye);
|
||||
let before = host.hub.guests[gi].vehicle;
|
||||
if self
|
||||
.look_at_entity(host, gi, p.id, Some(p.location))
|
||||
.is_some()
|
||||
{
|
||||
let swung = self.as_guest(host, conn, |g| {
|
||||
let swings = g.swings_started;
|
||||
let _ = g.use_item();
|
||||
g.swings_started != swings
|
||||
});
|
||||
self.lan_swing_back(host, conn, swung);
|
||||
self.guest_boarded(host, conn, before);
|
||||
}
|
||||
}
|
||||
S::MoveVehicle(p) => self.guest_move_vehicle(host, conn, &p),
|
||||
S::PaddleBoat(p) => self.guest_paddle_boat(host, conn, [p.left, p.right]),
|
||||
S::SetCreativeModeSlot(p) => {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if !g.creative() || !(0..46).contains(&p.slot) {
|
||||
@@ -913,6 +998,24 @@ impl Game<'_> {
|
||||
S::ContainerClick(p) if self.lan_inventory_click(host, conn, &p) => {}
|
||||
S::ContainerClick(p) if self.lan_crafting_click(host, conn, &p) => {}
|
||||
S::ContainerClick(p) if self.lan_machine_click(host, conn, &p) => {}
|
||||
S::ContainerClick(p) if self.lan_station_click(host, conn, &p) => {}
|
||||
S::ContainerClick(p) if self.lan_lectern_click(host, conn, &p) => {}
|
||||
S::ContainerClick(p) if self.lan_mount_click(host, conn, &p) => {}
|
||||
// ServerboundPlayerCommandPacket OPEN_INVENTORY: the ridden
|
||||
// mount's inventory (`HasCustomInventoryScreen`).
|
||||
S::PlayerCommand(p) if p.action == 5 => {
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.open_ridden_mount_menu();
|
||||
});
|
||||
}
|
||||
S::ContainerButtonClick(p) => {
|
||||
self.lan_station_button(host, conn, p.container, p.button);
|
||||
}
|
||||
S::RenameItem(p) => self.lan_station_rename(host, conn, &p.name),
|
||||
S::ContainerSlotStateChanged(p) => {
|
||||
self.lan_crafter_slot_state(host, conn, p.container, p.slot, p.enabled);
|
||||
}
|
||||
S::SetBeacon(p) => self.lan_station_beacon(host, conn, p.primary, p.secondary),
|
||||
S::SelectTrade(p) => self.lan_select_trade(host, conn, p.item),
|
||||
S::ContainerClose(p) if self.lan_merchant_closed(host, conn, Some(p.container)) => {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
@@ -920,7 +1023,11 @@ impl Game<'_> {
|
||||
self.drop_guest_items(host, gi, vec![c]);
|
||||
}
|
||||
}
|
||||
S::ContainerClose(p) if self.lan_crafting_closed(host, conn, Some(p.container)) => {
|
||||
S::ContainerClose(p)
|
||||
if self.lan_crafting_closed(host, conn, Some(p.container))
|
||||
|| self.lan_station_closed(host, conn, Some(p.container))
|
||||
|| self.lan_mount_closed(host, conn, Some(p.container)) =>
|
||||
{
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if let Some(c) = g.carried.take() {
|
||||
self.drop_guest_items(host, gi, vec![c]);
|
||||
@@ -1111,19 +1218,39 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Aim the swapped-in player at an entity; returns its centre and the eye.
|
||||
/// Aim the swapped-in player at an entity; returns where it aims and the
|
||||
/// eye. A boat, minecart, armor stand, item frame or painting is aimed at
|
||||
/// the point the client hit (the interact packet's location) when that
|
||||
/// is on it, else at its centre.
|
||||
fn look_at_entity(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
gi: usize,
|
||||
id: i32,
|
||||
location: Option<[f64; 3]>,
|
||||
) -> Option<([f64; 3], [f64; 3])> {
|
||||
let object = || {
|
||||
let (lo, hi, origin) = self.object_target(id)?;
|
||||
let middle = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0);
|
||||
let Some(l) = location else {
|
||||
return Some(middle);
|
||||
};
|
||||
let point = [0, 1, 2].map(|k| origin[k] + l[k]);
|
||||
// A little inside the box, so the ray from the eye meets it.
|
||||
let inside = [0, 1, 2].map(|k| {
|
||||
let margin = ((hi[k] - lo[k]) / 2.0).min(0.01);
|
||||
point[k].clamp(lo[k] + margin, hi[k] - margin)
|
||||
});
|
||||
let on = (0..3).all(|k| (point[k] - inside[k]).abs() < 0.1);
|
||||
Some(if on { inside } else { middle })
|
||||
};
|
||||
let centre = match self.mobs.iter().find(|m| m.id as i32 == id) {
|
||||
Some(m) => [
|
||||
m.position.x,
|
||||
m.position.y + m.kind.height * 0.5,
|
||||
m.position.z,
|
||||
],
|
||||
None if self.object_target(id).is_some() => object()?,
|
||||
// A dragon part or an end crystal (game/dragon.rs).
|
||||
None => {
|
||||
let b = self.tracked_special_box(id)?;
|
||||
@@ -1174,7 +1301,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let (old_yaw, old_pitch) = (host.hub.guests[gi].yaw, host.hub.guests[gi].pitch);
|
||||
if self.look_at_entity(host, gi, id).is_none() {
|
||||
if self.look_at_entity(host, gi, id, None).is_none() {
|
||||
return;
|
||||
}
|
||||
self.as_guest(host, conn, |g| {
|
||||
@@ -1381,17 +1508,43 @@ impl Game<'_> {
|
||||
if !host.hub.guests[i].dead {
|
||||
return;
|
||||
}
|
||||
// PlayerList.respawn: a server's player comes back in the Overworld,
|
||||
// where the world spawn is (respawn anchors and beds aside).
|
||||
// PlayerList.respawn: a server's player comes back in its respawn
|
||||
// point's level: the Overworld, where the world spawn and beds are,
|
||||
// or another one's anchor or forced point, which its block resolves
|
||||
// once the level has loaded around it.
|
||||
let server = self.options.dedicated.is_some();
|
||||
let own = host.hub.guests[i]
|
||||
.respawn_elsewhere
|
||||
.as_ref()
|
||||
.map(RespawnConfig::from_saved);
|
||||
let elsewhere = own.filter(|_| server);
|
||||
// In the window's world a guest has no level of its own to wait in:
|
||||
// its bed is resolved at once when its chunk is there, else it
|
||||
// comes back on the bed.
|
||||
let in_frame_bed = own.filter(|c| !server && c.dim == super::Dim::Overworld);
|
||||
let spawn = in_frame_bed.map_or(spawn, |c| {
|
||||
[
|
||||
f64::from(c.pos[0]) + 0.5,
|
||||
f64::from(c.pos[1]) + 0.6,
|
||||
f64::from(c.pos[2]) + 0.5,
|
||||
]
|
||||
});
|
||||
let level = elsewhere.map_or(super::Dim::Overworld, |c| c.dim);
|
||||
let spawn = elsewhere.map_or(spawn, |c| {
|
||||
[
|
||||
f64::from(c.pos[0]) + 0.5,
|
||||
f64::from(c.pos[1]),
|
||||
f64::from(c.pos[2]) + 0.5,
|
||||
]
|
||||
});
|
||||
let back = self.dim;
|
||||
let away = self.options.dedicated.is_some()
|
||||
&& host.hub.guests[i].dimension != super::Dim::Overworld.id();
|
||||
if away {
|
||||
let away = server && host.hub.guests[i].dimension != level.id();
|
||||
if server {
|
||||
self.server_trips.retain(|t| !t.is(conn));
|
||||
if let Err(e) = self.enter_level(Some(host), super::Dim::Overworld) {
|
||||
log::error!("could not open the Overworld: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if away && let Err(e) = self.enter_level(Some(host), level) {
|
||||
log::error!("could not open {}: {e}", level.id());
|
||||
return;
|
||||
}
|
||||
let g = &mut host.hub.guests[i];
|
||||
g.dead = false;
|
||||
@@ -1428,8 +1581,31 @@ impl Game<'_> {
|
||||
tick_frozen: self.clock.frozen(),
|
||||
tick_steps: self.clock.step_state(),
|
||||
collision: Some(self.collision),
|
||||
border: &self.border,
|
||||
};
|
||||
host.hub.respawn(i, spawn, &ctx);
|
||||
if let Some(config) = elsewhere {
|
||||
self.respawn_trip(conn, config);
|
||||
}
|
||||
if let Some(c) = in_frame_bed
|
||||
&& self.dim == super::Dim::Overworld
|
||||
&& self.world.loaded(c.pos[0], c.pos[2])
|
||||
{
|
||||
let found = self
|
||||
.as_guest(host, conn, |g| g.resolve_server_respawn(c))
|
||||
.flatten();
|
||||
let world = self
|
||||
.level
|
||||
.world_spawn
|
||||
.map_or([0.5, 80.0, 0.5], |[x, y, z]| {
|
||||
[f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5]
|
||||
});
|
||||
let at = found.map_or(world, |(at, yaw)| {
|
||||
host.hub.guests[i].yaw = yaw;
|
||||
[at.x, at.y, at.z]
|
||||
});
|
||||
host.hub.teleport(i, Some(at));
|
||||
}
|
||||
if away && let Err(e) = self.enter_level(Some(host), back) {
|
||||
log::error!("could not reopen {}: {e}", back.id());
|
||||
}
|
||||
@@ -1438,6 +1614,8 @@ impl Game<'_> {
|
||||
/// Per tick, for every guest: dig, use items, pick up drops, die.
|
||||
pub(super) fn tick_guests(&mut self, host: &mut LanHost) {
|
||||
self.lan_merchant_tick(host);
|
||||
self.lan_stations_tick(host);
|
||||
self.tick_lan_mount_menus(host);
|
||||
self.announce_guest_advancements(host);
|
||||
let conns: Vec<u32> = host
|
||||
.hub
|
||||
@@ -1455,6 +1633,9 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
// Player.tick's ItemCooldowns.tick, once a tick for each player
|
||||
// (the shield an axe disabled).
|
||||
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
|
||||
let digging = play.digging;
|
||||
let using = play.using.is_some();
|
||||
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
|
||||
@@ -1509,6 +1690,9 @@ impl Game<'_> {
|
||||
self.guest_dig_feedback(host, conn);
|
||||
self.guest_pickups(host, gi);
|
||||
self.guest_orbs(host, gi);
|
||||
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
|
||||
self.guest_forget_digging(host, conn);
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if g.health <= 0.0 && !g.dead {
|
||||
g.dead = true;
|
||||
@@ -1639,6 +1823,7 @@ impl Game<'_> {
|
||||
/// regeneration and the food tick (ServerPlayer.doTick). Their hurt
|
||||
/// sounds are heard at the guest.
|
||||
fn guest_server_tick(&mut self, host: &mut LanHost, conn: u32) {
|
||||
self.guest_vehicle_tick(host, conn);
|
||||
let Some(g) = host.hub.guest_mut(conn) else {
|
||||
return;
|
||||
};
|
||||
@@ -1657,6 +1842,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 sounds = self.as_guest(host, conn, |game| {
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
@@ -1675,6 +1861,26 @@ impl Game<'_> {
|
||||
let _ = w.fluid_base_tick();
|
||||
w.player.ground.on_ground = from_ground;
|
||||
let travelled: Vec<[f64; 3]> = moves.iter().map(|m| m.0).collect();
|
||||
// Entity.move's movementThisTick: each move, from where the
|
||||
// tick's first one started.
|
||||
let end = w.player.position;
|
||||
let start = travelled
|
||||
.iter()
|
||||
.fold(end, |p, d| p.subtract(Vec3::new(d[0], d[1], d[2])));
|
||||
w.player.history.clear();
|
||||
let mut from = start;
|
||||
for d in &travelled {
|
||||
let delta = Vec3::new(d[0], d[1], d[2]);
|
||||
let to = from.add_vector(delta);
|
||||
w.player
|
||||
.history
|
||||
.add(horizon_world::entity_move::MovementRecord {
|
||||
from,
|
||||
to,
|
||||
original: Some(delta),
|
||||
});
|
||||
from = to;
|
||||
}
|
||||
for (moved, on_ground) in moves {
|
||||
let movement = Vec3::new(moved[0], moved[1], moved[2]);
|
||||
if w.player.ground.on_ground && !on_ground && moved[1] > 0.0 {
|
||||
@@ -1693,6 +1899,17 @@ impl Game<'_> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A ServerPlayer travels on the server too
|
||||
// (MoveSimulationType.AUTHORITATIVE_SIDE_AND_SERVER), so every tick
|
||||
// on a ladder or vine LivingEntity.handleOnClimbable resets its fall
|
||||
// distance: climbing down doesn't hurt where it lands (#1255).
|
||||
if w.climbable().unwrap_or(false) {
|
||||
w.player.fall_distance = 0.0;
|
||||
}
|
||||
// Entity.applyEffectsFromBlocks along those moves: magma, cactus,
|
||||
// lit campfires and grown sweet berry bushes hurt (#1305).
|
||||
w.player.shift_key_down = sneaking;
|
||||
let _ = w.server_block_damage(start);
|
||||
let _ = w.tick_environment_damage();
|
||||
let _ = w.apply_fire_contact();
|
||||
let _ = w.server_hazards_tick(in_wall);
|
||||
@@ -1702,7 +1919,8 @@ impl Game<'_> {
|
||||
for d in travelled {
|
||||
game.movement_stats(Vec3::new(d[0], d[1], d[2]));
|
||||
}
|
||||
// LivingEntity.baseTick ends with tickEffects.
|
||||
// LivingEntity.baseTick: outside the world border, then tickEffects.
|
||||
game.border_damage();
|
||||
game.tick_mob_effects();
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return Vec::new();
|
||||
@@ -1853,6 +2071,23 @@ impl Game<'_> {
|
||||
|
||||
/// `level.destroyBlockProgress(id, pos, -1)` for a guest: its crack on `pos` is gone, for the
|
||||
/// host and the other guests.
|
||||
/// A player who dies digs and uses nothing any more: the block it was
|
||||
/// breaking, or had left to break (`hasDelayedDestroy`), stays, and its
|
||||
/// crack goes. Respawning gives it a new ServerPlayerGameMode (#1315).
|
||||
fn guest_forget_digging(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(play) = host.play.get_mut(&conn) else {
|
||||
return;
|
||||
};
|
||||
let cracked = play.digging.take().filter(|_| play.last_progress >= 0);
|
||||
play.mining = None;
|
||||
play.delayed = None;
|
||||
play.destroy_delay = 0;
|
||||
play.using = None;
|
||||
play.last_progress = -1;
|
||||
if let Some((pos, _)) = cracked {
|
||||
self.guest_crack_gone(host, conn, pos);
|
||||
}
|
||||
}
|
||||
fn guest_crack_gone(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
|
||||
let Some(id) = host.hub.guest(conn).map(|g| g.entity_id) else {
|
||||
return;
|
||||
|
||||
@@ -83,17 +83,16 @@ impl Game<'_> {
|
||||
/// `LivingEntity.removeFrost`), what finalizeSpawn set, the baby speed
|
||||
/// modifier, its effects' modifiers, its equipment's, and what
|
||||
/// `/attribute` changed.
|
||||
pub(super) fn mob_attributes(&self, m: &Mob) -> Vec<AttributeSnapshot> {
|
||||
pub(super) fn mob_attributes(&self, m: &Mob, out: &mut Vec<AttributeSnapshot>) {
|
||||
let kind = m.kind.id.as_str();
|
||||
let mut out = Vec::new();
|
||||
if let Some(a) = self.synced_attribute(kind, "minecraft:movement_speed") {
|
||||
instance(&mut out, a, finalize::base_value(m, Attr::MovementSpeed));
|
||||
instance(out, a, finalize::base_value(m, Attr::MovementSpeed));
|
||||
}
|
||||
if let Some(s) = m.extra.attributes.as_deref() {
|
||||
for (attr, v) in &s.bases {
|
||||
if let Some(a) = self.synced_attribute(kind, &format!("minecraft:{}", attr.name()))
|
||||
{
|
||||
instance(&mut out, a, *v).base = *v;
|
||||
instance(out, a, *v).base = *v;
|
||||
}
|
||||
}
|
||||
for x in &s.modifiers {
|
||||
@@ -101,7 +100,7 @@ impl Game<'_> {
|
||||
self.synced_attribute(kind, &format!("minecraft:{}", x.attr.name()))
|
||||
{
|
||||
add_modifier(
|
||||
&mut out,
|
||||
out,
|
||||
a,
|
||||
finalize::base_value(m, x.attr),
|
||||
AttributeModifier {
|
||||
@@ -122,7 +121,7 @@ impl Game<'_> {
|
||||
finalize::transient(m, Attr::MovementSpeed),
|
||||
) {
|
||||
add_modifier(
|
||||
&mut out,
|
||||
out,
|
||||
a,
|
||||
finalize::base_value(m, Attr::MovementSpeed),
|
||||
AttributeModifier {
|
||||
@@ -150,7 +149,7 @@ impl Game<'_> {
|
||||
};
|
||||
let start = base(attribute).unwrap_or_else(|| self.attribute_default(kind, attribute));
|
||||
add_modifier(
|
||||
&mut out,
|
||||
out,
|
||||
a,
|
||||
start,
|
||||
AttributeModifier {
|
||||
@@ -172,10 +171,9 @@ impl Game<'_> {
|
||||
("chest", e.chest.as_ref()),
|
||||
("head", e.head.as_ref()),
|
||||
],
|
||||
&mut out,
|
||||
out,
|
||||
);
|
||||
self.generic_attributes_of(Target::Mob(m.id), &mut out);
|
||||
out
|
||||
self.generic_attributes_of(Target::Mob(m.id), out);
|
||||
}
|
||||
|
||||
/// An armor stand's synced instances: movement speed (its first
|
||||
|
||||
@@ -55,6 +55,7 @@ fn load_player(menu: &mut Menu, g: &Guest) {
|
||||
menu.set_player_item(OFFHAND, g.offhand.clone());
|
||||
menu.selected = g.selected;
|
||||
menu.creative = g.creative();
|
||||
menu.levels = g.xp_level;
|
||||
menu.carried = g.carried.clone();
|
||||
}
|
||||
|
||||
@@ -83,27 +84,47 @@ fn place_back(menu: &mut Menu, env: &Env, stacks: Vec<ItemStack>) {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn menu_env(&self) -> Env<'_> {
|
||||
pub(super) fn menu_env(&self) -> Env<'_> {
|
||||
Env {
|
||||
items: self.res.items(),
|
||||
recipes: self.res.recipes(),
|
||||
brewing: &self.res.stations().brewing,
|
||||
banners: &self.res.stations().banners,
|
||||
station_recipes: &self.res.stations().recipes,
|
||||
enchantments: &self.res.stations().enchantments,
|
||||
names: &self.pack.names,
|
||||
trade: None,
|
||||
maps: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `op` on the guest's menu with their inventory copied in and back.
|
||||
fn guest_menu_op<R>(
|
||||
pub(super) fn guest_menu_op<R>(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
gi: usize,
|
||||
menu: &mut Menu,
|
||||
op: impl FnOnce(&mut Menu, &Env) -> R,
|
||||
) -> R {
|
||||
let env = self.menu_env();
|
||||
// The cartography table reads and makes the host level's maps.
|
||||
let maps = (menu.kind() == Kind::Cartography).then(|| {
|
||||
let g = &host.hub.guests[gi];
|
||||
let held: Vec<ItemStack> = (0..menu.slot_count())
|
||||
.filter_map(|s| menu.slot(s).cloned())
|
||||
.chain(g.inventory.iter().flatten().cloned())
|
||||
.chain(g.carried.clone())
|
||||
.collect();
|
||||
self.level_maps(&held)
|
||||
});
|
||||
let env = Env {
|
||||
maps: maps.as_ref().map(|m| m as &dyn horizon_client::menu::Maps),
|
||||
..self.menu_env()
|
||||
};
|
||||
load_player(menu, &host.hub.guests[gi]);
|
||||
let result = op(menu, &env);
|
||||
if let Some(maps) = maps {
|
||||
self.register_made_maps(maps);
|
||||
}
|
||||
let dropped = std::mem::take(&mut menu.dropped);
|
||||
let equipped = std::mem::take(&mut menu.equipped);
|
||||
menu.drops.clear();
|
||||
@@ -201,8 +222,14 @@ impl Game<'_> {
|
||||
(Kind::Furnace(recipe), f.items.to_vec())
|
||||
}
|
||||
Some(BlockEntity::Brewing(b)) => (Kind::Brewing, b.items.to_vec()),
|
||||
Some(BlockEntity::Crafter(c)) => (Kind::CrafterBlock, c.items.to_vec()),
|
||||
_ => return false,
|
||||
};
|
||||
// CrafterBlockEntity.containerData: the disabled slots, then triggered.
|
||||
let crafter_data = match self.world.block_entities.get(&pos) {
|
||||
Some(BlockEntity::Crafter(c)) => Some((c.disabled, c.triggered)),
|
||||
_ => None,
|
||||
};
|
||||
if host.hub.guests[gi].spectator() {
|
||||
return true;
|
||||
}
|
||||
@@ -219,6 +246,13 @@ impl Game<'_> {
|
||||
for (k, s) in items.iter().enumerate() {
|
||||
menu.set_slot(env, k, s.clone());
|
||||
}
|
||||
// After the slots: setting a disabled slot (CrafterBlockEntity.setItem) enables it.
|
||||
if let Some((disabled, triggered)) = crafter_data {
|
||||
for (k, d) in disabled.iter().enumerate() {
|
||||
menu.data[k] = i32::from(*d);
|
||||
}
|
||||
menu.data[9] = i32::from(triggered);
|
||||
}
|
||||
let before: Vec<_> = (0..menu.slot_count())
|
||||
.map(|s| menu.slot(s).cloned())
|
||||
.collect();
|
||||
@@ -241,6 +275,13 @@ impl Game<'_> {
|
||||
b.items[k] = menu.slot(k).cloned();
|
||||
}
|
||||
}
|
||||
// CrafterBlockEntity.setItem: filling a disabled slot enables it.
|
||||
Some(BlockEntity::Crafter(c)) => {
|
||||
for k in 0..9 {
|
||||
c.items[k] = menu.slot(k).cloned();
|
||||
c.disabled[k] = menu.data[k] == 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
host.play.entry(conn).or_default().crafting.machine = Some((p.container, menu));
|
||||
@@ -248,6 +289,39 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.handleContainerSlotStateChanged`: a
|
||||
/// guest's crafter slot toggle (`CrafterBlockEntity.setSlotState`) in
|
||||
/// their open crafter window.
|
||||
pub(super) fn lan_crafter_slot_state(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
window: i32,
|
||||
slot: i32,
|
||||
enabled: bool,
|
||||
) {
|
||||
let Some(pos) = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.filter(|g| !g.spectator())
|
||||
.and_then(|g| g.menu.as_ref())
|
||||
.filter(|m| window != 0 && m.window == window)
|
||||
.and_then(|m| match m.slots {
|
||||
Slots::Block(pos) => Some(pos),
|
||||
_ => None,
|
||||
})
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let (Some(BlockEntity::Crafter(c)), Ok(slot)) = (
|
||||
self.world.block_entities.get_mut(&pos),
|
||||
usize::try_from(slot),
|
||||
) {
|
||||
c.set_slot_state(slot, enabled);
|
||||
self.world.modified.insert((pos.0 >> 4, pos.2 >> 4));
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerGamePacketListenerImpl.handlePlaceRecipe: the guest's recipe
|
||||
/// book places a known recipe in their inventory grid or crafting table
|
||||
/// (`ServerPlaceRecipe`), and answers with a ghost recipe when the items
|
||||
@@ -331,6 +405,7 @@ impl Game<'_> {
|
||||
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
|
||||
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32) {
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
// The container it replaces loses its opener.
|
||||
self.guest_container_closed(host, conn);
|
||||
let title = horizon_protocol::text::translatable("container.crafting", &[]);
|
||||
@@ -430,22 +505,35 @@ impl Game<'_> {
|
||||
None => return false,
|
||||
}
|
||||
};
|
||||
if grid.is_empty() {
|
||||
return true;
|
||||
if !grid.is_empty() {
|
||||
self.lan_place_back(host, gi, grid);
|
||||
}
|
||||
let mut menu = Menu::new(Kind::Inventory);
|
||||
self.guest_menu_op(host, gi, &mut menu, |menu, env| place_back(menu, env, grid));
|
||||
true
|
||||
}
|
||||
|
||||
/// A guest left: what their crafting grids held goes back into their
|
||||
/// `Inventory.placeItemBackInInventory` of `stacks` for guest `gi`, what
|
||||
/// doesn't fit dropped (`clearContainer`).
|
||||
pub(super) fn lan_place_back(&mut self, host: &mut LanHost, gi: usize, stacks: Vec<ItemStack>) {
|
||||
let mut menu = Menu::new(Kind::Inventory);
|
||||
self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
place_back(menu, env, stacks)
|
||||
});
|
||||
}
|
||||
|
||||
/// A guest left: what their crafting grids and workstation held goes back into their
|
||||
/// inventory before it is saved (dropped where they stood if full).
|
||||
pub(super) fn lan_crafting_left(&mut self, host: &mut LanHost, g: &mut Guest) {
|
||||
let table = host.play.remove(&g.conn).and_then(|p| p.crafting.table);
|
||||
let play = host.play.remove(&g.conn);
|
||||
let mut grid: Vec<ItemStack> = g.craft_grid.iter_mut().filter_map(Option::take).collect();
|
||||
g.craft_result = None;
|
||||
if let Some((_, _, menu)) = table {
|
||||
grid.extend((1..10).filter_map(|s| menu.slot(s).cloned()));
|
||||
if let Some(play) = play {
|
||||
if let Some((_, _, menu)) = play.crafting.table {
|
||||
grid.extend((1..10).filter_map(|s| menu.slot(s).cloned()));
|
||||
}
|
||||
// A workstation's inputs too.
|
||||
if let Some(station) = &play.station {
|
||||
grid.extend(super::lan_stations::station_inputs(station));
|
||||
}
|
||||
}
|
||||
if grid.is_empty() {
|
||||
return;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! synched data that differs from the defaults), and who rides what.
|
||||
use super::Game;
|
||||
use horizon_client::lan::{
|
||||
entity::{EntityState, Riding},
|
||||
entity::{EntityState, Riding, States},
|
||||
hub::Hub,
|
||||
};
|
||||
use horizon_protocol::entity_data::{Value, serializer};
|
||||
@@ -36,9 +36,12 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
|
||||
velocity: [0.0; 3],
|
||||
data: 0,
|
||||
meta: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
equipment: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
precise: false,
|
||||
needs_sync: false,
|
||||
leash: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,8 +92,17 @@ fn stand_poses(raw: Option<&Tag>) -> Vec<(u8, i32, Value)> {
|
||||
impl Game<'_> {
|
||||
/// Who rides what (`Entity.getPassengers`): mobs on mobs, and the
|
||||
/// host in its boat or minecart.
|
||||
pub(super) fn lan_riding(&self, host_entity: Option<i32>, riding: &mut Riding) {
|
||||
pub(super) fn lan_riding(
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
guests: &[(u32, i32)],
|
||||
riding: &mut Riding,
|
||||
) {
|
||||
riding.clear();
|
||||
// The server's players in their boats and minecarts.
|
||||
for &(vehicle, guest) in guests {
|
||||
riding.add(vehicle as i32, guest);
|
||||
}
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
if let Some(vehicle) = m.extra.mount {
|
||||
riding.add(vehicle as i32, m.id as i32);
|
||||
@@ -107,7 +119,7 @@ impl Game<'_> {
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
hub: &mut Hub,
|
||||
out: &mut Vec<EntityState>,
|
||||
out: &mut States,
|
||||
) {
|
||||
for b in &self.boats {
|
||||
let p = b.position;
|
||||
@@ -264,22 +276,24 @@ impl Game<'_> {
|
||||
self.tridents.iter_mut().for_each(|t| give(&mut t.id));
|
||||
self.rockets.iter_mut().for_each(|r| give(&mut r.id));
|
||||
self.crystals.iter_mut().for_each(|c| give(&mut c.id));
|
||||
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);
|
||||
}
|
||||
|
||||
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
|
||||
/// end crystals, as guests track them.
|
||||
fn lan_projectile_states(
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
hub: &mut Hub,
|
||||
out: &mut Vec<EntityState>,
|
||||
) {
|
||||
fn lan_projectile_states(&self, host_entity: Option<i32>, hub: &mut Hub, out: &mut States) {
|
||||
let owner = |who: Option<horizon_client::mobs::Who>| match who {
|
||||
Some(horizon_client::mobs::Who::Mob(id)) => id as i32,
|
||||
Some(horizon_client::mobs::Who::Player) => host_entity.unwrap_or(0),
|
||||
None => 0,
|
||||
};
|
||||
let at = |p: horizon_core::geometry::Vec3| [p.x, p.y, p.z];
|
||||
// LightningBolt: each client draws and flashes its own.
|
||||
for b in self.bolts.iter().filter(|b| b.id != 0) {
|
||||
out.push(state(b.id, "minecraft:lightning_bolt", b.at, 0.0, false));
|
||||
}
|
||||
for t in self.tnts.iter().filter(|t| t.id != 0) {
|
||||
let mut e = state(t.id, "minecraft:tnt", at(t.position), 0.0, t.on_ground);
|
||||
e.velocity = at(t.velocity);
|
||||
@@ -347,6 +361,26 @@ impl Game<'_> {
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
// Thrown potions and bottles: ThrowableItemProjectile.DATA_ITEM_STACK
|
||||
// (the potion's colour), which a plain bottle's type already has.
|
||||
for (id, kind, stack, position, velocity) in self.potions.thrown_tracked() {
|
||||
let mut e = state(id, kind, at(position), 0.0, false);
|
||||
e.velocity = at(velocity);
|
||||
if stack.extra.is_some() || stack.count != 1 {
|
||||
e.meta.push((
|
||||
8,
|
||||
serializer::ITEM_STACK,
|
||||
Value::Item(hub.stack(&Some(stack.clone()))),
|
||||
));
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
// EyeOfEnder: its item is the plain eye (DATA_ITEM_STACK's default).
|
||||
for (id, position, velocity) in self.end.eyes_tracked() {
|
||||
let mut e = state(id, "minecraft:eye_of_ender", position, 0.0, false);
|
||||
e.velocity = velocity;
|
||||
out.push(e);
|
||||
}
|
||||
for t in self.tridents.iter().filter(|t| t.id != 0) {
|
||||
let mut e = state(t.id, "minecraft:trident", at(t.position), t.yaw, false);
|
||||
e.pitch = t.pitch;
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//! The horse inventory for a server's players (`ServerPlayer.openHorseInventory`):
|
||||
//! shift + use on a tamed horse, donkey, mule, camel or llama, or the
|
||||
//! inventory key while riding one (`ServerboundPlayerCommandPacket`
|
||||
//! OPEN_INVENTORY), opens `HorseInventoryMenu` over the mount with
|
||||
//! `ClientboundMountScreenOpenPacket`. Clicks run on the menu engine
|
||||
//! (`Kind::Mount`) with the mount's saddle, body item and chest copied in and
|
||||
//! written back, and the slots go to the client after each change, as
|
||||
//! `broadcastChanges` does.
|
||||
use super::{Game, lan::LanHost, lan_crafting::input};
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
menu::{Kind, Menu},
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
|
||||
/// A guest's open mount menu.
|
||||
pub(super) struct GuestMount {
|
||||
window: i32,
|
||||
mob: u32,
|
||||
columns: usize,
|
||||
state: i32,
|
||||
/// The menu's slots and the carried stack the client was last sent.
|
||||
sent: Option<(Vec<Option<ItemStack>>, Option<ItemStack>)>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ServerPlayer.openHorseInventory` for mount `mob`: the open menu
|
||||
/// closes, the client gets the mount screen and the menu's slots.
|
||||
pub(super) fn lan_open_mount(&mut self, host: &mut LanHost, conn: u32, mob: u32) {
|
||||
let Some(index) = self.mobs.iter().position(|m| m.id == mob) else {
|
||||
return;
|
||||
};
|
||||
let columns = super::mount_menu::mount_columns(&self.mobs[index]);
|
||||
self.lan_mount_closed(host, conn, None);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
self.guest_container_closed(host, conn);
|
||||
let Some(window) = host.hub.open_mount_window(conn, columns as i32, mob as i32) else {
|
||||
return;
|
||||
};
|
||||
host.play.entry(conn).or_default().mount_window = Some(GuestMount {
|
||||
window,
|
||||
mob,
|
||||
columns,
|
||||
state: 0,
|
||||
sent: None,
|
||||
});
|
||||
self.lan_send_mount(host, conn);
|
||||
}
|
||||
|
||||
/// `HorseInventoryMenu` over mount `index`, with the player's inventory
|
||||
/// loaded by the caller.
|
||||
fn mount_engine_menu(&self, index: usize, columns: usize) -> Menu {
|
||||
let env = self.menu_env();
|
||||
let mut menu = Menu::new(Kind::Mount(columns));
|
||||
menu.mount = Some(self.mobs[index].kind.id.to_string());
|
||||
let (saddle, body, items) = self.mount_contents(index);
|
||||
menu.set_slot(&env, 0, saddle);
|
||||
menu.set_slot(&env, 1, body.flatten());
|
||||
for (k, item) in items.into_iter().take(columns * 3).enumerate() {
|
||||
menu.set_slot(&env, 2 + k, item);
|
||||
}
|
||||
menu
|
||||
}
|
||||
|
||||
/// The menu's slots (the mount's, then the inventory and the hotbar)
|
||||
/// and the carried stack, sent when they changed since the last time.
|
||||
fn lan_send_mount(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(gm) = host.play.get(&conn).and_then(|p| p.mount_window.as_ref()) else {
|
||||
return;
|
||||
};
|
||||
let (mob, columns) = (gm.mob, gm.columns);
|
||||
let Some(index) = self.mobs.iter().position(|m| m.id == mob) else {
|
||||
return;
|
||||
};
|
||||
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let (saddle, body, items) = self.mount_contents(index);
|
||||
let mut slots = vec![saddle, body.flatten()];
|
||||
slots.extend(items.into_iter().take(columns * 3));
|
||||
slots.extend((9..36).chain(0..9).map(|i| g.inventory[i].clone()));
|
||||
let carried = g.carried.clone();
|
||||
let now = Some((slots, carried));
|
||||
let Some(gm) = host
|
||||
.play
|
||||
.get_mut(&conn)
|
||||
.and_then(|p| p.mount_window.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if gm.sent == now {
|
||||
return;
|
||||
}
|
||||
gm.state = (gm.state + 1) & 0x7FFF;
|
||||
let (window, state) = (gm.window, gm.state);
|
||||
let (slots, carried) = now.clone().unwrap_or_default();
|
||||
gm.sent = now;
|
||||
let content = C::ContainerSetContent(play::ContainerSetContent {
|
||||
container: window,
|
||||
state,
|
||||
items: slots.iter().map(|s| host.hub.stack(s)).collect(),
|
||||
carried: host.hub.stack(&carried),
|
||||
});
|
||||
host.hub.send_to(conn, &content);
|
||||
}
|
||||
|
||||
/// A click on the guest's mount window; false for other windows.
|
||||
pub(super) fn lan_mount_click(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
p: &play::ContainerClick,
|
||||
) -> bool {
|
||||
let Some((mob, columns)) = host
|
||||
.play
|
||||
.get(&conn)
|
||||
.and_then(|pl| pl.mount_window.as_ref())
|
||||
.filter(|m| m.window == p.container)
|
||||
.map(|m| (m.mob, m.columns))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
|
||||
&& !host.hub.guests[gi].spectator()
|
||||
&& let Some(index) = self.mobs.iter().position(|m| m.id == mob)
|
||||
{
|
||||
let mut menu = self.mount_engine_menu(index, columns);
|
||||
self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
let _ = menu.click(env, i32::from(p.slot), i32::from(p.button), input(p.input));
|
||||
});
|
||||
self.lan_mount_store(index, columns, &menu);
|
||||
}
|
||||
// The client is told what the server has, whatever it predicted.
|
||||
if let Some(gm) = host
|
||||
.play
|
||||
.get_mut(&conn)
|
||||
.and_then(|pl| pl.mount_window.as_mut())
|
||||
{
|
||||
gm.sent = None;
|
||||
}
|
||||
self.lan_send_mount(host, conn);
|
||||
true
|
||||
}
|
||||
|
||||
/// The engine menu's mount slots back on mount `index`.
|
||||
fn lan_store_slots(menu: &Menu, columns: usize) -> Vec<Option<ItemStack>> {
|
||||
(0..columns * 3)
|
||||
.map(|k| menu.slot(2 + k).cloned())
|
||||
.collect()
|
||||
}
|
||||
fn lan_mount_store(&mut self, index: usize, columns: usize, menu: &Menu) {
|
||||
let (_, body, _) = self.mount_contents(index);
|
||||
let body = body.map(|_| menu.slot(1).cloned());
|
||||
let items = Self::lan_store_slots(menu, columns);
|
||||
self.mount_store(index, menu.slot(0).cloned(), body, items);
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.removed` of the guest's mount menu (`window`;
|
||||
/// None closes it whichever it is): the carried stack goes back to the
|
||||
/// inventory. False when no such window is open.
|
||||
pub(super) fn lan_mount_closed(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
window: Option<i32>,
|
||||
) -> bool {
|
||||
let closed = host
|
||||
.play
|
||||
.get_mut(&conn)
|
||||
.and_then(|p| {
|
||||
p.mount_window
|
||||
.take_if(|m| window.is_none_or(|w| m.window == w))
|
||||
})
|
||||
.is_some();
|
||||
if closed
|
||||
&& let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
|
||||
&& let Some(carried) = host.hub.guests[gi].carried.take()
|
||||
{
|
||||
self.lan_place_back(host, gi, vec![carried]);
|
||||
}
|
||||
closed
|
||||
}
|
||||
|
||||
/// Each tick: a mount menu whose mount died or is out of reach closes
|
||||
/// (`AbstractMountInventoryMenu.stillValid`: alive, within the entity
|
||||
/// interaction range plus 4 of the eyes), and the others follow the
|
||||
/// mount's and the inventory's slots.
|
||||
pub(super) fn tick_lan_mount_menus(&mut self, host: &mut LanHost) {
|
||||
let open: Vec<(u32, u32, i32)> = host
|
||||
.play
|
||||
.iter()
|
||||
.filter_map(|(c, p)| p.mount_window.as_ref().map(|m| (*c, m.mob, m.window)))
|
||||
.collect();
|
||||
for (conn, mob, window) in open {
|
||||
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
|
||||
continue;
|
||||
};
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let valid = self.mobs.iter().find(|m| m.id == mob).is_some_and(|m| {
|
||||
let (w, h) = (f64::from(m.width()) / 2.0, f64::from(m.height()));
|
||||
let p = [m.position.x, m.position.y, m.position.z];
|
||||
let lo = [p[0] - w, p[1], p[2] - w];
|
||||
let hi = [p[0] + w, p[1] + h, p[2] + w];
|
||||
let d: f64 = (0..3)
|
||||
.map(|k| (eye[k] - eye[k].clamp(lo[k], hi[k])).powi(2))
|
||||
.sum();
|
||||
// Player.ENTITY_INTERACTION_RANGE 3, plus 4.
|
||||
m.alive() && d < 7.0 * 7.0
|
||||
});
|
||||
if valid {
|
||||
self.lan_send_mount(host, conn);
|
||||
} else {
|
||||
self.lan_mount_closed(host, conn, Some(window));
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::ContainerClose(play::ContainerClose { container: window }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
//! Workstation menus for LAN guests: the screen a guest's use opens runs on
|
||||
//! the host's menu engine (`horizon_client::menu`), the one single player
|
||||
//! uses, with vanilla's packets: OpenScreen with the station's `MenuType`,
|
||||
//! container clicks, `ServerboundContainerButtonClickPacket`, the content and
|
||||
//! data slot sync after each (`broadcastChanges`), and the inputs back into
|
||||
//! the inventory when the menu closes (`AbstractContainerMenu.clearContainer`).
|
||||
use super::{
|
||||
Game,
|
||||
lan::LanHost,
|
||||
lan_crafting::input,
|
||||
stations::{Kind as StationKind, Station},
|
||||
};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
menu::{Kind, Menu},
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
|
||||
/// A guest's open workstation menu.
|
||||
pub(super) struct GuestStation {
|
||||
window: i32,
|
||||
state: i32,
|
||||
kind: StationKind,
|
||||
pos: [i32; 3],
|
||||
menu: Box<Menu>,
|
||||
/// The data slots the client was last sent.
|
||||
sent_data: Vec<i32>,
|
||||
/// The game time of the menu's last sound (`lastSoundTime`).
|
||||
last_sound: u64,
|
||||
}
|
||||
|
||||
/// A guest's open lectern: `LecternMenu` over the block entity, made on the
|
||||
/// menu engine for each click (`Game::lectern_menu`).
|
||||
pub(super) struct GuestLectern {
|
||||
window: i32,
|
||||
state: i32,
|
||||
key: (i32, i32, i32),
|
||||
/// The book and page the client was last sent (`broadcastChanges`).
|
||||
sent: Option<(Option<ItemStack>, i32)>,
|
||||
}
|
||||
|
||||
/// The `MenuType`, the engine's menu and the input slots
|
||||
/// (`clearContainer`'s container) of a station a guest can use.
|
||||
fn menu_of(kind: StationKind) -> Option<(&'static str, Kind, std::ops::Range<usize>)> {
|
||||
Some(match kind {
|
||||
StationKind::Loom => ("minecraft:loom", Kind::Loom, 0..3),
|
||||
StationKind::Stonecutter => ("minecraft:stonecutter", Kind::Stonecutter, 0..1),
|
||||
StationKind::Grindstone => ("minecraft:grindstone", Kind::Grindstone, 0..2),
|
||||
StationKind::Anvil => ("minecraft:anvil", Kind::Anvil, 0..2),
|
||||
StationKind::Smithing => ("minecraft:smithing", Kind::Smithing, 0..3),
|
||||
StationKind::Enchanting => ("minecraft:enchantment", Kind::Enchanting, 0..2),
|
||||
StationKind::Beacon => ("minecraft:beacon", Kind::Beacon, 0..1),
|
||||
StationKind::Cartography => ("minecraft:cartography_table", Kind::Cartography, 0..2),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A workstation a guest's use opened (`ServerPlayer.openMenu`); false
|
||||
/// when guests can't use that one yet.
|
||||
pub(super) fn lan_open_station(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
station: &Station,
|
||||
) -> bool {
|
||||
let Some((menu_type, kind, _)) = menu_of(station.kind) else {
|
||||
return false;
|
||||
};
|
||||
self.lan_station_closed(host, conn, None);
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
// The container it replaces loses its opener.
|
||||
self.guest_container_closed(host, conn);
|
||||
let title = horizon_protocol::text::translatable(station.kind.title_key(), &[]);
|
||||
let Some(window) = host.hub.open_window(conn, menu_type, title) else {
|
||||
return true;
|
||||
};
|
||||
let mut menu = Box::new(Menu::new(kind));
|
||||
if let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
|
||||
&& kind == Kind::Enchanting
|
||||
{
|
||||
// Player.enchantmentSeed, made when first needed.
|
||||
if g.xp_seed == 0 {
|
||||
g.xp_seed = self.world.random.next_int();
|
||||
}
|
||||
menu.data[3] = g.xp_seed;
|
||||
}
|
||||
if kind == Kind::Beacon {
|
||||
menu.data = self.beacon_data(host, station.pos).to_vec();
|
||||
}
|
||||
host.play.entry(conn).or_default().station = Some(GuestStation {
|
||||
window,
|
||||
state: 0,
|
||||
kind: station.kind,
|
||||
pos: station.pos,
|
||||
menu,
|
||||
sent_data: Vec::new(),
|
||||
last_sound: 0,
|
||||
});
|
||||
self.lan_send_station(host, conn);
|
||||
true
|
||||
}
|
||||
|
||||
/// Runs `op` on the guest's open station (window `window`, any when
|
||||
/// None) with their inventory in it, then sends what changed. False when
|
||||
/// no such window is open.
|
||||
fn lan_station_op(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
window: Option<i32>,
|
||||
op: impl FnOnce(&mut Menu, &horizon_client::menu::Env),
|
||||
) -> bool {
|
||||
let Some(mut gs) = host
|
||||
.play
|
||||
.get_mut(&conn)
|
||||
.and_then(|p| p.station.take_if(|s| window.is_none_or(|w| s.window == w)))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
|
||||
&& !host.hub.guests[gi].spectator()
|
||||
{
|
||||
self.guest_menu_op(host, gi, &mut gs.menu, op);
|
||||
self.lan_station_sounds(&mut gs);
|
||||
if let Some(amount) = gs.menu.ground.take() {
|
||||
self.grindstone_taken(gs.pos, amount);
|
||||
}
|
||||
self.lan_station_combined(host, conn, &mut gs);
|
||||
if let Some(button) = gs.menu.enchant_button.take() {
|
||||
self.lan_enchant(host, gi, &mut gs, button);
|
||||
}
|
||||
if std::mem::take(&mut gs.menu.enchant_changed) {
|
||||
self.lan_enchant_offers(host, gi, &mut gs);
|
||||
}
|
||||
}
|
||||
host.play.entry(conn).or_default().station = Some(gs);
|
||||
self.lan_send_station(host, conn);
|
||||
true
|
||||
}
|
||||
|
||||
/// `LecternBlock.openScreen` for a guest: `minecraft:lectern` over the
|
||||
/// block entity, with its book and page.
|
||||
pub(super) fn lan_open_lectern(&mut self, host: &mut LanHost, conn: u32, key: (i32, i32, i32)) {
|
||||
self.lan_station_closed(host, conn, None);
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
self.guest_container_closed(host, conn);
|
||||
let title = horizon_protocol::text::translatable("container.lectern", &[]);
|
||||
let Some(window) = host.hub.open_window(conn, "minecraft:lectern", title) else {
|
||||
return;
|
||||
};
|
||||
host.play.entry(conn).or_default().lectern = Some(GuestLectern {
|
||||
window,
|
||||
state: 0,
|
||||
key,
|
||||
sent: None,
|
||||
});
|
||||
self.lan_send_lectern(host, conn, true);
|
||||
}
|
||||
|
||||
/// The lectern's book and page for the client: everything (`all`), or
|
||||
/// what changed since it was last sent.
|
||||
fn lan_send_lectern(&mut self, host: &mut LanHost, conn: u32, all: bool) {
|
||||
let Some(gl) = host.play.get_mut(&conn).and_then(|p| p.lectern.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
let (book, page) = match self.world.block_entities.get(&gl.key) {
|
||||
Some(BlockEntity::Lectern(l)) => (l.book.clone(), l.page),
|
||||
_ => (None, 0),
|
||||
};
|
||||
let now = Some((book.clone(), page));
|
||||
if !all && gl.sent == now {
|
||||
return;
|
||||
}
|
||||
let page_changed = gl.sent.as_ref().is_none_or(|(_, p)| *p != page);
|
||||
gl.sent = now;
|
||||
gl.state = (gl.state + 1) & 0x7FFF;
|
||||
let (window, state) = (gl.window, gl.state);
|
||||
let carried = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == conn)
|
||||
.and_then(|g| g.carried.clone());
|
||||
let content = C::ContainerSetContent(play::ContainerSetContent {
|
||||
container: window,
|
||||
state,
|
||||
items: vec![host.hub.stack(&book)],
|
||||
carried: host.hub.stack(&carried),
|
||||
});
|
||||
host.hub.send_to(conn, &content);
|
||||
if all || page_changed {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::ContainerSetData(play::ContainerSetData {
|
||||
container: window,
|
||||
id: 0,
|
||||
value: page as i16,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `op` on the guest's lectern menu (window `window`) with their
|
||||
/// inventory, the block entity takes the book and page back, and the
|
||||
/// client gets what changed. False when no such window is open.
|
||||
fn lan_lectern_op(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
window: i32,
|
||||
op: impl FnOnce(&mut Menu, &horizon_client::menu::Env),
|
||||
) -> bool {
|
||||
let Some(key) = host
|
||||
.play
|
||||
.get(&conn)
|
||||
.and_then(|p| p.lectern.as_ref())
|
||||
.filter(|l| l.window == window)
|
||||
.map(|l| l.key)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
|
||||
&& !host.hub.guests[gi].spectator()
|
||||
&& let Some(mut menu) = self.lectern_menu(key)
|
||||
{
|
||||
// Player.mayBuild: survival and creative.
|
||||
let may_build = matches!(host.hub.guests[gi].game_mode, 0 | 1);
|
||||
self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
menu.may_build = may_build;
|
||||
op(menu, env);
|
||||
});
|
||||
self.lectern_menu_done(key, &menu);
|
||||
}
|
||||
self.lan_send_lectern(host, conn, true);
|
||||
true
|
||||
}
|
||||
|
||||
/// A click on the guest's lectern window; false for other windows.
|
||||
pub(super) fn lan_lectern_click(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
p: &play::ContainerClick,
|
||||
) -> bool {
|
||||
self.lan_lectern_op(host, conn, p.container, |menu, env| {
|
||||
let _ = menu.click(env, i32::from(p.slot), i32::from(p.button), input(p.input));
|
||||
})
|
||||
}
|
||||
|
||||
/// The rest of a taken anvil or smithing result's `onTake`: the guest
|
||||
/// pays the levels, the anvil wears (level event 1030 or 1029), the
|
||||
/// smithing table sounds (1044).
|
||||
fn lan_station_combined(&mut self, host: &mut LanHost, conn: u32, gs: &mut GuestStation) {
|
||||
let change = std::mem::take(&mut gs.menu.level_change);
|
||||
if change != 0 {
|
||||
self.as_guest(host, conn, |g| g.give_levels(change));
|
||||
}
|
||||
if !std::mem::take(&mut gs.menu.combined) {
|
||||
return;
|
||||
}
|
||||
if gs.kind == StationKind::Smithing {
|
||||
self.level_event_at(1044, gs.pos, 0);
|
||||
} else {
|
||||
let creative = gs.menu.creative;
|
||||
// A broken anvil closes the menu on the next stillValid check.
|
||||
self.anvil_used(gs.pos, creative);
|
||||
}
|
||||
}
|
||||
|
||||
/// `EnchantmentMenu.slotsChanged` with a level: the costs and clues for
|
||||
/// the guest's seed and the bookshelves around the table.
|
||||
fn lan_enchant_offers(&mut self, host: &LanHost, gi: usize, gs: &mut GuestStation) {
|
||||
let seed = host.hub.guests[gi].xp_seed;
|
||||
let bookcases = self.count_bookshelves(gs.pos);
|
||||
let offers = horizon_client::stations::enchant_offers(
|
||||
&self.res.stations().enchantments,
|
||||
self.res.items(),
|
||||
seed,
|
||||
bookcases,
|
||||
gs.menu.slot(0),
|
||||
);
|
||||
for i in 0..3 {
|
||||
gs.menu.data[i] = offers.costs[i];
|
||||
let clue = offers.clues[i].as_ref();
|
||||
gs.menu.data[4 + i] = clue
|
||||
.and_then(|(e, _)| host.hub.registry_id("minecraft:enchantment", e))
|
||||
.unwrap_or(-1);
|
||||
gs.menu.data[7 + i] = clue.map_or(-1, |(_, l)| *l as i32);
|
||||
}
|
||||
gs.menu.data[3] = seed;
|
||||
}
|
||||
|
||||
/// `EnchantmentMenu.clickMenuButton`'s `access.execute`: the item gets
|
||||
/// the offer's enchantments, the guest pays its levels and lapis and
|
||||
/// gets a new seed, the table sounds, and the offers are made again.
|
||||
fn lan_enchant(&mut self, host: &mut LanHost, gi: usize, gs: &mut GuestStation, button: usize) {
|
||||
let Some(item) = gs.menu.slot(0).cloned() else {
|
||||
return;
|
||||
};
|
||||
let g = &host.hub.guests[gi];
|
||||
let (seed, creative, conn) = (g.xp_seed, g.creative(), g.conn);
|
||||
let Some(enchanted) = horizon_client::stations::apply_enchantment(
|
||||
&self.res.stations().enchantments,
|
||||
self.res.items(),
|
||||
seed,
|
||||
button,
|
||||
gs.menu.data[button],
|
||||
&item,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let need = button as i32 + 1;
|
||||
// Player.onEnchantmentPerformed, and EnchantmentMenu's awardStat(ENCHANT_ITEM).
|
||||
self.as_guest(host, conn, |game| {
|
||||
game.give_levels(-need);
|
||||
game.stats.custom("enchant_item", 1);
|
||||
});
|
||||
host.hub.guests[gi].xp_seed = self.world.random.next_int();
|
||||
let env = self.menu_env();
|
||||
let lapis = gs.menu.slot(1).cloned().filter(|_| !creative).map(|mut l| {
|
||||
l.count = l.count.saturating_sub(need as u32);
|
||||
l
|
||||
});
|
||||
gs.menu.set_slot(&env, 0, Some(enchanted));
|
||||
if !creative {
|
||||
gs.menu.set_slot(&env, 1, lapis.filter(|l| l.count > 0));
|
||||
}
|
||||
gs.menu.enchant_changed = false;
|
||||
self.lan_enchant_offers(host, gi, gs);
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
let at = gs.pos.map(|v| f64::from(v) + 0.5);
|
||||
self.audio.play(
|
||||
"minecraft:block.enchantment_table.use",
|
||||
Some(at),
|
||||
1.0,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.handleRenameItem`: the guest's anvil
|
||||
/// takes the name (`AnvilMenu.setItemName`).
|
||||
pub(super) fn lan_station_rename(&mut self, host: &mut LanHost, conn: u32, name: &str) {
|
||||
let anvil = host
|
||||
.play
|
||||
.get(&conn)
|
||||
.and_then(|p| p.station.as_ref())
|
||||
.is_some_and(|s| s.kind == StationKind::Anvil);
|
||||
if anvil {
|
||||
self.lan_station_op(host, conn, None, |menu, env| {
|
||||
menu.rename(env, name);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The sounds the menu played, at the station (heard by everyone around,
|
||||
/// as `level.playSound(null, pos, ...)`), once per tick.
|
||||
fn lan_station_sounds(&mut self, gs: &mut GuestStation) {
|
||||
let sounds = std::mem::take(&mut gs.menu.sounds);
|
||||
if sounds.is_empty() || gs.last_sound == self.ticks {
|
||||
return;
|
||||
}
|
||||
gs.last_sound = self.ticks;
|
||||
let at = gs.pos.map(|v| f64::from(v) + 0.5);
|
||||
for sound in sounds {
|
||||
self.audio.play(sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on the guest's station window; false for other windows.
|
||||
pub(super) fn lan_station_click(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
p: &play::ContainerClick,
|
||||
) -> bool {
|
||||
self.lan_station_op(host, conn, Some(p.container), |menu, env| {
|
||||
let _ = menu.click(env, i32::from(p.slot), i32::from(p.button), input(p.input));
|
||||
})
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.handleContainerButtonClick`:
|
||||
/// `clickMenuButton`, then `broadcastChanges` when the menu took it.
|
||||
pub(super) fn lan_station_button(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
window: i32,
|
||||
id: i32,
|
||||
) {
|
||||
// LecternMenu's page buttons and Take Book.
|
||||
if self.lan_lectern_op(host, conn, window, |menu, env| {
|
||||
menu.button(env, id);
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
self.lan_station_op(host, conn, Some(window), |menu, env| {
|
||||
menu.button(env, id);
|
||||
});
|
||||
}
|
||||
|
||||
/// The whole station menu and its changed data slots for the client
|
||||
/// (`sendAllDataToRemote` / `broadcastChanges`).
|
||||
fn lan_send_station(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let Some(gs) = host.play.get_mut(&conn).and_then(|p| p.station.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
gs.state = (gs.state + 1) & 0x7FFF;
|
||||
let own = gs.menu.slot_count() - 36;
|
||||
let mut items: Vec<Option<ItemStack>> =
|
||||
(0..own).map(|s| gs.menu.slot(s).cloned()).collect();
|
||||
let g = &host.hub.guests[gi];
|
||||
items.extend((9..36).chain(0..9).map(|i| g.inventory[i].clone()));
|
||||
let carried = g.carried.clone();
|
||||
let packet = C::ContainerSetContent(play::ContainerSetContent {
|
||||
container: gs.window,
|
||||
state: gs.state,
|
||||
items: items.iter().map(|s| host.hub.stack(s)).collect(),
|
||||
carried: host.hub.stack(&carried),
|
||||
});
|
||||
gs.sent_data.resize(gs.menu.data.len(), i32::MIN);
|
||||
let mut data = Vec::new();
|
||||
for (k, &v) in gs.menu.data.iter().enumerate() {
|
||||
if gs.sent_data[k] != v {
|
||||
gs.sent_data[k] = v;
|
||||
data.push(C::ContainerSetData(play::ContainerSetData {
|
||||
container: gs.window,
|
||||
id: k as i16,
|
||||
value: v as i16,
|
||||
}));
|
||||
}
|
||||
}
|
||||
host.hub.send_to(conn, &packet);
|
||||
for packet in data {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.removed` of the guest's station (`window`;
|
||||
/// None closes it whichever it is): the carried stack and the inputs go
|
||||
/// back to the inventory, what doesn't fit is dropped. False when no such
|
||||
/// window is open.
|
||||
pub(super) fn lan_station_closed(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
window: Option<i32>,
|
||||
) -> bool {
|
||||
// A lectern's menu keeps nothing of its own (the book is the block entity's).
|
||||
let lectern = host
|
||||
.play
|
||||
.get_mut(&conn)
|
||||
.and_then(|p| p.lectern.take_if(|l| window.is_none_or(|w| l.window == w)));
|
||||
if lectern.is_some() {
|
||||
// AbstractContainerMenu.removed: the carried stack goes back.
|
||||
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
|
||||
&& let Some(carried) = host.hub.guests[gi].carried.take()
|
||||
{
|
||||
self.lan_place_back(host, gi, vec![carried]);
|
||||
}
|
||||
if window.is_some() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let Some(gs) = host
|
||||
.play
|
||||
.get_mut(&conn)
|
||||
.and_then(|p| p.station.take_if(|s| window.is_none_or(|w| s.window == w)))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return true;
|
||||
};
|
||||
// AbstractContainerMenu.removed: the carried stack first, then the inputs.
|
||||
let mut back: Vec<ItemStack> = host.hub.guests[gi].carried.take().into_iter().collect();
|
||||
let inputs = station_inputs(&gs);
|
||||
if gs.kind == StationKind::Beacon {
|
||||
// BeaconMenu.removed drops the payment (player.drop).
|
||||
if !back.is_empty() {
|
||||
self.lan_place_back(host, gi, std::mem::take(&mut back));
|
||||
}
|
||||
self.drop_guest_items(host, gi, inputs);
|
||||
} else {
|
||||
back.extend(inputs);
|
||||
}
|
||||
if !back.is_empty() {
|
||||
self.lan_place_back(host, gi, back);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `BeaconBlockEntity.dataAccess`: the pyramid's levels and the powers
|
||||
/// (`BeaconMenu.encodeEffect`: the `mob_effect` id + 1, 0 for none).
|
||||
fn beacon_data(&self, host: &LanHost, pos: [i32; 3]) -> [i32; 3] {
|
||||
let Some(BlockEntity::Beacon(b)) = self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
|
||||
else {
|
||||
return [0; 3];
|
||||
};
|
||||
let encode = |e: &Option<String>| {
|
||||
e.as_deref()
|
||||
.and_then(|n| host.hub.registry_id("minecraft:mob_effect", n))
|
||||
.map_or(0, |id| id + 1)
|
||||
};
|
||||
[b.levels, encode(&b.primary), encode(&b.secondary)]
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.handleSetBeaconPacket`: the guest's
|
||||
/// beacon menu takes the powers (`BeaconMenu.updateEffects`), and they
|
||||
/// become the block entity's (`dataAccess.set`: the power_select sound
|
||||
/// while the beam is up).
|
||||
pub(super) fn lan_station_beacon(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
primary: Option<i32>,
|
||||
secondary: Option<i32>,
|
||||
) {
|
||||
let Some(pos) = host
|
||||
.play
|
||||
.get(&conn)
|
||||
.and_then(|p| p.station.as_ref())
|
||||
.filter(|s| s.kind == StationKind::Beacon)
|
||||
.map(|s| s.pos)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// An id the registry doesn't have fails the packet's codec.
|
||||
let mut powers: [Option<(i32, String)>; 2] = [None, None];
|
||||
for (k, id) in [primary, secondary].into_iter().enumerate() {
|
||||
if let Some(id) = id {
|
||||
let Some(name) = host.hub.registry_name("minecraft:mob_effect", id) else {
|
||||
return;
|
||||
};
|
||||
powers[k] = Some((id, name.to_owned()));
|
||||
}
|
||||
}
|
||||
let data = self.beacon_data(host, pos);
|
||||
let mut updated = false;
|
||||
self.lan_station_op(host, conn, None, |menu, env| {
|
||||
menu.data.copy_from_slice(&data);
|
||||
let power = |k: usize| powers[k].as_ref().map(|(id, n)| (*id, n.as_str()));
|
||||
updated = menu.beacon_update(env, power(0), power(1));
|
||||
});
|
||||
if !updated {
|
||||
return;
|
||||
}
|
||||
let [primary, secondary] = powers.map(|p| p.map(|(_, n)| n));
|
||||
let mut select_sound = false;
|
||||
if let Some(BlockEntity::Beacon(b)) =
|
||||
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
|
||||
{
|
||||
b.primary = primary;
|
||||
b.secondary = secondary;
|
||||
select_sound = !b.sections.is_empty();
|
||||
}
|
||||
if select_sound {
|
||||
self.beacon_sound(pos, "minecraft:block.beacon.power_select");
|
||||
}
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
}
|
||||
|
||||
/// `ServerPlayer.doTick`'s `stillValid`: a guest's station closes once its
|
||||
/// block is gone or the guest is out of reach (`isWithinBlockInteractionRange(pos, 4)`).
|
||||
pub(super) fn lan_stations_tick(&mut self, host: &mut LanHost) {
|
||||
self.lan_lecterns_tick(host);
|
||||
let conns: Vec<u32> = host
|
||||
.play
|
||||
.iter()
|
||||
.filter(|(_, p)| p.station.is_some())
|
||||
.map(|(c, _)| *c)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
|
||||
continue;
|
||||
};
|
||||
if !host.hub.here(g) {
|
||||
continue;
|
||||
}
|
||||
let Some(gs) = host.play.get(&conn).and_then(|p| p.station.as_ref()) else {
|
||||
continue;
|
||||
};
|
||||
let [x, y, z] = gs.pos;
|
||||
let name = &self.res.facts().get(self.world.block(x, y, z)).name;
|
||||
let block = StationKind::from_block(name) == Some(gs.kind);
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
// The block interaction range (4.5, creative 5) and the buffer.
|
||||
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
|
||||
let near = (0..3)
|
||||
.map(|k| {
|
||||
let lo = f64::from(gs.pos[k]);
|
||||
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
|
||||
})
|
||||
.sum::<f64>()
|
||||
< reach * reach;
|
||||
if !(block && near) {
|
||||
let window = gs.window;
|
||||
self.lan_station_closed(host, conn, Some(window));
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::ContainerClose(play::ContainerClose { container: window }),
|
||||
);
|
||||
} else if gs.kind == StationKind::Beacon {
|
||||
// The beacon's data slots follow its block entity.
|
||||
let data = self.beacon_data(host, gs.pos);
|
||||
if let Some(gs) = host.play.get_mut(&conn).and_then(|p| p.station.as_mut())
|
||||
&& gs.menu.data[..] != data[..]
|
||||
{
|
||||
gs.menu.data.copy_from_slice(&data);
|
||||
self.lan_send_station(host, conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The guests' lecterns each tick: `stillValid` (the block entity, its
|
||||
/// book, the reach) closes them, and `broadcastChanges` sends a book or
|
||||
/// page another player changed.
|
||||
fn lan_lecterns_tick(&mut self, host: &mut LanHost) {
|
||||
let open: Vec<(u32, i32, (i32, i32, i32))> = host
|
||||
.play
|
||||
.iter()
|
||||
.filter_map(|(c, p)| p.lectern.as_ref().map(|l| (*c, l.window, l.key)))
|
||||
.collect();
|
||||
for (conn, window, key) in open {
|
||||
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
|
||||
continue;
|
||||
};
|
||||
if !host.hub.here(g) {
|
||||
continue;
|
||||
}
|
||||
let has_book = matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
Some(BlockEntity::Lectern(l)) if l.book.is_some()
|
||||
);
|
||||
// Container.stillValidBlockEntity: the interaction range and its buffer.
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
|
||||
let pos = [key.0, key.1, key.2];
|
||||
let near = (0..3)
|
||||
.map(|k| {
|
||||
let lo = f64::from(pos[k]);
|
||||
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
|
||||
})
|
||||
.sum::<f64>()
|
||||
< reach * reach;
|
||||
if has_book && near {
|
||||
self.lan_send_lectern(host, conn, false);
|
||||
} else {
|
||||
self.lan_station_closed(host, conn, Some(window));
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::ContainerClose(play::ContainerClose { container: window }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `clearContainer` gives back from a station's menu.
|
||||
pub(super) fn station_inputs(gs: &GuestStation) -> Vec<ItemStack> {
|
||||
menu_of(gs.kind)
|
||||
.map(|(_, _, inputs)| inputs.filter_map(|s| gs.menu.slot(s).cloned()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -55,8 +55,9 @@ impl Game<'_> {
|
||||
state.session.update_special_prices();
|
||||
}
|
||||
let title = self.merchant_display_name(&state);
|
||||
// The container the trading screen replaces loses its opener.
|
||||
// The container or workstation the trading screen replaces is closed.
|
||||
self.guest_container_closed(host, conn);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
let Some(window) = host.hub.open_merchant_window(conn, title) else {
|
||||
self.merchant_closed(&mut state);
|
||||
return;
|
||||
@@ -128,23 +129,6 @@ impl Game<'_> {
|
||||
}
|
||||
/// The screen title: the custom name, else the profession (the client
|
||||
/// adds the level itself).
|
||||
#[allow(dead_code)]
|
||||
fn merchant_title(&self, state: &MerchantState) -> String {
|
||||
if let Some(name) = &state.name {
|
||||
return name.clone();
|
||||
}
|
||||
let e = &self.res.stations().enchantments;
|
||||
let profession = state.session.profession.trim_start_matches("minecraft:");
|
||||
if state.session.villager {
|
||||
e.translate(&format!("entity.minecraft.villager.{profession}"))
|
||||
.unwrap_or(profession)
|
||||
.to_owned()
|
||||
} else {
|
||||
e.translate("entity.minecraft.wandering_trader")
|
||||
.unwrap_or("Wandering Trader")
|
||||
.to_owned()
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.sendMerchantOffers`, when the offers changed.
|
||||
fn lan_send_offers(
|
||||
&mut self,
|
||||
@@ -254,7 +238,11 @@ impl Game<'_> {
|
||||
recipes: self.res.recipes(),
|
||||
brewing: &self.res.stations().brewing,
|
||||
banners: &self.res.stations().banners,
|
||||
station_recipes: &self.res.stations().recipes,
|
||||
enchantments: &self.res.stations().enchantments,
|
||||
names: &self.pack.names,
|
||||
trade: Some(&trade_env),
|
||||
maps: None,
|
||||
};
|
||||
let mut menu = Menu::new(Kind::Merchant);
|
||||
{
|
||||
@@ -393,8 +381,8 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Takes a station a guest's action opened: a trading screen goes to the
|
||||
/// guest; other stations are not shown to guests yet.
|
||||
/// Takes a station a guest's action opened: a trading screen or a
|
||||
/// workstation menu goes to the guest (game/lan_stations.rs).
|
||||
pub(super) fn lan_station_opened(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
@@ -406,6 +394,8 @@ impl Game<'_> {
|
||||
&& let Some(m) = station.merchant.take()
|
||||
{
|
||||
self.lan_open_merchant(host, conn, *m);
|
||||
return;
|
||||
}
|
||||
self.lan_open_station(host, conn, &station);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 614 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user