mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
240
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
602e86e093 | ||
|
|
8b0152bd11 | ||
|
|
1c7656ee9b | ||
|
|
91f42fb633 | ||
|
|
92b0ee1347 | ||
|
|
014fc57b97 | ||
|
|
a9ccd5458c | ||
|
|
3e99730b98 | ||
|
|
f7b6777af6 | ||
|
|
00b438d0ce | ||
|
|
990c609269 | ||
|
|
9cfed309ac | ||
|
|
900bba1269 | ||
|
|
8500adab0c | ||
|
|
8b52929723 | ||
|
|
a4e68e6a53 | ||
|
|
4eb7a98330 | ||
|
|
27bf66b354 | ||
|
|
b3e9d8ebd1 | ||
|
|
baf013dd3f | ||
|
|
e310a5ca5e | ||
|
|
c7f6903144 | ||
|
|
05497ddd10 | ||
|
|
038e52f87e | ||
|
|
69887b87d1 | ||
|
|
4786d56d5b | ||
|
|
a5d68dc890 | ||
|
|
9ae041ccd3 | ||
|
|
63507e7c26 | ||
|
|
52aa397b26 | ||
|
|
525129fbe6 | ||
|
|
bf274d9254 | ||
|
|
07e7179773 | ||
|
|
a161487994 | ||
|
|
8c2f683e0a | ||
|
|
190faa4f55 | ||
|
|
36aa1ec0a2 | ||
|
|
8ddb74031c | ||
|
|
7f7a05cbd0 | ||
|
|
e5fbba281c | ||
|
|
63d59b26cf | ||
|
|
72b749cdb0 | ||
|
|
1a8dae9f21 | ||
|
|
5d77ace9a4 | ||
|
|
afd3874482 | ||
|
|
16b7e60074 | ||
|
|
83fa14e51b | ||
|
|
7a0bdbc05e | ||
|
|
67277d4571 | ||
|
|
d3bad12383 | ||
|
|
a9682c5554 | ||
|
|
663b11d8dd | ||
|
|
4779b48d2b | ||
|
|
01f1a0360d | ||
|
|
dcb3749ed1 | ||
|
|
96a031792b | ||
|
|
08a73694ab | ||
|
|
fb7665d88e | ||
|
|
be16401f90 | ||
|
|
fd9e6a7e78 | ||
|
|
aae60c16ba | ||
|
|
4e04e97565 | ||
|
|
fc8643b380 | ||
|
|
954bb6a1fa | ||
|
|
042adc674a | ||
|
|
cd494e74ba | ||
|
|
f1f8170c58 | ||
|
|
700c9ec8cd | ||
|
|
1ec2762fe6 | ||
|
|
2f8d913b85 | ||
|
|
6c4309d36f | ||
|
|
95d0a06ba5 | ||
|
|
90afc3dbf4 | ||
|
|
cdaf1d79a0 | ||
|
|
d68ca84275 | ||
|
|
270ba3fea9 | ||
|
|
4f4dc33370 | ||
|
|
59adff16ff | ||
|
|
160076e15f | ||
|
|
658d54e578 | ||
|
|
3576a7a1c0 | ||
|
|
0ed1bd8deb | ||
|
|
f254871c1f | ||
|
|
9f687dd1ec | ||
|
|
dc6b648f69 | ||
|
|
36a5696abf | ||
|
|
48d6211781 | ||
|
|
1ef5f8fae5 | ||
|
|
0928c83d4c | ||
|
|
724f42355f | ||
|
|
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 | ||
|
|
fa14833b41 | ||
|
|
e4ea9e0199 | ||
|
|
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 | ||
|
|
b88218d019 | ||
|
|
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 | ||
|
|
c16b41ab31 | ||
|
|
46efecf9d2 | ||
|
|
d029d0d28c | ||
|
|
aa7f71a79b | ||
|
|
3704384c72 | ||
|
|
772844362e | ||
|
|
938a25a712 | ||
|
|
de8cb0dfa6 | ||
|
|
41a4ce4edb | ||
|
|
0ae1ab2e70 | ||
|
|
3aa36f95ab | ||
|
|
84c7f39148 | ||
|
|
388f59aff4 | ||
|
|
5bad717213 | ||
|
|
dd1525f045 | ||
|
|
9054acfed2 | ||
|
|
770809908d | ||
|
|
83d001460c | ||
|
|
8f738d35ec | ||
|
|
6f1f83d07c | ||
|
|
743cc8e069 | ||
|
|
8291c8af5b | ||
|
|
e6b92eb4ee | ||
|
|
2f3f1377a3 | ||
|
|
dbb1ca0907 | ||
|
|
ca5404e52d | ||
|
|
b5a92262c8 | ||
|
|
3e868683ba | ||
|
|
4fecf43d28 | ||
|
|
c8f02d91b7 | ||
|
|
cd37ea98a0 | ||
|
|
255f1960ee | ||
|
|
30a2cc7c61 | ||
|
|
bc9e5afacc | ||
|
|
16738c9f26 | ||
|
|
e70b520e5d | ||
|
|
c2b7f3a4ea | ||
|
|
8e700aa683 | ||
|
|
d9560d46e6 | ||
|
|
26142a9c40 | ||
|
|
832f879b27 | ||
|
|
b7453b129b | ||
|
|
05e169f4e3 | ||
|
|
8b75ac3cdb | ||
|
|
55891f9c95 | ||
|
|
2f883b5cdb | ||
|
|
0468b75c43 | ||
|
|
dfe795bc09 | ||
|
|
9019d708d0 | ||
|
|
186777110f | ||
|
|
ac25d41578 | ||
|
|
acbfb8ceca | ||
|
|
6ea7a51ff3 | ||
|
|
e570561db9 | ||
|
|
21998a7e6a | ||
|
|
cb0bbe7d19 | ||
|
|
863a309161 | ||
|
|
3917f35e3e | ||
|
|
a893fa6125 | ||
|
|
3b21a975bb | ||
|
|
67ca65bb3b | ||
|
|
6216b84669 | ||
|
|
2ecbdd57eb | ||
|
|
62874d8dc4 | ||
|
|
da314fa658 | ||
|
|
6127790456 | ||
|
|
30cdd6298a |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,17 +1,20 @@
|
||||
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
|
||||
# Beta game releases become stable once a day (docs/release-flow.md, "Channels"): at 20:00 UTC (21:00 in Paris in
|
||||
# winter time, 22:00 in summer) tools/release_channels.py promotes the newest beta that had been out 6 hours by then,
|
||||
# passed its release smoke test and isn't covered by an issue labelled release-blocker. Launchers on the Alpha
|
||||
# channel install every beta when it is published; Stable gets the day's promotion. The later runs of the evening are
|
||||
# retries for a run GitHub starts late or skips: they promote the same beta, so stable still moves once.
|
||||
# By hand, from GitHub's app too (Actions > promote > Run workflow):
|
||||
# gh workflow run promote -f tag=v0.2.3 # that beta now, without waiting for 20:00
|
||||
# gh workflow run promote -f tag=v0.2.3 -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.
|
||||
# When the oldest waiting beta is 30 hours old (it missed a promotion it was due for), an issue "Promotion to stable
|
||||
# has stalled" says so; it closes itself after the next promotion.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '13,43 * * * *'
|
||||
- cron: '7,37 20-21 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
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.
|
||||
# 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.
|
||||
# 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
|
||||
# stable in its daily promotion. 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
|
||||
@@ -45,6 +45,7 @@ jobs:
|
||||
outputs:
|
||||
release: ${{ steps.decide.outputs.release }}
|
||||
tag: ${{ steps.decide.outputs.tag }}
|
||||
version: ${{ steps.decide.outputs.version }}
|
||||
head: ${{ steps.decide.outputs.head }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
@@ -65,6 +66,9 @@ jobs:
|
||||
run: |
|
||||
python3 tools/release_train.py decide --kind game ${HEAD_SHA:+--head "$HEAD_SHA"} ${FORCE:+--force} | tee decision.json
|
||||
echo "### $(jq -r '.reason' decision.json)" >> "$GITHUB_STEP_SUMMARY"
|
||||
# The version the programs are built with (docs/versioning.md): the tag without its v.
|
||||
tag=$(jq -r '.tag' decision.json)
|
||||
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
|
||||
# A dry run builds whatever the decision, to test the builds.
|
||||
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
|
||||
|
||||
@@ -99,6 +103,9 @@ jobs:
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
# The release's version, built in (horizon_core::release): --version, user agents, crash reports and
|
||||
# telemetry say it. No version-bump commits.
|
||||
HORIZON_VERSION: ${{ needs.decide.outputs.version }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -162,9 +169,13 @@ jobs:
|
||||
bin=target/release
|
||||
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
|
||||
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
|
||||
"$bin/horizon-prepare${{ matrix.exe }}" --version
|
||||
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
|
||||
"$bin/horizon-server${{ matrix.exe }}" --version
|
||||
# The version is the release's.
|
||||
for binary in horizon-prepare horizon-server; do
|
||||
out=$("$bin/$binary${{ matrix.exe }}" --version | tr -d '\r')
|
||||
echo "$out"
|
||||
[[ "$out" == "$binary $HORIZON_VERSION" ]] || { echo "::error::expected $binary $HORIZON_VERSION"; exit 1; }
|
||||
done
|
||||
- name: Tools from the runner
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: bash tools/ci/runner-env.sh
|
||||
@@ -245,7 +256,7 @@ jobs:
|
||||
folder="dist/${{ matrix.server_dir }}"
|
||||
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
|
||||
"$folder/horizon-engine" --help | grep -q -- '--resources'
|
||||
"$folder/horizon-server" --version
|
||||
[[ "$("$folder/horizon-server" --version)" == "horizon-server $HORIZON_VERSION" ]]
|
||||
cd dist
|
||||
rm -f "${{ matrix.server_asset }}"
|
||||
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
|
||||
@@ -338,7 +349,8 @@ jobs:
|
||||
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).
|
||||
# A beta (pre-release), the Alpha channel's: promote.yml makes it stable and latest in its daily promotion
|
||||
# (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 \
|
||||
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz \
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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`).
|
||||
@@ -42,8 +42,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"] }
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -174,7 +174,7 @@ pub fn discovery_url() -> String {
|
||||
fn agent() -> ureq::Agent {
|
||||
ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.user_agent(concat!("Horizon/", env!("CARGO_PKG_VERSION")))
|
||||
.user_agent(&format!("Horizon/{}", horizon_core::release::VERSION))
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ impl Session {
|
||||
"initialize" => Ok(json!({
|
||||
"protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL),
|
||||
"capabilities": { "tools": {} },
|
||||
"serverInfo": { "name": "horizon-ctl", "version": env!("CARGO_PKG_VERSION") },
|
||||
"serverInfo": { "name": "horizon-ctl", "version": horizon_core::release::VERSION },
|
||||
"instructions": "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).",
|
||||
})),
|
||||
"ping" => Ok(json!({})),
|
||||
|
||||
@@ -528,7 +528,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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -105,18 +105,23 @@ impl Report {
|
||||
}
|
||||
}
|
||||
|
||||
/// The release tag (the launcher installs each build in a folder named after it) and the client's
|
||||
/// own version, or just the latter.
|
||||
/// The release this binary is (`v0.2.3`); for a release from before 0.2, its tag and the client's
|
||||
/// crate version; for a build from source, that version alone.
|
||||
pub fn version() -> String {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
match release_tag() {
|
||||
Some(tag) => format!("{tag} (client {version})"),
|
||||
None => version.to_owned(),
|
||||
match (horizon_core::release::RELEASE, release_tag()) {
|
||||
(Some(_), Some(tag)) => tag,
|
||||
(None, Some(tag)) => format!("{tag} (client {version})"),
|
||||
(_, None) => version.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The release this binary came with: launcher installs live in a folder named after the tag.
|
||||
/// The release this binary came with: the version release.yml built in (docs/versioning.md), else
|
||||
/// the folder the launcher installed it in, named after the tag (releases from before 0.2).
|
||||
pub fn release_tag() -> Option<String> {
|
||||
if let Some(version) = horizon_core::release::RELEASE {
|
||||
return Some(format!("v{version}"));
|
||||
}
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let folder = exe.parent()?.file_name()?.to_str()?;
|
||||
(folder.starts_with('v') && folder[1..].starts_with(|c: char| c.is_ascii_digit()))
|
||||
@@ -517,6 +522,18 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_release_reports_its_built_in_version() {
|
||||
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
|
||||
match horizon_core::release::RELEASE {
|
||||
Some(version) => {
|
||||
assert_eq!(release_tag(), Some(format!("v{version}")));
|
||||
assert_eq!(super::version(), format!("v{version}"));
|
||||
}
|
||||
None => assert!(release_tag().is_none() || super::version().contains("(client ")),
|
||||
}
|
||||
}
|
||||
|
||||
fn report() -> Report {
|
||||
Report {
|
||||
time: chrono::TimeZone::with_ymd_and_hms(&Local, 2026, 10, 14, 9, 5, 7).unwrap(),
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Horizon Extras' dynamic lights (docs/extras.md): a torch, lantern or other light source held by
|
||||
//! a player or mob, dropped as an item, or an entity on fire lights the blocks and entities
|
||||
//! around it. Not in vanilla; off by default.
|
||||
//!
|
||||
//! Client-only and at render time: the light engine, chunk meshes and saves never see it. Each
|
||||
//! frame the game picks the [`MAX`] sources nearest the camera; terrain.wgsl and entity.wgsl
|
||||
//! raise a vertex's block light to `level - distance` (one level per block, as block light falls
|
||||
//! off) when that is brighter. Off, the list is empty and the shaders skip the loop.
|
||||
use crate::blocks::BlockFacts;
|
||||
use glam::DVec3;
|
||||
use horizon_world::registry::BlockRegistry;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Lights the shaders read: `Globals.dynamic_lights` in gfx.rs, terrain.wgsl and entity.wgsl.
|
||||
pub const MAX: usize = 16;
|
||||
/// What an entity on fire gives off, as a lit campfire or fire block does.
|
||||
pub const BURNING: u8 = 15;
|
||||
/// Sources farther than this from the camera light nothing it can see well.
|
||||
const REACH: f64 = 96.0;
|
||||
|
||||
/// How brightly each item glows when held or dropped.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Luminance(HashMap<String, u8>);
|
||||
|
||||
impl Luminance {
|
||||
/// A block item glows like its block's default state (`getLightEmission`): torches 14,
|
||||
/// lanterns and glowstone 15, a lit redstone torch 7; a furnace's item, unlit, not at all.
|
||||
pub fn new(facts: &BlockFacts, registry: &BlockRegistry) -> Self {
|
||||
let mut map = HashMap::new();
|
||||
for (id, state) in facts.states.iter().enumerate() {
|
||||
if state.emission == 0 || *state.item != *state.name {
|
||||
continue;
|
||||
}
|
||||
if registry.default_state(&state.name).map(|d| d.0 as usize) == Some(id) {
|
||||
map.insert(state.item.to_string(), state.emission);
|
||||
}
|
||||
}
|
||||
// A bucket of lava glows as lava does.
|
||||
map.insert("minecraft:lava_bucket".into(), 15);
|
||||
Self(map)
|
||||
}
|
||||
|
||||
pub fn of(&self, item: &str) -> u8 {
|
||||
self.0.get(item).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn from_pairs(pairs: &[(&str, u8)]) -> Self {
|
||||
Self(pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// One light this frame: where it is (world space) and its level, 1 to 15.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Source {
|
||||
pub at: DVec3,
|
||||
pub level: u8,
|
||||
}
|
||||
|
||||
/// The [`MAX`] sources nearest `camera` within reach, as the shaders read them: camera-relative
|
||||
/// position and level. Overwrites `out`.
|
||||
pub fn select(sources: &[Source], camera: DVec3, out: &mut Vec<[f32; 4]>) {
|
||||
out.clear();
|
||||
let key = |s: &Source| s.at.distance_squared(camera);
|
||||
let mut near: Vec<&Source> = sources
|
||||
.iter()
|
||||
.filter(|s| s.level > 0 && key(s) <= REACH * REACH)
|
||||
.collect();
|
||||
near.sort_by(|a, b| key(a).total_cmp(&key(b)));
|
||||
out.extend(near.into_iter().take(MAX).map(|s| {
|
||||
let p = (s.at - camera).as_vec3();
|
||||
[p.x, p.y, p.z, f32::from(s.level.min(15))]
|
||||
}));
|
||||
}
|
||||
|
||||
/// The block light a vertex at `pos` (camera-relative) gets from `lights`, 0 to 15: the WGSL
|
||||
/// `dynamic_block_light` in Rust, for tests.
|
||||
pub fn level_at(pos: [f32; 3], lights: &[[f32; 4]]) -> f32 {
|
||||
lights.iter().fold(0.0f32, |level, l| {
|
||||
let d =
|
||||
((pos[0] - l[0]).powi(2) + (pos[1] - l[1]).powi(2) + (pos[2] - l[2]).powi(2)).sqrt();
|
||||
level.max(l[3] - d)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nearest_sources_win() {
|
||||
let camera = DVec3::new(100.0, 64.0, 100.0);
|
||||
let mut sources: Vec<Source> = (0..40)
|
||||
.map(|i| Source {
|
||||
at: camera + DVec3::new(f64::from(i), 0.0, 0.0),
|
||||
level: 14,
|
||||
})
|
||||
.collect();
|
||||
sources.push(Source {
|
||||
at: camera + DVec3::new(0.5, 0.0, 0.0),
|
||||
level: 0,
|
||||
});
|
||||
sources.push(Source {
|
||||
at: camera + DVec3::new(0.0, 0.0, 200.0),
|
||||
level: 15,
|
||||
});
|
||||
let mut out = Vec::new();
|
||||
select(&sources, camera, &mut out);
|
||||
assert_eq!(out.len(), MAX);
|
||||
assert_eq!(out[0], [0.0, 0.0, 0.0, 14.0]);
|
||||
assert_eq!(out[MAX - 1][0], (MAX - 1) as f32);
|
||||
select(&[], camera, &mut out);
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_falls_off_a_level_per_block() {
|
||||
let torch = [[0.0, 0.0, 0.0, 14.0]];
|
||||
assert_eq!(level_at([0.0, 0.0, 0.0], &torch), 14.0);
|
||||
assert_eq!(level_at([3.0, 4.0, 0.0], &torch), 9.0);
|
||||
assert_eq!(level_at([20.0, 0.0, 0.0], &torch), 0.0);
|
||||
let two = [[0.0, 0.0, 0.0, 14.0], [10.0, 0.0, 0.0, 15.0]];
|
||||
assert_eq!(level_at([8.0, 0.0, 0.0], &two), 13.0);
|
||||
assert_eq!(level_at([0.0; 3], &[]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_luminance() {
|
||||
let l = Luminance::from_pairs(&[("minecraft:torch", 14)]);
|
||||
assert_eq!(l.of("minecraft:torch"), 14);
|
||||
assert_eq!(l.of("minecraft:stone"), 0);
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
|
||||
@@ -583,6 +583,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 +607,8 @@ impl ArmorStand {
|
||||
no_base_plate: false,
|
||||
invisible: false,
|
||||
marker: false,
|
||||
custom_name: None,
|
||||
name_visible: false,
|
||||
last_hit: i64::MIN / 2,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ struct Globals {
|
||||
// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png.
|
||||
end_portal_sprites: array<vec4<f32>, 2>,
|
||||
// x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000)
|
||||
// y: Nether cardinal lighting.
|
||||
// y: Nether cardinal lighting, z: how many dynamic_lights are set.
|
||||
time: vec4<f32>,
|
||||
crumbling_sprite: vec4<f32>,
|
||||
// Selection outline parameters (read by terrain.wgsl).
|
||||
outline_params: vec4<f32>,
|
||||
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
|
||||
dynamic_lights: array<vec4<f32>, 16>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -41,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 {
|
||||
@@ -58,13 +61,25 @@ fn overlay_texel(overlay: u32) -> vec4<f32> {
|
||||
if overlay >= 16u && overlay < 32u { return vec4(1.0, 1.0, 1.0, WHITE_OVERLAY_ALPHA[overlay - 16u]); }
|
||||
return vec4(1.0);
|
||||
}
|
||||
// Horizon Extras' dynamic lights (dynamic_light.rs): the block light, in the vertex's units
|
||||
// (16 per level), a vertex at camera-relative `pos` gets from the frame's light sources, one
|
||||
// level less per block. time.z of them are set: none when the extra is off, so no loop runs.
|
||||
fn dynamic_block_light(pos: vec3<f32>) -> f32 {
|
||||
let count = min(u32(globals.time.z), 16u);
|
||||
var level = 0.0;
|
||||
for (var i = 0u; i < count; i++) {
|
||||
let l = globals.dynamic_lights[i];
|
||||
level = max(level, l.w - distance(pos, l.xyz));
|
||||
}
|
||||
return level * 16.0;
|
||||
}
|
||||
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
|
||||
var out: Out;
|
||||
out.clip = globals.view_proj * vec4(position, 1.0);
|
||||
out.proj = vec4(out.clip.xy * 0.5 + out.clip.w * 0.5, out.clip.zw);
|
||||
out.uv = uv;
|
||||
let l = vec2(f32(light.y), f32(light.x));
|
||||
let l = vec2(max(f32(light.y), dynamic_block_light(position)), f32(light.x));
|
||||
let lm = textureSampleLevel(lightmap, lightmap_sampler,
|
||||
clamp(l / 256.0 + 0.5 / 16.0, vec2(0.5 / 16.0), vec2(15.5 / 16.0)), 0.0);
|
||||
// Celestial bodies, clouds, beacon beams, End portals and breaking cracks are unlit.
|
||||
@@ -95,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>,
|
||||
@@ -195,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};
|
||||
@@ -555,6 +556,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,6 +615,7 @@ 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;
|
||||
@@ -1266,6 +1276,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 +1319,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 +1601,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"))]);
|
||||
|
||||
@@ -69,6 +69,8 @@ impl Corner {
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Extras {
|
||||
pub fullbright: bool,
|
||||
/// Held, dropped and burning light sources light their surroundings (`dynamic_light`).
|
||||
pub dynamic_lights: bool,
|
||||
pub zoom: bool,
|
||||
/// A vanilla key name (`key.keyboard.z`).
|
||||
pub zoom_key: String,
|
||||
@@ -110,6 +112,7 @@ impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fullbright: false,
|
||||
dynamic_lights: false,
|
||||
zoom: false,
|
||||
zoom_key: "key.keyboard.z".into(),
|
||||
zoom_level: 4,
|
||||
@@ -158,6 +161,7 @@ impl Extras {
|
||||
};
|
||||
match key.trim() {
|
||||
"fullbright" => e.fullbright = flag(e.fullbright),
|
||||
"dynamicLights" => e.dynamic_lights = flag(e.dynamic_lights),
|
||||
"zoom" => e.zoom = flag(e.zoom),
|
||||
"zoomKey" if value.starts_with("key.") => e.zoom_key = value.to_owned(),
|
||||
"zoomLevel" => {
|
||||
@@ -201,8 +205,9 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\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",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
self.zoom_key,
|
||||
self.zoom_level,
|
||||
@@ -423,6 +428,7 @@ impl Cps {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Item {
|
||||
Fullbright,
|
||||
DynamicLights,
|
||||
Zoom,
|
||||
ZoomKey,
|
||||
ZoomLevel,
|
||||
@@ -452,6 +458,7 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
vec![
|
||||
Err("Lighting"),
|
||||
Ok(Fullbright),
|
||||
Ok(DynamicLights),
|
||||
Err("Zoom"),
|
||||
Ok(Zoom),
|
||||
Ok(ZoomKey),
|
||||
@@ -501,6 +508,7 @@ impl Item {
|
||||
pub fn caption(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "Fullbright",
|
||||
Item::DynamicLights => "Dynamic lights",
|
||||
Item::Zoom => "Zoom",
|
||||
Item::ZoomKey => "Zoom key",
|
||||
Item::ZoomLevel => "Starting zoom",
|
||||
@@ -533,7 +541,8 @@ impl Item {
|
||||
KeystrokesCorner | KeystrokesCps => Keystrokes,
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort | AutoReconnect => {
|
||||
Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort
|
||||
| AutoReconnect => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -542,6 +551,9 @@ impl Item {
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "Show blocks and entities at full light, even in dark areas.",
|
||||
Item::DynamicLights => {
|
||||
"Torches and other light sources held, dropped or on fire light the blocks around them."
|
||||
}
|
||||
Item::Zoom => "Hold the zoom key to zoom; the wheel changes it while held.",
|
||||
Item::ZoomKey => "Click, then press a key. Escape unbinds it.",
|
||||
Item::ZoomLevel => "The zoom the key starts at.",
|
||||
@@ -581,7 +593,7 @@ impl Item {
|
||||
use crate::graphics_style::Cost;
|
||||
match self {
|
||||
Item::Fullbright | Item::Zoom | Item::Fps | Item::Keystrokes => Some(Cost::None),
|
||||
Item::Minimap => Some(Cost::Low),
|
||||
Item::Minimap | Item::DynamicLights => Some(Cost::Low),
|
||||
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
|
||||
_ => None,
|
||||
}
|
||||
@@ -590,6 +602,7 @@ impl Item {
|
||||
pub fn switch(self, e: &Extras) -> Option<bool> {
|
||||
Some(match self {
|
||||
Item::Fullbright => e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights,
|
||||
Item::Zoom => e.zoom,
|
||||
Item::ZoomSmooth => e.zoom_smooth,
|
||||
Item::ZoomSensitivity => e.zoom_sensitivity,
|
||||
@@ -632,6 +645,7 @@ impl Item {
|
||||
pub fn cycle(self, e: &mut Extras, back: bool) {
|
||||
match self {
|
||||
Item::Fullbright => e.fullbright = !e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
|
||||
Item::Zoom => e.zoom = !e.zoom,
|
||||
Item::ZoomSmooth => e.zoom_smooth = !e.zoom_smooth,
|
||||
Item::ZoomSensitivity => e.zoom_sensitivity = !e.zoom_sensitivity,
|
||||
@@ -677,7 +691,7 @@ mod tests {
|
||||
#[test]
|
||||
fn everything_is_off_by_default_and_round_trips() {
|
||||
let d = Extras::default();
|
||||
assert!(!d.fullbright);
|
||||
assert!(!d.fullbright && !d.dynamic_lights);
|
||||
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
|
||||
assert!(!d.mouse_tweaks && !d.sort);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
@@ -685,6 +699,9 @@ mod tests {
|
||||
let mut e = d.clone();
|
||||
Item::Fullbright.cycle(&mut e, false);
|
||||
assert_eq!(Item::Fullbright.switch(&e), Some(true));
|
||||
Item::DynamicLights.cycle(&mut e, false);
|
||||
assert_eq!(Item::DynamicLights.switch(&e), Some(true));
|
||||
assert_eq!(Extras::parse(&e.serialize()), e);
|
||||
e.zoom = true;
|
||||
e.zoom_key = "key.keyboard.c".into();
|
||||
e.zoom_level = 6;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
+406
-217
File diff suppressed because it is too large.
Load diff
@@ -403,6 +403,7 @@ impl Game<'_> {
|
||||
Screen::Stats => "stats",
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::CommandBlock => "command-block",
|
||||
}
|
||||
}
|
||||
@@ -1840,11 +1841,21 @@ impl Game<'_> {
|
||||
if self.ticks < *report_tick && !timed_out {
|
||||
return None;
|
||||
}
|
||||
let after = pos.map(|p| self.block_json(p));
|
||||
let changed = match (*pos, *before) {
|
||||
(Some(p), Some(b)) => self.world.block(p[0], p[1], p[2]) != b,
|
||||
_ => false,
|
||||
};
|
||||
// A client of a server (single player's server thread, a LAN
|
||||
// guest) sees the block once the server's answer comes.
|
||||
if *place
|
||||
&& !changed
|
||||
&& self.remote.is_some()
|
||||
&& self.ticks < *report_tick + 20
|
||||
&& !timed_out
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let after = pos.map(|p| self.block_json(p));
|
||||
let used = delta(inventory, &self.inventory_counts());
|
||||
let mut v = json!({
|
||||
"ok": !*place || changed,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -432,6 +432,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()
|
||||
@@ -700,9 +704,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) {
|
||||
|
||||
@@ -523,13 +523,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 +564,8 @@ impl Game<'_> {
|
||||
"graphics" => Page::Graphics,
|
||||
"graphics-advanced" => Page::GraphicsAdvanced,
|
||||
"extras" => Page::Extras,
|
||||
"horizon" => Page::Horizon,
|
||||
"shader-packs" => Page::ShaderPacks,
|
||||
other => return Err(format!("unknown options page {other}").into()),
|
||||
};
|
||||
if self.screen == Screen::Playing && self.title.is_none() {
|
||||
@@ -727,6 +738,7 @@ impl Game<'_> {
|
||||
Screen::Stats => "stats",
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::CommandBlock => "command-block",
|
||||
}
|
||||
}
|
||||
@@ -879,14 +891,16 @@ impl Game<'_> {
|
||||
"tridents": self.tridents.len(),
|
||||
"rockets": self.rockets.len(),
|
||||
"fight": self.dragon_status(),
|
||||
"carts": self.carts.iter().map(|c| serde_json::json!({"kind": c.kind.id(), "position": [c.position.x, c.position.y, c.position.z], "velocity": [c.velocity.x, c.velocity.y, c.velocity.z]})).collect::<Vec<_>>(),
|
||||
// A client of a server (single player's server thread) knows the
|
||||
// carts it was sent.
|
||||
"carts": self.remote_carts().unwrap_or_else(|| self.carts.iter().map(|c| serde_json::json!({"kind": c.kind.id(), "position": [c.position.x, c.position.y, c.position.z], "velocity": [c.velocity.x, c.velocity.y, c.velocity.z]})).collect::<Vec<_>>()),
|
||||
"riding": self.riding,
|
||||
"using": self.using,
|
||||
"render_distance": self.streaming.radius,
|
||||
"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<_, _>>(),
|
||||
@@ -915,6 +929,8 @@ 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());
|
||||
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],
|
||||
@@ -390,6 +394,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 +407,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 +504,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 +607,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 +2129,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 +2951,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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Horizon Extras' dynamic lights (`horizon_client::dynamic_light`): the light sources near the
|
||||
//! camera this frame. Nothing is gathered while the extra is off.
|
||||
use super::{Game, shield};
|
||||
use glam::DVec3;
|
||||
use horizon_client::dynamic_light::{self, BURNING, Luminance, Source};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Fills `dynamic_lights` for the frame: the player's and mobs' held items and fire, and
|
||||
/// glowing dropped items, the nearest [`dynamic_light::MAX`] of them.
|
||||
pub(super) fn gather_dynamic_lights(&mut self, camera: DVec3, alpha: f64) {
|
||||
let mut out = std::mem::take(&mut self.dynamic_lights);
|
||||
out.clear();
|
||||
if !self.settings.extras.dynamic_lights {
|
||||
self.dynamic_lights = out;
|
||||
return;
|
||||
}
|
||||
if self.luminance.is_none() {
|
||||
self.luminance = Some(Luminance::new(self.res.facts(), self.pack.assets.registry));
|
||||
}
|
||||
let lum = self.luminance.as_ref().expect("built above");
|
||||
let held = |item: Option<&str>| item.map_or(0, |i| lum.of(i));
|
||||
let mut sources = Vec::new();
|
||||
if let Some(p) = &self.player
|
||||
&& !p.spectator
|
||||
{
|
||||
let feet = self
|
||||
.previous_position
|
||||
.lerp(p.position, self.clock.camera_fraction());
|
||||
let level = held(self.stack_in(self.selected).map(|s| &*s.item))
|
||||
.max(held(self.stack_in(shield::OFFHAND_SLOT).map(|s| &*s.item)))
|
||||
.max(if p.fire_ticks > 0 { BURNING } else { 0 });
|
||||
// About where the hands are.
|
||||
sources.push(Source {
|
||||
at: DVec3::new(feet.x, feet.y + 1.2, feet.z),
|
||||
level,
|
||||
});
|
||||
}
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
let level = held(m.extra.main_hand.as_deref())
|
||||
.max(held(m.extra.off_hand.as_deref()))
|
||||
.max(if m.fire_ticks > 0 { BURNING } else { 0 });
|
||||
if level == 0 {
|
||||
continue;
|
||||
}
|
||||
let at = m.previous.lerp(m.position, alpha);
|
||||
sources.push(Source {
|
||||
at: DVec3::new(at.x, at.y + f64::from(m.height()) * 0.6, at.z),
|
||||
level,
|
||||
});
|
||||
}
|
||||
for item in &self.dropped {
|
||||
let level = lum
|
||||
.of(&item.stack.item)
|
||||
.max(if item.fire_ticks > 0 { BURNING } else { 0 });
|
||||
if level == 0 {
|
||||
continue;
|
||||
}
|
||||
let at = item.previous.lerp(item.position, alpha);
|
||||
sources.push(Source {
|
||||
at: DVec3::new(at.x, at.y + 0.25, at.z),
|
||||
level,
|
||||
});
|
||||
}
|
||||
dynamic_light::select(&sources, camera, &mut out);
|
||||
self.dynamic_lights = out;
|
||||
}
|
||||
}
|
||||
@@ -138,47 +138,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);
|
||||
|
||||
@@ -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,10 @@
|
||||
//! the world's options) with [`Integrated::call`], as vanilla's client calls
|
||||
//! into its `IntegratedServer`.
|
||||
//!
|
||||
//! Opt-in for now (`--server-thread` or `HORIZON_SERVER_THREAD=1`): the
|
||||
//! player joins as a network player, and the server side of a network player
|
||||
//! does not do everything single player does yet.
|
||||
//! Opt-in again (`--server-thread` or `HORIZON_SERVER_THREAD=1`): it was the
|
||||
//! default for one day, but a server's players can't board, ride or hit
|
||||
//! boats and minecarts yet (#1222), and single player's window is one of
|
||||
//! them here.
|
||||
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,7 +10,7 @@ use horizon_client::{
|
||||
self, Lan, MAX_PLAYERS, NetConfig,
|
||||
chunks::ChunkEncoder,
|
||||
data::SyncData,
|
||||
entity::EntityState,
|
||||
entity::{EntityState, States},
|
||||
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
playerdata,
|
||||
},
|
||||
@@ -69,6 +69,9 @@ pub(super) struct LanHost {
|
||||
/// 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
|
||||
@@ -136,8 +139,7 @@ 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();
|
||||
fn mob_meta(m: &Mob, hub: &Hub, meta: &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)));
|
||||
@@ -280,14 +282,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 +320,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] = &[
|
||||
@@ -537,6 +540,7 @@ impl Game<'_> {
|
||||
waypoints: Default::default(),
|
||||
waypoint_dead: Default::default(),
|
||||
commands: Vec::new(),
|
||||
states: Default::default(),
|
||||
});
|
||||
self.audio.capture = Some(Vec::new());
|
||||
Ok(())
|
||||
@@ -740,77 +744,44 @@ 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());
|
||||
fn lan_entities(&self, host_entity: Option<i32>, 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,
|
||||
});
|
||||
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];
|
||||
mob_meta(m, hub, &mut e.meta);
|
||||
mob_equipment(m, hub, &mut e.equipment);
|
||||
self.mob_attributes(m, &mut e.attributes);
|
||||
}
|
||||
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 +800,42 @@ 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, &mut host.hub, &mut states);
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
states.clear();
|
||||
}
|
||||
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),
|
||||
@@ -887,7 +876,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 +941,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
host.states = states;
|
||||
}
|
||||
self.lan_flush_effects(&mut host);
|
||||
self.tick_guests(&mut host);
|
||||
@@ -1011,15 +1001,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, &mut host.hub, &mut theirs);
|
||||
theirs.as_slice()
|
||||
} else {
|
||||
entities
|
||||
};
|
||||
|
||||
@@ -49,10 +49,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.
|
||||
@@ -285,10 +293,12 @@ impl Game<'_> {
|
||||
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.
|
||||
@@ -463,7 +473,7 @@ impl Game<'_> {
|
||||
(self.respawn_config, self.level.spawn) = respawn;
|
||||
// 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);
|
||||
@@ -473,14 +483,28 @@ impl Game<'_> {
|
||||
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.sleeping = g.bed.is_some();
|
||||
@@ -532,12 +556,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 +614,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 +686,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 +735,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 +934,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 +976,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 +1001,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 +1196,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 +1279,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| {
|
||||
@@ -1438,6 +1543,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
|
||||
@@ -1639,6 +1746,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;
|
||||
};
|
||||
@@ -1693,6 +1801,13 @@ 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;
|
||||
}
|
||||
let _ = w.tick_environment_damage();
|
||||
let _ = w.apply_fire_contact();
|
||||
let _ = w.server_hazards_tick(in_wall);
|
||||
|
||||
@@ -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};
|
||||
@@ -89,8 +89,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 +116,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 +273,22 @@ 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));
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
@@ -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;
|
||||
@@ -254,7 +255,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 +398,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 +411,8 @@ impl Game<'_> {
|
||||
&& let Some(m) = station.merchant.take()
|
||||
{
|
||||
self.lan_open_merchant(host, conn, *m);
|
||||
return;
|
||||
}
|
||||
self.lan_open_station(host, conn, &station);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
//! Boats and minecarts for a server's players: LAN guests, players on a
|
||||
//! dedicated server, and single player's window on its server thread.
|
||||
//!
|
||||
//! A player aims at a vehicle, item frame, armor stand or painting as at a
|
||||
//! mob (`ServerGamePacketListenerImpl.handleInteract`), and the game's own
|
||||
//! code for it runs as that player (`AbstractBoat.interact` seats it,
|
||||
//! `VehicleEntity.hurtServer` breaks the vehicle). A boat is driven by its
|
||||
//! rider's client (`handleMoveVehicle`, `handlePaddleBoat`); a minecart moves
|
||||
//! here with the rider's input (`ServerPlayer.getLastClientMoveIntent`).
|
||||
//! Sneaking gets out (`Player.rideTick`: `wantsToStopRiding`), and the client
|
||||
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
|
||||
use super::{Game, Keys, boats::SEAT, lan::LanHost};
|
||||
use horizon_client::collisions::SceneCollisions;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_protocol::{
|
||||
packets::play::{self, Clientbound as C},
|
||||
text::translatable,
|
||||
};
|
||||
|
||||
/// `PlayerInput` flags, as the server player's client last sent them.
|
||||
pub(super) fn input_keys(flags: u8) -> Keys {
|
||||
Keys {
|
||||
forward: flags & 1 != 0,
|
||||
backward: flags & 2 != 0,
|
||||
left: flags & 4 != 0,
|
||||
right: flags & 8 != 0,
|
||||
jump: flags & 16 != 0,
|
||||
shift: flags & 32 != 0,
|
||||
sprint: flags & 64 != 0,
|
||||
..Keys::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn corners(b: Aabb) -> ([f64; 3], [f64; 3]) {
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The box of a boat, minecart, armor stand, item frame or painting, and
|
||||
/// the point an interact packet's location is measured from (the
|
||||
/// client's `remote_object_hit` uses the same points).
|
||||
pub(super) fn object_target(&self, id: i32) -> Option<([f64; 3], [f64; 3], [f64; 3])> {
|
||||
let id = u32::try_from(id).ok()?;
|
||||
let at = |v: Vec3| [v.x, v.y, v.z];
|
||||
let centre = |p: [i32; 3]| p.map(|v| f64::from(v) + 0.5);
|
||||
if let Some(b) = self.boats.iter().find(|b| b.id == id) {
|
||||
let (lo, hi) = corners(b.bounds());
|
||||
return Some((lo, hi, at(b.position)));
|
||||
}
|
||||
if let Some(c) = self.carts.iter().find(|c| c.id == id) {
|
||||
let (lo, hi) = corners(c.bounds());
|
||||
return Some((lo, hi, at(c.position)));
|
||||
}
|
||||
if let Some(a) = self.armor_stands.iter().find(|a| a.id == id) {
|
||||
let (lo, hi) = corners(a.bounds());
|
||||
return Some((lo, hi, at(a.position)));
|
||||
}
|
||||
if let Some(f) = self.item_frames.iter().find(|f| f.id == id) {
|
||||
let (lo, hi) = f.bounds();
|
||||
return Some((lo, hi, centre(f.pos)));
|
||||
}
|
||||
let p = self.paintings.iter().find(|p| p.id == id)?;
|
||||
let (lo, hi) = p.bounds();
|
||||
Some((lo, hi, centre(p.pos)))
|
||||
}
|
||||
|
||||
/// The vehicles the server's players ride: they move with their riders,
|
||||
/// not in `tick_boats` and `tick_carts`.
|
||||
pub(super) fn guest_vehicles(&self) -> Vec<u32> {
|
||||
self.lan.as_ref().map_or_else(Vec::new, |h| {
|
||||
h.hub.guests.iter().filter_map(|g| g.vehicle).collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a server player other than the one acting rides `vehicle`
|
||||
/// (`Entity.canAddPassenger`: a boat or minecart seats one player here).
|
||||
pub(super) fn ridden_by_guest(&self, vehicle: u32) -> bool {
|
||||
self.lan.as_ref().is_some_and(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.any(|g| Some(g.conn) != self.lan_acting && g.vehicle == Some(vehicle))
|
||||
})
|
||||
}
|
||||
|
||||
/// `Player.rideTick` for a server player, before its moves this tick:
|
||||
/// it stays in its seat, steers its minecart, or gets out.
|
||||
pub(super) fn guest_vehicle_tick(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let g = &host.hub.guests[gi];
|
||||
let Some(vehicle) = g.vehicle else {
|
||||
return;
|
||||
};
|
||||
let (keys, dead) = (input_keys(g.input), g.dead || g.health <= 0.0);
|
||||
let boat = self.boats.iter().position(|b| b.id == vehicle);
|
||||
let cart = self.carts.iter().any(|c| c.id == vehicle);
|
||||
let mount = self.mobs.iter().any(|m| m.id == vehicle);
|
||||
// The vehicle is gone (broken, in another level) or the rider died:
|
||||
// its client drops it with the entity or the death screen.
|
||||
if dead || (boat.is_none() && !cart && !mount) {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
(g.vehicle, g.vehicle_good) = (None, None);
|
||||
return;
|
||||
}
|
||||
if let Some(i) = boat {
|
||||
// AbstractBoat.tick on the server for a boat its rider drives:
|
||||
// only the hurt animation and the damage wear off here.
|
||||
let b = &mut self.boats[i];
|
||||
b.hurt_time = (b.hurt_time - 1).max(0);
|
||||
b.damage = (b.damage - 1.0).max(0.0);
|
||||
}
|
||||
// Getting out goes through leave_guest, which tells the client and
|
||||
// teleports it (ServerPlayer.dismountTo).
|
||||
let seat = self.as_guest(host, conn, |game| {
|
||||
if let Some(i) = game.boats.iter().position(|b| Some(b.id) == game.riding) {
|
||||
if keys.shift {
|
||||
game.dismount(i);
|
||||
return None;
|
||||
}
|
||||
let p = game.boats[i].position;
|
||||
return Some([p.x, p.y + SEAT, p.z]);
|
||||
}
|
||||
// The minecart moves with the rider's input, and lets it out when
|
||||
// it sneaks or the cart breaks. An animal takes the rider's input
|
||||
// for its own tick (`travelRidden`), or lets it off.
|
||||
if !game.tick_cart_riding(keys) && game.tick_mount_riding(keys) {
|
||||
game.riding?;
|
||||
game.position_rider();
|
||||
}
|
||||
game.riding?;
|
||||
let p = game.player.as_ref()?.position;
|
||||
Some([p.x, p.y, p.z])
|
||||
});
|
||||
if let Some(Some(at)) = seat {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.previous_position = g.position;
|
||||
g.position = at;
|
||||
}
|
||||
}
|
||||
|
||||
/// After an interaction: a player that got into a vehicle sits in it,
|
||||
/// and its last good vehicle position is where the vehicle is.
|
||||
pub(super) fn guest_boarded(&mut self, host: &mut LanHost, conn: u32, before: Option<u32>) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let Some(vehicle) = host.hub.guests[gi].vehicle else {
|
||||
return;
|
||||
};
|
||||
if before == Some(vehicle) {
|
||||
return;
|
||||
}
|
||||
// The host in the window rides it already.
|
||||
if self.riding == Some(vehicle) {
|
||||
host.hub.guests[gi].vehicle = before;
|
||||
return;
|
||||
}
|
||||
let at = self
|
||||
.boats
|
||||
.iter()
|
||||
.find(|b| b.id == vehicle)
|
||||
.map(|b| b.position)
|
||||
.or_else(|| {
|
||||
self.carts
|
||||
.iter()
|
||||
.find(|c| c.id == vehicle)
|
||||
.map(|c| c.position)
|
||||
});
|
||||
if let Some(p) = at {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.vehicle_good = Some([p.x, p.y, p.z]);
|
||||
g.previous_position = g.position;
|
||||
g.position = [p.x, p.y + SEAT, p.z];
|
||||
g.on_ground = false;
|
||||
return;
|
||||
}
|
||||
// An animal: the rider sits where `positionRider` puts it.
|
||||
let seat = self.as_guest(host, conn, |game| {
|
||||
game.position_rider();
|
||||
game.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z])
|
||||
});
|
||||
if let Some(Some(at)) = seat {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.previous_position = g.position;
|
||||
g.position = at;
|
||||
g.on_ground = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.handleMoveVehicle`: the boat its rider
|
||||
/// drives goes where the client says, unless it moved too quickly or
|
||||
/// wrongly; then it stays, and the client is told where it is.
|
||||
pub(super) fn guest_move_vehicle(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
p: &play::MoveVehicleRequest,
|
||||
) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let [x, y, z] = p.position;
|
||||
if !(x.is_finite() && y.is_finite() && z.is_finite())
|
||||
|| !p.yaw.is_finite()
|
||||
|| !p.pitch.is_finite()
|
||||
{
|
||||
host.hub.kick(
|
||||
conn,
|
||||
translatable("multiplayer.disconnect.invalid_vehicle_movement", &[]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let g = &host.hub.guests[gi];
|
||||
if g.awaiting_teleport.is_some() || !g.loaded {
|
||||
return;
|
||||
}
|
||||
let owner = host.hub.singleplayer_owner == Some(g.uuid);
|
||||
let (vehicle, good) = (g.vehicle, g.vehicle_good);
|
||||
let back = |host: &mut LanHost, position: Vec3, yaw: f32| {
|
||||
let packet = play::MoveVehicle {
|
||||
position: [position.x, position.y, position.z],
|
||||
yaw,
|
||||
pitch: 0.0,
|
||||
};
|
||||
host.hub.send_to(conn, &C::MoveVehicle(packet));
|
||||
};
|
||||
// Only a boat is driven by its rider (getControllingPassenger); a
|
||||
// minecart that is elsewhere on the client is put back.
|
||||
let Some(i) = vehicle.and_then(|v| self.boats.iter().position(|b| b.id == v)) else {
|
||||
if let Some(c) = vehicle.and_then(|v| self.carts.iter().find(|c| c.id == v))
|
||||
&& c.position.distance_squared(Vec3::new(x, y, z)) > 1.0
|
||||
{
|
||||
back(host, c.position, c.yaw);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let b = &self.boats[i];
|
||||
let old = b.position;
|
||||
let good = good.map_or(old, |g| Vec3::new(g[0], g[1], g[2]));
|
||||
let target = Vec3::new(
|
||||
x.clamp(-3.0E7, 3.0E7),
|
||||
y.clamp(-2.0E7, 2.0E7),
|
||||
z.clamp(-3.0E7, 3.0E7),
|
||||
);
|
||||
// Moved too quickly: more than 10 blocks past its own motion.
|
||||
let moved = target.distance_squared(old) - b.velocity.length_squared();
|
||||
if moved > 100.0 && !owner {
|
||||
log::warn!("boat {} moved too quickly! {moved}", b.id);
|
||||
back(host, old, b.yaw);
|
||||
return;
|
||||
}
|
||||
// vehicle.move(PLAYER, target - lastGood) with the boat's collisions:
|
||||
// where it can't follow the client (a quarter block off, the height
|
||||
// aside), it moved wrongly.
|
||||
let bounds = b.bounds();
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let wanted = target.subtract(good);
|
||||
let resolved = scene
|
||||
.resolve(bounds, wanted, 0.0, b.on_ground)
|
||||
.unwrap_or(wanted);
|
||||
let reached = old.add_vector(resolved);
|
||||
let wrongly = (target.x - reached.x).powi(2) + (target.z - reached.z).powi(2) > 0.0625;
|
||||
let free_before = scene.unobstructed(bounds).unwrap_or(true);
|
||||
// isEntityCollidingWithAnythingNew: a box that was free is not there.
|
||||
let moved_box = bounds.move_by(target.subtract(old));
|
||||
let free_after = scene
|
||||
.unobstructed(moved_box.deflate(Vec3::new(1.0E-5, 1.0E-5, 1.0E-5)))
|
||||
.unwrap_or(true);
|
||||
if free_before && (wrongly || !free_after) {
|
||||
log::warn!("boat {} moved wrongly", b.id);
|
||||
back(host, old, b.yaw);
|
||||
return;
|
||||
}
|
||||
let b = &mut self.boats[i];
|
||||
b.previous = old;
|
||||
b.velocity = target.subtract(old);
|
||||
b.position = target;
|
||||
b.previous_yaw = b.yaw;
|
||||
b.yaw = p.yaw;
|
||||
b.on_ground = p.on_ground;
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.vehicle_good = Some([target.x, target.y, target.z]);
|
||||
g.previous_position = g.position;
|
||||
g.position = [target.x, target.y + SEAT, target.z];
|
||||
}
|
||||
|
||||
/// `handlePaddleBoat`: the paddles of the boat the player drives.
|
||||
pub(super) fn guest_paddle_boat(&mut self, host: &LanHost, conn: u32, paddles: [bool; 2]) {
|
||||
let vehicle = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == conn)
|
||||
.and_then(|g| g.vehicle);
|
||||
if let Some(b) = self.boats.iter_mut().find(|b| Some(b.id) == vehicle) {
|
||||
b.paddling = paddles;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,15 +312,10 @@ impl Game<'_> {
|
||||
.map_or_else(String::new, |(end, start)| {
|
||||
format!(", cpu {:.1} ms", (end - start).as_secs_f32() * 1000.0)
|
||||
});
|
||||
let list: Vec<String> = spans
|
||||
.iter()
|
||||
.filter(|(_, ms)| *ms >= 0.5)
|
||||
.map(|(n, ms)| format!("{n} {ms:.1}"))
|
||||
.collect();
|
||||
log::info!(
|
||||
"Horizon: slow tick {wall:.1} ms{cpu} ({} mobs): {}",
|
||||
self.mobs.len(),
|
||||
list.join(", ")
|
||||
horizon_client::spans::describe(&spans)
|
||||
);
|
||||
}
|
||||
result
|
||||
|
||||
@@ -5,17 +5,13 @@
|
||||
//! witches, mooshroom colours, turtles, burnt items), lightning rods, the sky
|
||||
//! flash, the sounds and the branching additive bolt.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
mesher::Vertex,
|
||||
mobs::{Damage, DamageKind, MobEvent, SpawnSetup, Species},
|
||||
};
|
||||
use horizon_core::{
|
||||
position::BlockPos,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_client::mobs::{Damage, DamageKind, MobEvent, SpawnSetup, Species};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
|
||||
pub(super) struct Bolt {
|
||||
at: [f64; 3],
|
||||
/// The entity id a server's players know it by (0 until it has one).
|
||||
pub id: u32,
|
||||
pub at: [f64; 3],
|
||||
life: i32,
|
||||
flashes: i32,
|
||||
seed: i64,
|
||||
@@ -137,7 +133,9 @@ impl Game<'_> {
|
||||
let r = &mut self.world.random;
|
||||
let seed = r.next_long();
|
||||
let flashes = r.next_int_bound(3).unwrap_or(0) + 1;
|
||||
self.lightning_seen[0] += 1;
|
||||
self.bolts.push(Bolt {
|
||||
id: 0,
|
||||
at,
|
||||
life: 2,
|
||||
flashes,
|
||||
@@ -146,6 +144,24 @@ impl Game<'_> {
|
||||
converted: Vec::new(),
|
||||
});
|
||||
}
|
||||
/// `ClientLevel.addEntity` for a server's lightning bolt: on a client it
|
||||
/// only flashes, sounds and draws (`LightningBolt.tick` off the server).
|
||||
pub(super) fn spawn_remote_bolt(&mut self, id: u32, at: [f64; 3]) {
|
||||
self.bolts.retain(|b| b.id != id);
|
||||
let r = &mut self.world.random;
|
||||
let seed = r.next_long();
|
||||
let flashes = r.next_int_bound(3).unwrap_or(0) + 1;
|
||||
self.lightning_seen[0] += 1;
|
||||
self.bolts.push(Bolt {
|
||||
id,
|
||||
at,
|
||||
life: 2,
|
||||
flashes,
|
||||
seed,
|
||||
visual_only: true,
|
||||
converted: Vec::new(),
|
||||
});
|
||||
}
|
||||
/// `spawnFire`: fire at the strike point and `extra` random neighbours.
|
||||
fn bolt_fire(&mut self, at: [f64; 3], extra: i32) {
|
||||
if self.ui.difficulty < 2 {
|
||||
@@ -222,6 +238,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if b.life >= 0 {
|
||||
if self.flash == 0 {
|
||||
self.lightning_seen[1] += 1;
|
||||
}
|
||||
self.flash = 2;
|
||||
if !b.visual_only {
|
||||
self.thunder_hit(b);
|
||||
@@ -349,71 +368,14 @@ impl Game<'_> {
|
||||
|| e.stack.item.contains("netherite")
|
||||
});
|
||||
}
|
||||
/// `LightningBoltRenderer`: 4 layers of a trunk and two branches, 8
|
||||
/// segments of 16 blocks, from the bolt's seed.
|
||||
pub(super) fn bolt_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
|
||||
let white = self.gui.atlas.font.white();
|
||||
let uv = [white.u0, white.v0];
|
||||
// ARGB 0.45, 0.45, 0.5 at 0.3 alpha, packed RGBA.
|
||||
let color = (76u32 << 24) | (127 << 16) | (114 << 8) | 114;
|
||||
for b in &self.bolts {
|
||||
let o = (glam::DVec3::from_array(b.at) - camera).as_vec3();
|
||||
let mut random = LegacyRandom::new(b.seed);
|
||||
let (mut xs, mut zs) = ([0f32; 8], [0f32; 8]);
|
||||
let (mut x_off, mut z_off) = (0f32, 0f32);
|
||||
for i in (0..8).rev() {
|
||||
xs[i] = x_off;
|
||||
zs[i] = z_off;
|
||||
x_off += (random.next_int_bound(11).unwrap_or(5) - 5) as f32;
|
||||
z_off += (random.next_int_bound(11).unwrap_or(5) - 5) as f32;
|
||||
}
|
||||
for layer in 0..4 {
|
||||
let mut random = LegacyRandom::new(b.seed);
|
||||
for branch in 0..3 {
|
||||
let trunk = branch == 0;
|
||||
let start = 7 - branch;
|
||||
let end = if trunk { 0 } else { start - 3 + 1 };
|
||||
let mut sx = xs[start] - x_off;
|
||||
let mut sz = zs[start] - z_off;
|
||||
for segment in (end..=start).rev() {
|
||||
let (ex, ez) = (sx, sz);
|
||||
let spread = if trunk { 11 } else { 31 };
|
||||
sx += (random.next_int_bound(spread).unwrap_or(0) - spread / 2) as f32;
|
||||
sz += (random.next_int_bound(spread).unwrap_or(0) - spread / 2) as f32;
|
||||
let mut top = 0.1 + layer as f32 * 0.2;
|
||||
let mut bottom = top;
|
||||
if trunk {
|
||||
top *= segment as f32 * 0.1 + 1.0;
|
||||
bottom *= (segment as f32 - 1.0) * 0.1 + 1.0;
|
||||
}
|
||||
for (rx, rz, lx, lz) in [
|
||||
(false, false, true, false),
|
||||
(true, false, true, true),
|
||||
(true, true, false, true),
|
||||
(false, true, false, false),
|
||||
] {
|
||||
let s = |p: bool, r: f32| if p { r } else { -r };
|
||||
let y0 = segment as f32 * 16.0;
|
||||
let corners = [
|
||||
[sx + s(rx, bottom), y0, sz + s(rz, bottom)],
|
||||
[ex + s(rx, top), y0 + 16.0, ez + s(rz, top)],
|
||||
[ex + s(lx, top), y0 + 16.0, ez + s(lz, top)],
|
||||
[sx + s(lx, bottom), y0, sz + s(lz, bottom)],
|
||||
];
|
||||
for c in corners {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: (o + glam::Vec3::from_array(c)).to_array(),
|
||||
uv,
|
||||
layer: 2,
|
||||
color,
|
||||
light: 240 | 240 << 8,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Simulation-order render inputs; seeded geometry lives in the renderer.
|
||||
pub(super) fn bolt_instances(&self) -> Vec<horizon_client::gfx::lightning::Bolt> {
|
||||
self.bolts
|
||||
.iter()
|
||||
.map(|b| horizon_client::gfx::lightning::Bolt {
|
||||
at: b.at,
|
||||
seed: b.seed,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,8 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
if tamed && sneaking {
|
||||
// The horse inventory screen is not available yet.
|
||||
// AbstractHorse.openCustomInventoryScreen.
|
||||
self.open_mount_menu(index);
|
||||
return true;
|
||||
}
|
||||
let m = &self.mobs[index];
|
||||
|
||||
@@ -68,8 +68,9 @@ impl Game<'_> {
|
||||
}
|
||||
l.last_inputs = inputs;
|
||||
}
|
||||
/// A sound the loom menu played: the result's once per tick.
|
||||
pub(super) fn loom_sound(&mut self, sound: &str) {
|
||||
/// A sound the loom or stonecutter menu played: the result's once per
|
||||
/// tick (`lastSoundTime`).
|
||||
pub(super) fn station_sound(&mut self, sound: &str) {
|
||||
let ticks = self.ticks;
|
||||
let Some(s) = self.station.as_mut() else {
|
||||
return;
|
||||
@@ -98,6 +99,7 @@ impl Game<'_> {
|
||||
if taken {
|
||||
self.audio
|
||||
.play("minecraft:ui.loom.select_pattern", None, 1.0, 1.0);
|
||||
self.remote_station_button(index as i32);
|
||||
}
|
||||
taken
|
||||
}
|
||||
|
||||
@@ -327,6 +327,46 @@ impl Containers<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The block columns any item entity's box reaches, so that a hopper with
|
||||
/// no item over it skips the scan of every item. Kept for one block-entity
|
||||
/// pass, in which items do not move: removing one leaves a superset, still
|
||||
/// right, and adding one (always pushed at the end, under a new id) or
|
||||
/// removing the last rebuilds the columns.
|
||||
#[derive(Default)]
|
||||
pub(super) struct ItemColumns {
|
||||
/// The item count and last item id when last asked, if built.
|
||||
seen: Option<(usize, Option<u32>)>,
|
||||
cells: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
|
||||
}
|
||||
impl ItemColumns {
|
||||
/// Whether an item's box may reach into column (x, z).
|
||||
fn may_reach(
|
||||
&mut self,
|
||||
items: &[horizon_client::entities::ItemEntity],
|
||||
x: i32,
|
||||
z: i32,
|
||||
) -> bool {
|
||||
let (len, last) = (items.len(), items.last().map(|i| i.id));
|
||||
let stale = self
|
||||
.seen
|
||||
.is_none_or(|(seen_len, seen_last)| len > seen_len || last != seen_last);
|
||||
self.seen = Some((len, last));
|
||||
if stale {
|
||||
self.cells.clear();
|
||||
for item in items {
|
||||
let b = item.bounds();
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
for cx in lo.x.floor() as i32..=hi.x.floor() as i32 {
|
||||
for cz in lo.z.floor() as i32..=hi.z.floor() as i32 {
|
||||
self.cells.insert((cx, cz));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.cells.contains(&(x, z))
|
||||
}
|
||||
}
|
||||
|
||||
fn bone_meal() -> ItemStack {
|
||||
ItemStack {
|
||||
item: "minecraft:bone_meal".into(),
|
||||
@@ -648,6 +688,11 @@ impl Game<'_> {
|
||||
f64::from(pos.2) + 1.0,
|
||||
],
|
||||
);
|
||||
if let Some(columns) = self.item_columns.as_mut()
|
||||
&& !columns.may_reach(&self.dropped, pos.0, pos.2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < self.dropped.len() {
|
||||
let b = self.dropped[i].bounds();
|
||||
@@ -880,3 +925,122 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod item_column_tests {
|
||||
use super::ItemColumns;
|
||||
use horizon_client::{entities::ItemEntity, items::ItemStack};
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
fn item(id: u32, x: f64, y: f64, z: f64) -> ItemEntity {
|
||||
let at = Vec3::new(x, y, z);
|
||||
ItemEntity {
|
||||
health: 5,
|
||||
fire_ticks: 0,
|
||||
id,
|
||||
stack: ItemStack::new("minecraft:stone", 1),
|
||||
thrower: None,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: true,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
ticks: 0,
|
||||
}
|
||||
}
|
||||
/// `hopper_suck`'s test: some item box meets the hopper's SUCK_AABB.
|
||||
fn scan(items: &[ItemEntity], (x, y, z): (i32, i32, i32)) -> bool {
|
||||
let (min, max) = (
|
||||
Vec3::new(f64::from(x), f64::from(y) + 11.0 / 16.0, f64::from(z)),
|
||||
Vec3::new(f64::from(x) + 1.0, f64::from(y) + 2.0, f64::from(z) + 1.0),
|
||||
);
|
||||
items.iter().any(|i| {
|
||||
let b = i.bounds();
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
lo.x < max.x
|
||||
&& hi.x > min.x
|
||||
&& lo.y < max.y
|
||||
&& hi.y > min.y
|
||||
&& lo.z < max.z
|
||||
&& hi.z > min.z
|
||||
})
|
||||
}
|
||||
|
||||
/// One tick of empty hoppers looking for items: each scans every item
|
||||
/// (before) or asks the columns first (after, build included).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn hopper_suck_cost() {
|
||||
for (hoppers, count) in [(500i32, 200u32), (2000, 500), (2000, 2000)] {
|
||||
let mut seed = 99u64;
|
||||
let mut next = |range: f64| {
|
||||
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64 * range
|
||||
};
|
||||
let items: Vec<ItemEntity> = (0..count)
|
||||
.map(|id| item(id, next(160.0), 64.0, next(160.0)))
|
||||
.collect();
|
||||
let at: Vec<(i32, i32, i32)> = (0..hoppers)
|
||||
.map(|_| (next(160.0) as i32, 63, next(160.0) as i32))
|
||||
.collect();
|
||||
let t = std::time::Instant::now();
|
||||
let a: Vec<bool> = at.iter().map(|&h| scan(&items, h)).collect();
|
||||
let before = t.elapsed().as_secs_f64() * 1e3;
|
||||
let t = std::time::Instant::now();
|
||||
let mut columns = ItemColumns::default();
|
||||
let b: Vec<bool> = at
|
||||
.iter()
|
||||
.map(|&h| columns.may_reach(&items, h.0, h.2) && scan(&items, h))
|
||||
.collect();
|
||||
let after = t.elapsed().as_secs_f64() * 1e3;
|
||||
assert_eq!(a, b);
|
||||
println!("{hoppers} hoppers, {count} items: {before:.3} ms -> {after:.3} ms");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_hopper_with_an_item_over_it_is_skipped() {
|
||||
let mut seed = 0x1234_5678_9abc_def1u64;
|
||||
let mut next = |range: f64| {
|
||||
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64 * range
|
||||
};
|
||||
let mut items: Vec<ItemEntity> = Vec::new();
|
||||
let mut columns = ItemColumns::default();
|
||||
let mut next_id = 0;
|
||||
let mut skipped = 0;
|
||||
for round in 0..3000 {
|
||||
// Items come and go as hoppers take them and crafters drop them.
|
||||
match round % 5 {
|
||||
0 | 1 => {
|
||||
next_id += 1;
|
||||
// On edges too: a quarter of them sit on a block line.
|
||||
let snap = |v: f64, edge: bool| if edge { v.floor() + 0.125 } else { v };
|
||||
let edge = round % 4 == 0;
|
||||
items.push(item(
|
||||
next_id,
|
||||
snap(next(24.0) - 12.0, edge),
|
||||
64.0,
|
||||
snap(next(24.0) - 12.0, edge),
|
||||
));
|
||||
}
|
||||
2 if !items.is_empty() => {
|
||||
let i = next(items.len() as f64) as usize;
|
||||
items.remove(i);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let hopper = (next(26.0) as i32 - 13, 63, next(26.0) as i32 - 13);
|
||||
let reach = columns.may_reach(&items, hopper.0, hopper.2);
|
||||
let found = scan(&items, hopper);
|
||||
assert!(
|
||||
reach || !found,
|
||||
"round {round}: skipped a hopper with an item over it"
|
||||
);
|
||||
skipped += usize::from(!reach);
|
||||
}
|
||||
assert!(skipped > 1000, "skipped only {skipped}");
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use horizon_client::{
|
||||
containers::Crafter,
|
||||
hud::{HudBatch, rgba},
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
use serde_json::Value;
|
||||
|
||||
/// `CrafterBlock`'s crafting animation length.
|
||||
@@ -323,6 +324,15 @@ impl Game<'_> {
|
||||
menu.set_slot_state(i, enabled);
|
||||
Some(true)
|
||||
});
|
||||
if let Some((container, _)) = self.remote.as_ref().and_then(|r| r.menu.clone()) {
|
||||
self.send(S::ContainerSlotStateChanged(
|
||||
play::ContainerSlotStateChanged {
|
||||
slot: i as i32,
|
||||
container,
|
||||
enabled,
|
||||
},
|
||||
));
|
||||
}
|
||||
let pitch = if enabled { 1.0 } else { 0.75 };
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.4, pitch);
|
||||
|
||||
@@ -275,6 +275,37 @@ impl Game<'_> {
|
||||
}
|
||||
self.maps.data.get_mut(&id)
|
||||
}
|
||||
/// `ClientboundMapItemDataPacket.handle`: the server's colours for a map
|
||||
/// this client does not own (lobby map art, a framed map on a wall).
|
||||
pub(super) fn remote_map_item_data(
|
||||
&mut self,
|
||||
p: &horizon_protocol::packets::play::MapItemData,
|
||||
) {
|
||||
let dimension = self.dimension_id();
|
||||
let map = self
|
||||
.maps
|
||||
.data
|
||||
.entry(p.map_id)
|
||||
.or_insert_with(|| MapData::fresh(0.0, 0.0, p.scale.max(0) as u8, dimension));
|
||||
map.locked = p.locked;
|
||||
let Some(patch) = &p.patch else {
|
||||
map.dirty = false;
|
||||
return;
|
||||
};
|
||||
for x in 0..patch.width {
|
||||
for y in 0..patch.height {
|
||||
let Some(&color) = patch.colors.get((x + y * patch.width) as usize) else {
|
||||
continue;
|
||||
};
|
||||
let (px, py) = (patch.start_x + x, patch.start_y + y);
|
||||
if (0..128).contains(&px) && (0..128).contains(&py) {
|
||||
map.update_color(px, py, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The server owns a remote map: only the texture has to change.
|
||||
map.dirty = false;
|
||||
}
|
||||
fn next_map_id(&mut self) -> i32 {
|
||||
let last = *self.maps.last_id.get_or_insert_with(|| {
|
||||
horizon_client::save::read_dat(&self.world_root.join("data/minecraft/maps/last_id.dat"))
|
||||
|
||||
@@ -775,10 +775,13 @@ impl Game<'_> {
|
||||
self.push_carts();
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
let ticking = self.entity_ticking_area();
|
||||
// A server player's minecart moves with its rider (lan_vehicles.rs).
|
||||
let ridden = self.guest_vehicles();
|
||||
let mut i = 0;
|
||||
while i < self.carts.len() {
|
||||
let p = self.carts[i].position;
|
||||
if Some(self.carts[i].id) == self.riding
|
||||
|| ridden.contains(&self.carts[i].id)
|
||||
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
|
||||
{
|
||||
i += 1;
|
||||
@@ -986,7 +989,7 @@ impl Game<'_> {
|
||||
let held = self.held().cloned();
|
||||
match self.carts[i].kind {
|
||||
CartKind::Rideable => {
|
||||
if sneaking || self.riding.is_some() {
|
||||
if sneaking || self.riding.is_some() || self.ridden_by_guest(self.carts[i].id) {
|
||||
return false;
|
||||
}
|
||||
self.riding = Some(self.carts[i].id);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
//! The horse inventory (`HorseInventoryMenu`, `HorseInventoryScreen`): the
|
||||
//! saddle and body armour slots, the 3x5 chest of donkeys and mules and the
|
||||
//! mount drawn following the cursor. It opens with E while riding a tamed
|
||||
//! mount or with shift + use on a tamed one.
|
||||
//! saddle and body armour slots (a llama's carpet), the chest of donkeys,
|
||||
//! mules and llamas and the mount drawn following the cursor. It opens with
|
||||
//! E while riding a tamed mount or with shift + use on a tamed one. On a
|
||||
//! server the screen shows the server's menu (`ClientboundMountScreenOpenPacket`)
|
||||
//! and its clicks go out as container clicks; a server's players get theirs
|
||||
//! from game/lan_mount_menu.rs.
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
hud::{HudBatch, rgba},
|
||||
items::ItemStack,
|
||||
mobs::{Mob, Species, equine},
|
||||
mobs::{Mob, Species, equine, llama},
|
||||
};
|
||||
|
||||
/// Menu slot ids: the saddle, the body armour and the chest from 710.
|
||||
@@ -17,11 +20,43 @@ pub(super) const CHEST: usize = 710;
|
||||
const CHEST_SIZE: usize = 15;
|
||||
const COLUMNS: usize = 5;
|
||||
|
||||
/// `AbstractHorse.getInventoryColumns`: five for a donkey's or mule's chest,
|
||||
/// a llama's strength for its chest, else none.
|
||||
pub(super) fn mount_columns(m: &Mob) -> usize {
|
||||
let species = m.species();
|
||||
if llama::is_llama(species) {
|
||||
llama::inventory_columns(m).clamp(0, 5) as usize
|
||||
} else if m.extra.chested && matches!(species, Species::Donkey | Species::Mule) {
|
||||
COLUMNS
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
/// Whether the mount has a horse inventory (`AbstractHorse`).
|
||||
pub(super) fn has_mount_menu(species: Species) -> bool {
|
||||
equine::is_equine(species) || llama::is_llama(species)
|
||||
}
|
||||
/// The saddle slot is active (`canUseSlot(SADDLE)` and `CAN_EQUIP_SADDLE`):
|
||||
/// not on llamas.
|
||||
fn shows_saddle(species: Species) -> bool {
|
||||
!llama::is_llama(species)
|
||||
}
|
||||
/// The body slot is active: horse armour, or a llama's carpet.
|
||||
fn shows_body(species: Species) -> bool {
|
||||
can_wear_horse_armor(species) || llama::is_llama(species)
|
||||
}
|
||||
|
||||
/// The open mount menu: copies of the mount's slots, written back on every change.
|
||||
#[derive(Default)]
|
||||
pub(super) struct MountMenu {
|
||||
open: Option<Open>,
|
||||
}
|
||||
impl MountMenu {
|
||||
/// The mount whose menu is open.
|
||||
pub(super) fn opened_mob(&self) -> Option<u32> {
|
||||
self.open.as_ref().map(|o| o.mob)
|
||||
}
|
||||
}
|
||||
struct Open {
|
||||
mob: u32,
|
||||
species: Species,
|
||||
@@ -40,6 +75,9 @@ fn can_wear_horse_armor(species: Species) -> bool {
|
||||
}
|
||||
/// Whether `item` fits the body slot of the species (`isEquippableInSlot`).
|
||||
fn body_armor_fits(species: Species, item: &str) -> bool {
|
||||
if llama::is_llama(species) {
|
||||
return item.ends_with("_carpet");
|
||||
}
|
||||
can_wear_horse_armor(species) && item.ends_with("_horse_armor")
|
||||
}
|
||||
|
||||
@@ -55,18 +93,16 @@ impl Game<'_> {
|
||||
pub(super) fn open_mount_menu(&mut self, index: usize) {
|
||||
let m = &self.mobs[index];
|
||||
let species = m.species();
|
||||
if !equine::is_equine(species) {
|
||||
if !has_mount_menu(species) {
|
||||
return;
|
||||
}
|
||||
let chested = m.extra.chested && matches!(species, Species::Donkey | Species::Mule);
|
||||
let columns = if chested { COLUMNS } else { 0 };
|
||||
let mut items = m.extra.items.clone();
|
||||
items.resize(CHEST_SIZE, None);
|
||||
let show_armor = can_wear_horse_armor(species);
|
||||
let show_armor = shows_body(species);
|
||||
self.mount_menu.open = Some(Open {
|
||||
mob: m.id,
|
||||
species,
|
||||
columns,
|
||||
columns: mount_columns(m),
|
||||
saddle: m.saddle_item(),
|
||||
armor: m
|
||||
.extra
|
||||
@@ -75,10 +111,40 @@ impl Game<'_> {
|
||||
.filter(|_| show_armor)
|
||||
.map(|i| ItemStack::new(i, 1)),
|
||||
items,
|
||||
show_saddle: true,
|
||||
show_saddle: shows_saddle(species),
|
||||
show_armor,
|
||||
});
|
||||
self.carried = None;
|
||||
self.show_mount_menu();
|
||||
}
|
||||
/// `ClientPacketListener.handleMountScreenOpen`: a server's horse
|
||||
/// inventory over mount `entity`, its slots to come in the menu's content.
|
||||
pub(super) fn open_remote_mount_menu(&mut self, entity: u32, columns: usize) -> bool {
|
||||
let Some(species) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == entity)
|
||||
.map(Mob::species)
|
||||
.filter(|s| has_mount_menu(*s))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
self.mount_menu.open = Some(Open {
|
||||
mob: entity,
|
||||
species,
|
||||
columns: columns.min(COLUMNS),
|
||||
saddle: None,
|
||||
armor: None,
|
||||
items: vec![None; CHEST_SIZE],
|
||||
show_saddle: shows_saddle(species),
|
||||
show_armor: shows_body(species),
|
||||
});
|
||||
self.show_mount_menu();
|
||||
true
|
||||
}
|
||||
fn show_mount_menu(&mut self) {
|
||||
if self.remote.is_none() && self.lan_acting.is_none() {
|
||||
self.carried = None;
|
||||
}
|
||||
self.station = None;
|
||||
self.container = None;
|
||||
self.craft_size = 2;
|
||||
@@ -94,15 +160,39 @@ impl Game<'_> {
|
||||
return false;
|
||||
};
|
||||
let m = &self.mobs[index];
|
||||
if !equine::is_equine(m.species()) || !m.extra.tame || !m.alive() {
|
||||
if !has_mount_menu(m.species()) || !m.extra.tame || !m.alive() {
|
||||
return false;
|
||||
}
|
||||
self.open_mount_menu(index);
|
||||
true
|
||||
}
|
||||
/// Riding a mount that has a horse inventory (`HasCustomInventoryScreen`).
|
||||
pub(super) fn riding_mount_with_menu(&self) -> bool {
|
||||
self.riding
|
||||
.and_then(|id| self.mount_mob_index(id))
|
||||
.is_some_and(|i| has_mount_menu(self.mobs[i].species()))
|
||||
}
|
||||
/// A server closed its window: the screen's copy goes.
|
||||
pub(super) fn mount_menu_reset(&mut self) {
|
||||
self.mount_menu.open = None;
|
||||
}
|
||||
/// The entity type of the open mount menu's mount (`minecraft:horse`).
|
||||
pub(super) fn mount_menu_entity(&self) -> Option<String> {
|
||||
let open = self.mount_menu.open.as_ref()?;
|
||||
let m = self.mobs.iter().find(|m| m.id == open.mob)?;
|
||||
Some(m.kind.id.to_string())
|
||||
}
|
||||
/// The chest columns of the open mount menu (the server's window's).
|
||||
pub(super) fn mount_menu_columns(&self) -> Option<usize> {
|
||||
self.mount_menu.open.as_ref().map(|o| o.columns)
|
||||
}
|
||||
/// The menu ends when the mount dies or is more than 4 blocks away
|
||||
/// (`AbstractMountInventoryMenu.stillValid`).
|
||||
pub(super) fn tick_mount_menu(&mut self) {
|
||||
// A server's menu ends when the server closes it.
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(open) = self.mount_menu.open.as_ref() else {
|
||||
return;
|
||||
};
|
||||
@@ -126,8 +216,12 @@ impl Game<'_> {
|
||||
self.mount_sync();
|
||||
self.mount_menu.open = None;
|
||||
}
|
||||
/// Writes the menu slots to the mount, with the equip sounds.
|
||||
/// Writes the menu slots to the mount, with the equip sounds. A
|
||||
/// server's menu is the server's to write.
|
||||
fn mount_sync(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(open) = self.mount_menu.open.as_ref() else {
|
||||
return;
|
||||
};
|
||||
@@ -135,25 +229,66 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let (saddle, armor) = (open.saddle.clone(), open.armor.clone());
|
||||
let body = open.show_armor.then_some(armor);
|
||||
let items = open.items.clone();
|
||||
let m = &mut self.mobs[index];
|
||||
self.mount_store(index, saddle, body, items);
|
||||
}
|
||||
/// The slots of mount `index` as its menu shows them: the saddle, the
|
||||
/// body item (None when the slot isn't active), the chest.
|
||||
pub(super) fn mount_contents(
|
||||
&self,
|
||||
index: usize,
|
||||
) -> (
|
||||
Option<ItemStack>,
|
||||
Option<Option<ItemStack>>,
|
||||
Vec<Option<ItemStack>>,
|
||||
) {
|
||||
let m = &self.mobs[index];
|
||||
let species = m.species();
|
||||
let body =
|
||||
shows_body(species).then(|| m.extra.head.as_deref().map(|i| ItemStack::new(i, 1)));
|
||||
let mut items = m.extra.items.clone();
|
||||
items.resize(mount_columns(m) * 3, None);
|
||||
(m.saddle_item(), body, items)
|
||||
}
|
||||
/// The menu's slots back on mount `index` (`body` None: not its slot),
|
||||
/// with the equip sound of what was put on (`Equippable.equipSound`).
|
||||
pub(super) fn mount_store(
|
||||
&mut self,
|
||||
index: usize,
|
||||
saddle: Option<ItemStack>,
|
||||
body: Option<Option<ItemStack>>,
|
||||
items: Vec<Option<ItemStack>>,
|
||||
) {
|
||||
let m = &self.mobs[index];
|
||||
let at = m.center();
|
||||
let sound = if saddle.is_some() && !m.extra.saddled {
|
||||
Some("minecraft:entity.horse.saddle")
|
||||
} else if armor.is_some() && m.extra.head.is_none() {
|
||||
Some("minecraft:entity.horse.armor")
|
||||
let put_on = if saddle.is_some() && !m.extra.saddled {
|
||||
saddle.as_ref()
|
||||
} else {
|
||||
None
|
||||
body.as_ref()
|
||||
.and_then(Option::as_ref)
|
||||
.filter(|_| m.extra.head.is_none())
|
||||
};
|
||||
let sound = put_on.map(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.get(&s.item)
|
||||
.and_then(|i| i.equip_sound.clone())
|
||||
.unwrap_or_else(|| "minecraft:item.armor.equip_generic".into())
|
||||
});
|
||||
let m = &mut self.mobs[index];
|
||||
m.set_saddle(saddle);
|
||||
if open.show_armor {
|
||||
m.extra.head = armor.map(|a| a.item);
|
||||
if let Some(body) = body {
|
||||
m.extra.head = body.map(|a| a.item);
|
||||
}
|
||||
let mut items = items;
|
||||
let keep = m.extra.items.len().max(CHEST_SIZE);
|
||||
items.resize(keep, None);
|
||||
m.extra.items = items;
|
||||
let (x, z) = (at[0].floor() as i32 >> 4, at[2].floor() as i32 >> 4);
|
||||
self.world.modified.insert((x, z));
|
||||
if let Some(sound) = sound {
|
||||
self.audio.play(sound, Some(at), 0.5, 1.0);
|
||||
self.audio.play(&sound, Some(at), 0.5, 1.0);
|
||||
}
|
||||
}
|
||||
pub(super) fn mount_slots(&self) -> Option<Vec<usize>> {
|
||||
@@ -214,7 +349,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// A click in the mount menu before the generic handling.
|
||||
pub(super) fn mount_click(&mut self, right: bool) -> bool {
|
||||
if !self.mount_active() {
|
||||
// A server's menu: the click goes out like any container click.
|
||||
if !self.mount_active() || self.remote.is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(r) = self.renderer.as_ref() else {
|
||||
@@ -314,12 +450,12 @@ impl Game<'_> {
|
||||
if open.show_armor {
|
||||
batch.sprite(atlas, "gui/sprites/container/slot", ox + 7.0, oy + 35.0);
|
||||
if open.armor.is_none() {
|
||||
batch.sprite(
|
||||
atlas,
|
||||
"gui/sprites/container/slot/horse_armor",
|
||||
ox + 8.0,
|
||||
oy + 36.0,
|
||||
);
|
||||
let sprite = if llama::is_llama(open.species) {
|
||||
"gui/sprites/container/slot/llama_armor"
|
||||
} else {
|
||||
"gui/sprites/container/slot/horse_armor"
|
||||
};
|
||||
batch.sprite(atlas, sprite, ox + 8.0, oy + 36.0);
|
||||
}
|
||||
}
|
||||
let grey = rgba(64, 64, 64, 255);
|
||||
@@ -448,4 +584,16 @@ mod tests {
|
||||
"minecraft:iron_chestplate"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llamas_wear_carpets_and_no_saddle() {
|
||||
assert!(body_armor_fits(Species::Llama, "minecraft:red_carpet"));
|
||||
assert!(!body_armor_fits(
|
||||
Species::Llama,
|
||||
"minecraft:iron_horse_armor"
|
||||
));
|
||||
assert!(shows_body(Species::TraderLlama));
|
||||
assert!(!shows_saddle(Species::Llama));
|
||||
assert!(shows_saddle(Species::Donkey) && !shows_body(Species::Donkey));
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,10 @@ impl Game<'_> {
|
||||
let held = self.inventory[self.selected].clone();
|
||||
let m = &self.mobs[index];
|
||||
let species = m.species();
|
||||
// A server player rides it already (`isVehicle`: no seat, no feeding).
|
||||
if self.ridden_by_guest(m.id) {
|
||||
return false;
|
||||
}
|
||||
if matches!(species, Species::Pig | Species::Strider) {
|
||||
return self.interact_steerable(index, item);
|
||||
}
|
||||
@@ -246,24 +250,9 @@ impl Game<'_> {
|
||||
self.consume_held();
|
||||
true
|
||||
}
|
||||
/// The rider's input for this tick; returns false when not on a mount.
|
||||
pub(super) fn tick_mount_riding(&mut self, keys: Keys) -> bool {
|
||||
let Some(i) = self.mount_index() else {
|
||||
return false;
|
||||
};
|
||||
if keys.shift || !self.mobs[i].alive() {
|
||||
self.dismount_mount();
|
||||
return true;
|
||||
}
|
||||
let strafe = f32::from(u8::from(keys.left)) - f32::from(u8::from(keys.right));
|
||||
let forward = f32::from(u8::from(keys.forward)) - f32::from(u8::from(keys.backward));
|
||||
// LocalPlayer.aiStep: the riding jump charges while space is held.
|
||||
let held = self.held().map(|s| s.item.clone());
|
||||
let steer = match self.mobs[i].species() {
|
||||
Species::Pig => held.as_deref() == Some("minecraft:carrot_on_a_stick"),
|
||||
Species::Strider => held.as_deref() == Some("minecraft:warped_fungus_on_a_stick"),
|
||||
_ => false,
|
||||
};
|
||||
/// `LocalPlayer.aiStep`'s riding jump: the bar charges while jump is
|
||||
/// held; on release, the jump's strength (0 when there is none).
|
||||
pub(super) fn charge_mount_jump(&mut self, i: usize, keys: Keys) -> f32 {
|
||||
// PlayerRideableJumping: horses, camels and nautili (the dash).
|
||||
let saddled = self.mobs[i].extra.saddled
|
||||
&& (equine::is_equine(self.mobs[i].species())
|
||||
@@ -299,6 +288,27 @@ impl Game<'_> {
|
||||
}
|
||||
st.was_jumping = keys.jump;
|
||||
}
|
||||
jump
|
||||
}
|
||||
/// The rider's input for this tick; returns false when not on a mount.
|
||||
pub(super) fn tick_mount_riding(&mut self, keys: Keys) -> bool {
|
||||
let Some(i) = self.mount_index() else {
|
||||
return false;
|
||||
};
|
||||
if keys.shift || !self.mobs[i].alive() {
|
||||
self.dismount_mount();
|
||||
return true;
|
||||
}
|
||||
let strafe = f32::from(u8::from(keys.left)) - f32::from(u8::from(keys.right));
|
||||
let forward = f32::from(u8::from(keys.forward)) - f32::from(u8::from(keys.backward));
|
||||
// LocalPlayer.aiStep: the riding jump charges while space is held.
|
||||
let held = self.held().map(|s| s.item.clone());
|
||||
let steer = match self.mobs[i].species() {
|
||||
Species::Pig => held.as_deref() == Some("minecraft:carrot_on_a_stick"),
|
||||
Species::Strider => held.as_deref() == Some("minecraft:warped_fungus_on_a_stick"),
|
||||
_ => false,
|
||||
};
|
||||
let jump = self.charge_mount_jump(i, keys);
|
||||
let m = &mut self.mobs[i];
|
||||
if jump > 0.0 {
|
||||
// onPlayerJump: rear while jumping (horses).
|
||||
@@ -324,7 +334,7 @@ impl Game<'_> {
|
||||
pitch: self.pitch,
|
||||
jump: if jump > 0.0 { jump } else { pending },
|
||||
steer,
|
||||
sprint: self.keys.contains(&winit::keyboard::KeyCode::ControlLeft),
|
||||
sprint: keys.sprint || self.keys.contains(&winit::keyboard::KeyCode::ControlLeft),
|
||||
jumping: keys.jump,
|
||||
});
|
||||
true
|
||||
|
||||
@@ -124,7 +124,7 @@ impl Game<'_> {
|
||||
team,
|
||||
viewer,
|
||||
self.spectator(),
|
||||
m.has_effect("minecraft:invisibility"),
|
||||
m.invisible || m.has_effect("minecraft:invisibility"),
|
||||
);
|
||||
// Entity.isDiscrete: a crouching player.
|
||||
let discrete = player && m.extra.crouching;
|
||||
@@ -144,9 +144,16 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
// getDisplayName: the name in the team's prefix, suffix and colour.
|
||||
let member = if player {
|
||||
Text::literal(&name)
|
||||
} else {
|
||||
m.custom_name_text
|
||||
.clone()
|
||||
.unwrap_or_else(|| Text::literal(&name))
|
||||
};
|
||||
let shown = match team {
|
||||
Some(t) => t.formatted_name(Text::literal(&name)),
|
||||
None => Text::literal(&name),
|
||||
Some(t) => t.formatted_name(member),
|
||||
None => member,
|
||||
};
|
||||
let mut lines = Vec::new();
|
||||
// EntityRenderer.extractNameTags: the score shows within the
|
||||
@@ -171,6 +178,64 @@ impl Game<'_> {
|
||||
discrete,
|
||||
});
|
||||
}
|
||||
// Armor stands name themselves: an invisible one with a visible name
|
||||
// is a hologram (its model is skipped, its name tag is not).
|
||||
for a in &self.armor_stands {
|
||||
let Some(name) = &a.custom_name else {
|
||||
continue;
|
||||
};
|
||||
if !a.name_visible {
|
||||
continue;
|
||||
}
|
||||
let height = match (a.marker, a.small) {
|
||||
(true, _) => 0.0,
|
||||
(false, true) => 0.9875,
|
||||
(false, false) => 1.975,
|
||||
};
|
||||
let distance_sqr =
|
||||
glam::DVec3::new(a.position.x, a.position.y, a.position.z).distance_squared(camera);
|
||||
if distance_sqr >= 64.0 * 64.0 {
|
||||
continue;
|
||||
}
|
||||
let team = self.mob_team(a.id);
|
||||
let invisible = sb::invisible_to(team, viewer, self.spectator(), false);
|
||||
let view = sb::NameView {
|
||||
discrete: false,
|
||||
distance_sqr,
|
||||
invisible_to_viewer: invisible,
|
||||
hud_hidden: self.ui.hide_gui,
|
||||
is_camera: false,
|
||||
is_vehicle: false,
|
||||
};
|
||||
if !sb::name_shown(team, viewer, view) {
|
||||
continue;
|
||||
}
|
||||
let shown = match team {
|
||||
Some(t) => t.formatted_name(name.clone()),
|
||||
None => name.clone(),
|
||||
};
|
||||
let mut lines = Vec::new();
|
||||
let mut lift = 0.0;
|
||||
let holder = self.persist.uuids.get(&a.id).map(|u| uuid_text(*u));
|
||||
if distance_sqr < 10.0 * 10.0
|
||||
&& let Some(score) = holder.and_then(|h| self.below_name_text(&h))
|
||||
{
|
||||
lines.push((score, 0.0));
|
||||
lift = f64::from(9.0f32 * 1.15 * 0.025);
|
||||
}
|
||||
lines.push((shown, lift));
|
||||
let l = self.world.light(
|
||||
a.position.x.floor() as i32,
|
||||
(a.position.y + height).floor() as i32,
|
||||
a.position.z.floor() as i32,
|
||||
);
|
||||
tags.push(NameTag {
|
||||
at: glam::DVec3::new(a.position.x, a.position.y + height + 0.5, a.position.z),
|
||||
lines,
|
||||
light: l,
|
||||
discrete: false,
|
||||
});
|
||||
}
|
||||
tags
|
||||
}
|
||||
/// `SubmitNodeCollection.submitNameTag`: a discrete name is drawn once,
|
||||
|
||||
@@ -9,6 +9,7 @@ use horizon_client::{
|
||||
mobs::{
|
||||
Mob, MobWorld, Species,
|
||||
data::{self, MobData},
|
||||
seen_grid::SeenGrid,
|
||||
spawn_rules::{Dimension, SpawnLevel},
|
||||
spawn_selection::{
|
||||
self, Category, Counted, Heightmap, NaturalLevel, SpawnCost, SpawnPlayer, SpawnRequest,
|
||||
@@ -75,8 +76,72 @@ pub(super) struct GameLevel<'g, 'a> {
|
||||
pub(super) pending: std::cell::RefCell<Vec<Aabb>>,
|
||||
/// The types and boxes of what a spawner created this tick.
|
||||
pub(super) pending_ids: std::cell::RefCell<Vec<(String, Aabb)>>,
|
||||
/// The game's mobs by box, built once a pass has asked more than
|
||||
/// [`SCANNED_QUERIES`] box queries (the mobs do not change while it
|
||||
/// holds the game); the first ones scan, as the build costs a few scans.
|
||||
pub(super) boxes: std::cell::OnceCell<MobBoxes>,
|
||||
pub(super) box_queries: std::cell::Cell<u32>,
|
||||
}
|
||||
const SCANNED_QUERIES: u32 = 8;
|
||||
/// A mob not removed: its index in `Game::mobs`, box, alive, and whether
|
||||
/// it is a living shulker.
|
||||
type MobBox = (usize, Aabb, bool, bool);
|
||||
fn mob_box(i: usize, m: &Mob) -> MobBox {
|
||||
let alive = m.alive();
|
||||
(
|
||||
i,
|
||||
m.bounds(),
|
||||
alive,
|
||||
alive && m.species() == Species::Shulker,
|
||||
)
|
||||
}
|
||||
/// The boxes of the game's mobs not removed, and a grid over them.
|
||||
pub(super) struct MobBoxes {
|
||||
entries: Vec<MobBox>,
|
||||
grid: SeenGrid,
|
||||
}
|
||||
impl MobBoxes {
|
||||
fn new(mobs: &[Mob]) -> Self {
|
||||
let entries: Vec<_> = mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| !m.removed)
|
||||
.map(|(i, m)| mob_box(i, m))
|
||||
.collect();
|
||||
let grid = SeenGrid::new(entries.iter().map(|e| e.1));
|
||||
Self { entries, grid }
|
||||
}
|
||||
/// The entries whose boxes meet `area` (and possibly a few more).
|
||||
fn near(&self, area: Aabb) -> impl Iterator<Item = MobBox> + '_ {
|
||||
let mut near = Vec::new();
|
||||
let indexed = self.grid.candidates(area, &mut near);
|
||||
let all = (!indexed).then(|| self.entries.iter());
|
||||
near.into_iter()
|
||||
.map(|i| self.entries[i as usize])
|
||||
.chain(all.into_iter().flatten().copied())
|
||||
}
|
||||
}
|
||||
impl GameLevel<'_, '_> {
|
||||
/// The mobs not removed whose boxes may meet `area` (callers test them).
|
||||
fn mobs_near(&self, area: Aabb) -> Box<dyn Iterator<Item = MobBox> + '_> {
|
||||
let asked = self.box_queries.get() + 1;
|
||||
self.box_queries.set(asked);
|
||||
if asked <= SCANNED_QUERIES && self.boxes.get().is_none() {
|
||||
return Box::new(
|
||||
self.game
|
||||
.mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| !m.removed)
|
||||
.map(|(i, m)| mob_box(i, m)),
|
||||
);
|
||||
}
|
||||
Box::new(
|
||||
self.boxes
|
||||
.get_or_init(|| MobBoxes::new(&self.game.mobs))
|
||||
.near(area),
|
||||
)
|
||||
}
|
||||
fn biome(&self, x: i32, y: i32, z: i32) -> Option<&BiomeInfo> {
|
||||
let chunk = self.game.world.chunks.get(&(x >> 4, z >> 4))?;
|
||||
Some(self.game.res.biomes().get(chunk.biome(x, y, z)))
|
||||
@@ -191,7 +256,13 @@ impl NaturalLevel for GameLevel<'_, '_> {
|
||||
StructureSpawns::shared(&self.game.options.resources)
|
||||
}
|
||||
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
|
||||
nearest_player(&self.players, x, y, z).map(|p| p.distance_sqr(x, y, z))
|
||||
// The nearest player's distance (`nearest_player`), without finding
|
||||
// which player it is: every spawn attempt asks.
|
||||
self.players
|
||||
.iter()
|
||||
.filter(|p| !p.spectator)
|
||||
.map(|p| p.distance_sqr(x, y, z))
|
||||
.reduce(f64::min)
|
||||
}
|
||||
fn respawn(&self) -> Option<[i32; 3]> {
|
||||
// The world spawn point lives in the overworld.
|
||||
@@ -218,12 +289,9 @@ impl NaturalLevel for GameLevel<'_, '_> {
|
||||
// getEntityCollisions(null, box): living shulkers are solid.
|
||||
let grown = bounds.inflate(Vec3::new(1e-7, 1e-7, 1e-7));
|
||||
no_collision_empty(&mut scene, bounds)
|
||||
&& !self.game.mobs.iter().any(|m| {
|
||||
m.species() == Species::Shulker
|
||||
&& m.alive()
|
||||
&& !m.removed
|
||||
&& m.bounds().intersects(grown)
|
||||
})
|
||||
&& !self
|
||||
.mobs_near(grown)
|
||||
.any(|(_, b, _, shulker)| shulker && b.intersects(grown))
|
||||
&& !self
|
||||
.pending_ids
|
||||
.borrow()
|
||||
@@ -238,10 +306,8 @@ impl NaturalLevel for GameLevel<'_, '_> {
|
||||
!player
|
||||
&& !self.pending.borrow().iter().any(|b| b.intersects(bounds))
|
||||
&& !self
|
||||
.game
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.alive() && !m.removed && m.bounds().intersects(bounds))
|
||||
.mobs_near(bounds)
|
||||
.any(|(_, b, alive, _)| alive && b.intersects(bounds))
|
||||
}
|
||||
fn spawn_mobs(&self) -> bool {
|
||||
self.game.ui.rules.bool("spawn_mobs")
|
||||
@@ -283,10 +349,8 @@ impl horizon_client::mobs::spawner::SpawnerLevel for GameLevel<'_, '_> {
|
||||
}
|
||||
fn count_entities(&self, id: &str, bounds: Aabb) -> usize {
|
||||
let mobs = self
|
||||
.game
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| !m.removed && m.kind.id == id && m.bounds().intersects(bounds))
|
||||
.mobs_near(bounds)
|
||||
.filter(|&(i, b, ..)| self.game.mobs[i].kind.id == id && b.intersects(bounds))
|
||||
.count();
|
||||
let pending = self
|
||||
.pending_ids
|
||||
@@ -305,6 +369,8 @@ impl<'a> Game<'a> {
|
||||
players: self.level_players(),
|
||||
pending: Default::default(),
|
||||
pending_ids: Default::default(),
|
||||
boxes: Default::default(),
|
||||
box_queries: Default::default(),
|
||||
}
|
||||
}
|
||||
/// Creates a mob the spawner accepted, finalized the game's way; the
|
||||
|
||||
@@ -230,6 +230,11 @@ impl Game<'_> {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::OpenShaderPackFolder => {
|
||||
let dir = horizon_client::shader_pack::folder();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -113,7 +113,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// Where focus can land: buttons drawn last frame, every registered
|
||||
/// target (sliders, fields, rows, Horizon-style widgets), and the slots
|
||||
/// target (sliders, fields, rows, keyboard keys), and the slots
|
||||
/// and tabs of inventories (GUI pixels), on screen.
|
||||
fn pad_targets(&self) -> Vec<Target> {
|
||||
let mut targets = self.pad_all_targets();
|
||||
@@ -137,7 +137,7 @@ impl Game<'_> {
|
||||
})
|
||||
.collect();
|
||||
for t in widgets::last_targets() {
|
||||
// A Horizon button is both a labelled button and a target.
|
||||
// A button can be both a labelled button and a target.
|
||||
if let Some(same) = targets.iter_mut().find(|o| o.rect == t.rect) {
|
||||
same.kind = t.kind;
|
||||
} else {
|
||||
@@ -1026,45 +1026,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The focus highlight. Classic screens look as vanilla's keyboard focus
|
||||
/// does: buttons, sliders and slots already draw their highlighted
|
||||
/// sprite under the (hidden) pointer, and list rows and fields get
|
||||
/// vanilla's white one-pixel outline (`AbstractSelectionList`'s focused
|
||||
/// selection, `EditBox`'s highlighted border). Horizon-style screens get
|
||||
/// the design system's focus colour: a two-dp accent ring.
|
||||
/// The focus highlight, as vanilla's keyboard focus looks: buttons,
|
||||
/// sliders and slots already draw their highlighted sprite under the
|
||||
/// (hidden) pointer, and list rows and fields get vanilla's white
|
||||
/// one-pixel outline (`AbstractSelectionList`'s focused selection,
|
||||
/// `EditBox`'s highlighted border).
|
||||
fn draw_pad_focus(&self, batch: &mut HudBatch, focus: Target) {
|
||||
let [x, y, w, h] = focus.rect;
|
||||
// The options' sub-pages keep the classic layout under either style.
|
||||
let horizon = horizon_client::ui_theme::horizon()
|
||||
&& if self.screen == Screen::Options {
|
||||
self.ui.options.page() == Page::Main
|
||||
} else {
|
||||
self.title.is_some() || self.screen == Screen::Paused
|
||||
};
|
||||
if horizon {
|
||||
use horizon_client::ui_theme::{self as theme, Ui, color};
|
||||
let mut ui = Ui::new(&self.gui.atlas, batch, self.cursor);
|
||||
let k = ui.batch.scale / ui.density;
|
||||
let (x, y, w, h) = (x * k, y * k, w * k, h * k);
|
||||
let radius = match focus.kind {
|
||||
Kind::Button | Kind::Tab(_) | Kind::Slider { .. } => (h + 6.0) / 2.0,
|
||||
Kind::Field => theme::RADIUS + 3.0,
|
||||
_ => theme::RADIUS_PLATE + 3.0,
|
||||
};
|
||||
for inset in [0.0, 1.0] {
|
||||
let o = 3.0 - inset;
|
||||
ui.border(
|
||||
x - o,
|
||||
y - o,
|
||||
w + 2.0 * o,
|
||||
h + 2.0 * o,
|
||||
radius - inset,
|
||||
color::ACCENT,
|
||||
);
|
||||
}
|
||||
ui.finish();
|
||||
return;
|
||||
}
|
||||
if matches!(focus.kind, Kind::Row | Kind::Field) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let white = rgba(255, 255, 255, 255);
|
||||
|
||||
@@ -312,6 +312,7 @@ impl Game<'_> {
|
||||
let crouching = p.pose == Pose::Crouching;
|
||||
let (position, flying) = (p.position, p.flying || p.fall_flying);
|
||||
let (fall_flying, fly_ticks, velocity) = (p.fall_flying, p.fall_fly_ticks, p.velocity);
|
||||
let spinning = p.auto_spin_attack_ticks > 0;
|
||||
let using = self.using.map(|(_, ticks)| ticks as i32 + 1).unwrap_or(0);
|
||||
let using_off_hand = self
|
||||
.using
|
||||
@@ -397,6 +398,7 @@ impl Game<'_> {
|
||||
m.velocity = velocity;
|
||||
m.extra.fall_flying = fall_flying;
|
||||
m.extra.fall_fly_ticks = fly_ticks;
|
||||
m.extra.auto_spin_attack = spinning;
|
||||
m.extra.main_hand_left = self.settings.main_hand_left;
|
||||
m.extra.using_item = using;
|
||||
m.extra.using_off_hand = using_off_hand;
|
||||
@@ -451,6 +453,7 @@ impl Game<'_> {
|
||||
out,
|
||||
);
|
||||
self.player_elytra_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
}
|
||||
/// `extractEntityInInventoryFollowsMouse`: the player in the box
|
||||
@@ -592,6 +595,47 @@ impl Game<'_> {
|
||||
out,
|
||||
);
|
||||
}
|
||||
/// `SpinAttackEffectLayer`: Riptide's two boxes turning around a spinning
|
||||
/// player (`SpinAttackEffectModel.setupAnim`).
|
||||
fn spin_attack_geometry(
|
||||
&self,
|
||||
m: &Mob,
|
||||
camera: glam::DVec3,
|
||||
partial: f32,
|
||||
light: u32,
|
||||
out: &mut Vec<horizon_client::mesher::Vertex>,
|
||||
) {
|
||||
if !m.extra.auto_spin_attack {
|
||||
return;
|
||||
}
|
||||
let (Some(model), Some(rect)) = (
|
||||
self.res.spin_attack().as_ref(),
|
||||
self.gui.atlas.sprites.get("entity/trident/trident_riptide"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let age = m.age as f32 + partial;
|
||||
horizon_client::mobs::render_model(
|
||||
model,
|
||||
horizon_client::mobs::render::base_transform(m, partial, camera),
|
||||
&|part| {
|
||||
let mut p = part.pose;
|
||||
if let Some(i) = part
|
||||
.name
|
||||
.strip_prefix("box")
|
||||
.and_then(|i| i.parse::<u8>().ok())
|
||||
{
|
||||
let angle = age * -(45.0 + f32::from(i + 1) * 5.0);
|
||||
p[4] = wrap(angle).to_radians();
|
||||
}
|
||||
p
|
||||
},
|
||||
[rect.u0, rect.v0, rect.u1, rect.v1],
|
||||
light,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn player_preview_quads(
|
||||
|
||||
@@ -34,6 +34,9 @@ impl Game<'_> {
|
||||
if !self.ui.rules.bool("mob_griefing") {
|
||||
return;
|
||||
}
|
||||
// Whether each item is piglin-loved, kept until an item is added or
|
||||
// removed (added ones are pushed at the end under a new id).
|
||||
let mut loved: (Option<(usize, Option<u32>)>, Vec<bool>) = (None, Vec::new());
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if m.species() != Species::Piglin
|
||||
@@ -46,11 +49,20 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let pos = m.position;
|
||||
let key = (self.dropped.len(), self.dropped.last().map(|e| e.id));
|
||||
if loved.0 != Some(key) {
|
||||
loved.0 = Some(key);
|
||||
loved.1 = self
|
||||
.dropped
|
||||
.iter()
|
||||
.map(|e| self.piglin_loved(&e.stack.item))
|
||||
.collect();
|
||||
}
|
||||
let best = self
|
||||
.dropped
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, e)| self.piglin_loved(&e.stack.item))
|
||||
.filter(|&(j, _)| loved.1[j])
|
||||
.map(|(j, e)| (j, e.position.distance_squared(pos)))
|
||||
.filter(|(_, d)| *d <= 81.0)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
|
||||
@@ -279,7 +279,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: None,
|
||||
maps: None,
|
||||
};
|
||||
for i in 0..36 {
|
||||
menu.set_player_item(i, self.inventory[i].clone());
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -52,6 +52,8 @@ impl Game<'_> {
|
||||
invisible: false,
|
||||
check_interval: 0,
|
||||
});
|
||||
} else if kind == "minecraft:lightning_bolt" {
|
||||
self.spawn_remote_bolt(id, position);
|
||||
} else if kind == "minecraft:painting" {
|
||||
// The variant comes with the entity data; until then the
|
||||
// registry's first one (VariantUtils.getAny).
|
||||
@@ -123,6 +125,14 @@ impl Game<'_> {
|
||||
} else if let Some(a) = self.armor_stands.iter_mut().find(|a| a.id == id) {
|
||||
match (index, value) {
|
||||
(0, Value::Byte(flags)) => a.invisible = flags & 0x20 != 0,
|
||||
// Entity.DATA_CUSTOM_NAME and DATA_CUSTOM_NAME_VISIBLE.
|
||||
(2, Value::OptText(name)) => {
|
||||
a.custom_name = name.as_ref().map(horizon_client::entity_nbt::name_text);
|
||||
}
|
||||
(2, Value::Text(name)) => {
|
||||
a.custom_name = Some(horizon_client::entity_nbt::name_text(name));
|
||||
}
|
||||
(3, Value::Bool(visible)) => a.name_visible = *visible,
|
||||
(9, Value::Float(health)) => a.health = *health,
|
||||
// ArmorStand.DATA_CLIENT_FLAGS.
|
||||
(15, Value::Byte(flags)) => {
|
||||
@@ -180,7 +190,12 @@ impl Game<'_> {
|
||||
/// A move's rotation and ground state for an object.
|
||||
pub(super) fn turn_remote_object(&mut self, id: i32, yaw: Option<f32>, on_ground: bool) {
|
||||
let id = id as u32;
|
||||
// The boat the player drives turns here (isLocalInstanceAuthoritative).
|
||||
let driven = self.riding;
|
||||
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
|
||||
if Some(id) == driven {
|
||||
return;
|
||||
}
|
||||
if let Some(yaw) = yaw {
|
||||
b.yaw = yaw;
|
||||
}
|
||||
@@ -205,7 +220,12 @@ impl Game<'_> {
|
||||
c.previous_yaw = c.yaw;
|
||||
c.position = step(c.id, c.position);
|
||||
}
|
||||
// The boat the player drives moves here, not where the server says.
|
||||
let driven = self.riding;
|
||||
for b in &mut self.boats {
|
||||
if Some(b.id) == driven {
|
||||
continue;
|
||||
}
|
||||
b.previous = b.position;
|
||||
b.previous_yaw = b.yaw;
|
||||
b.position = step(b.id, b.position);
|
||||
@@ -266,5 +286,6 @@ impl Game<'_> {
|
||||
self.armor_stands.retain(|a| a.id != id);
|
||||
self.item_frames.retain(|f| f.id != id);
|
||||
self.paintings.retain(|p| p.id != id);
|
||||
self.bolts.retain(|b| b.id != id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! A server's workstation menus on this client: the server opens the menu
|
||||
//! (`ClientboundOpenScreenPacket` with the station's `MenuType`), the
|
||||
//! station screen draws it, and its clicks and buttons are predicted on the
|
||||
//! menu engine (`Game::menu_session`) and sent, as
|
||||
//! `MultiPlayerGameMode.handleContainerInput` and `handleInventoryButtonClick`
|
||||
//! do. The menu's client-side `slotsChanged` follows the server's slots, and
|
||||
//! its data slots come in `ClientboundContainerSetDataPacket`.
|
||||
use super::{
|
||||
Game, Screen,
|
||||
stations::{Kind, Station},
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
|
||||
impl Game<'_> {
|
||||
/// The server opened workstation menu `menu` in window `container`.
|
||||
pub(super) fn remote_open_station(
|
||||
&mut self,
|
||||
container: i32,
|
||||
menu: String,
|
||||
kind: Kind,
|
||||
title: String,
|
||||
pos: [i32; 3],
|
||||
) {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.menu = Some((container, menu));
|
||||
r.menu_title = Some(title);
|
||||
}
|
||||
self.craft_size = 2;
|
||||
self.container = None;
|
||||
self.station = Some(Station::new_kind(kind, pos));
|
||||
self.screen = Screen::Inventory;
|
||||
self.capture(false);
|
||||
}
|
||||
|
||||
/// Window `container` is the server's workstation menu, open here.
|
||||
pub(super) fn remote_station_open(&self, container: i32) -> bool {
|
||||
self.station.is_some()
|
||||
&& self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.menu.as_ref())
|
||||
.is_some_and(|(id, name)| {
|
||||
*id == container && Self::remote_station_kind(name).is_some()
|
||||
})
|
||||
}
|
||||
|
||||
/// The server set station slot `internal`: the client menu's
|
||||
/// `Slot.set` runs its container's `setChanged`, so the loom lists the
|
||||
/// patterns its inputs allow and the stonecutter the recipes of its input.
|
||||
pub(super) fn remote_station_slot_changed(&mut self, internal: usize) {
|
||||
if !self.station.as_ref().is_some_and(|s| {
|
||||
matches!(
|
||||
s.kind,
|
||||
Kind::Loom
|
||||
| Kind::Stonecutter
|
||||
| Kind::Grindstone
|
||||
| Kind::Anvil
|
||||
| Kind::Smithing
|
||||
| Kind::Cartography
|
||||
)
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
self.menu_session(|menu, env, to_menu| {
|
||||
let i = to_menu(internal)?;
|
||||
let stack = menu.slot(i).cloned();
|
||||
menu.set_slot(env, i, stack);
|
||||
Some(true)
|
||||
});
|
||||
// AnvilScreen.slotChanged: the box shows the new input's name, which
|
||||
// its responder sends.
|
||||
self.remote_anvil_rename();
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.setData` from the server.
|
||||
pub(super) fn remote_station_data(&mut self, id: usize, value: i16) {
|
||||
let Some(s) = self.station.as_mut() else {
|
||||
return;
|
||||
};
|
||||
match (s.kind, id) {
|
||||
// LoomMenu.selectedBannerPatternIndex.
|
||||
(Kind::Loom, 0) => s.loom.selected = usize::try_from(value).ok(),
|
||||
// StonecutterMenu.selectedRecipeIndex.
|
||||
(Kind::Stonecutter, 0) => s.selected = usize::try_from(value).ok(),
|
||||
// AnvilMenu.cost.
|
||||
(Kind::Anvil, 0) => s.anvil.cost = i32::from(value),
|
||||
// EnchantmentMenu: costs, the seed (the screen's glyph names),
|
||||
// the clues' enchantments and levels.
|
||||
(Kind::Enchanting, 0..3) => s.offers.costs[id] = i32::from(value),
|
||||
(Kind::Enchanting, 3) => self.level.xp_seed = i32::from(value),
|
||||
(Kind::Enchanting, 4..7) => {
|
||||
let name = self.remote.as_ref().and_then(|r| {
|
||||
r.registries
|
||||
.name("minecraft:enchantment", i32::from(value))
|
||||
.map(std::sync::Arc::<str>::from)
|
||||
});
|
||||
let level = s.offers.clues[id - 4].as_ref().map_or(1, |(_, l)| *l);
|
||||
s.offers.clues[id - 4] = name.map(|n| (n, level));
|
||||
}
|
||||
(Kind::Enchanting, 7..10) => {
|
||||
if let Some((_, level)) = s.offers.clues[id - 7].as_mut() {
|
||||
*level = value.max(0) as u32;
|
||||
}
|
||||
}
|
||||
// BeaconMenu: the levels, then the powers (BeaconScreen's
|
||||
// dataChanged shows the menu's).
|
||||
(Kind::Beacon, 0) => s.beacon_levels = Some(i32::from(value)),
|
||||
(Kind::Beacon, 1 | 2) => {
|
||||
let effect = self.remote_effect_id(i32::from(value) - 1);
|
||||
let index =
|
||||
effect.and_then(|e| super::beacon::EFFECTS.iter().position(|(n, _)| *n == e));
|
||||
if let Some(s) = self.station.as_mut() {
|
||||
s.beacon[id - 1] = index;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `AnvilScreen.onNameChanged` on a server: the rename box's text (blank
|
||||
/// when it is the input's own name) goes to the server when the menu's
|
||||
/// `setItemName` takes it.
|
||||
pub(super) fn remote_anvil_rename(&mut self) {
|
||||
if self.remote.is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(s) = self
|
||||
.station
|
||||
.as_ref()
|
||||
.filter(|s| s.kind == Kind::Anvil && s.inputs[0].is_some())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let name = self.anvil_item_name(s);
|
||||
if s.anvil_sent.as_deref() == Some(&name) {
|
||||
return;
|
||||
}
|
||||
if let Some(s) = self.station.as_mut() {
|
||||
s.anvil_sent = Some(name.clone());
|
||||
}
|
||||
self.send(S::RenameItem(play::RenameItem { name }));
|
||||
}
|
||||
|
||||
/// `MultiPlayerGameMode.handleInventoryButtonClick`: the screen already
|
||||
/// ran `clickMenuButton` on its own menu.
|
||||
pub(super) fn remote_station_button(&self, button: i32) {
|
||||
if let Some((container, _)) = self.remote.as_ref().and_then(|r| r.menu.as_ref())
|
||||
&& self.remote_station_open(*container)
|
||||
{
|
||||
self.send(S::ContainerButtonClick(play::ContainerButtonClick {
|
||||
container: *container,
|
||||
button,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Boats, minecarts, item frames, armor stands and paintings for a server's
|
||||
//! client: single player on its server thread, a LAN guest, a player on a
|
||||
//! dedicated server.
|
||||
//!
|
||||
//! The client aims at them like at mobs (`Minecraft.hitResult`, then
|
||||
//! `ServerboundInteractPacket`), and rides the vehicle the server seats it in
|
||||
//! (`ClientPacketListener.handleSetEntityPassengersPacket`). A boat is
|
||||
//! controlled by its rider's client: it ticks here and its moves go to the
|
||||
//! server (`LocalPlayer.tick`: `ServerboundMoveVehiclePacket`,
|
||||
//! `AbstractBoat.tick`: `ServerboundPaddleBoatPacket`). A minecart moves on
|
||||
//! the server with the rider's input, and the rider sits in it.
|
||||
use super::{Game, Keys, view_vector};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
|
||||
/// An object under the crosshair: its entity id, how far along the view it
|
||||
/// was hit, and where (`EntityHitResult.getLocation` minus the entity's
|
||||
/// position, the interact packet's location).
|
||||
pub(super) struct ObjectHit {
|
||||
pub id: i32,
|
||||
pub distance: f64,
|
||||
pub location: [f64; 3],
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The nearest boat, minecart, item frame, armor stand or painting the
|
||||
/// view hits within reach.
|
||||
pub(super) fn remote_object_hit(&self) -> Option<ObjectHit> {
|
||||
let eye = self.eye_position()?;
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let at = |d: f64| {
|
||||
[
|
||||
eye[0] + dir[0] * d,
|
||||
eye[1] + dir[1] * d,
|
||||
eye[2] + dir[2] * d,
|
||||
]
|
||||
};
|
||||
let block_centre = |p: [i32; 3]| {
|
||||
[
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]) + 0.5,
|
||||
f64::from(p[2]) + 0.5,
|
||||
]
|
||||
};
|
||||
let mut hits: Vec<(i32, f64, [f64; 3])> = Vec::new();
|
||||
if let Some((i, d)) = self.painting_hit() {
|
||||
let p = &self.paintings[i];
|
||||
hits.push((p.id as i32, d, block_centre(p.pos)));
|
||||
}
|
||||
if let Some((i, d)) = self.frame_hit() {
|
||||
let f = &self.item_frames[i];
|
||||
hits.push((f.id as i32, d, block_centre(f.pos)));
|
||||
}
|
||||
if let Some((i, d, _)) = self.armor_stand_hit() {
|
||||
let a = &self.armor_stands[i];
|
||||
let p = a.position;
|
||||
hits.push((a.id as i32, d, [p.x, p.y, p.z]));
|
||||
}
|
||||
if let Some((i, d)) = self.boat_hit() {
|
||||
let p = self.boats[i].position;
|
||||
hits.push((self.boats[i].id as i32, d, [p.x, p.y, p.z]));
|
||||
}
|
||||
if let Some((i, d)) = self.cart_hit() {
|
||||
let p = self.carts[i].position;
|
||||
hits.push((self.carts[i].id as i32, d, [p.x, p.y, p.z]));
|
||||
}
|
||||
let (id, distance, origin) = hits.into_iter().min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
let point = at(distance);
|
||||
Some(ObjectHit {
|
||||
id,
|
||||
distance,
|
||||
location: [
|
||||
point[0] - origin[0],
|
||||
point[1] - origin[1],
|
||||
point[2] - origin[2],
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleSetEntityPassengersPacket`: who rides
|
||||
/// `vehicle` now. The local player gets in or out; mobs and other
|
||||
/// players sit where the server keeps them.
|
||||
pub(super) fn remote_set_passengers(&mut self, vehicle: i32, passengers: &[i32]) {
|
||||
let Some(me) = self.remote.as_ref().map(|r| r.entity_id) else {
|
||||
return;
|
||||
};
|
||||
let vehicle_id = vehicle as u32;
|
||||
for m in &mut self.mobs {
|
||||
if m.extra.mount == Some(vehicle_id) && !passengers.contains(&(m.id as i32)) {
|
||||
m.extra.mount = None;
|
||||
} else if passengers.contains(&(m.id as i32)) {
|
||||
m.extra.mount = Some(vehicle_id);
|
||||
}
|
||||
}
|
||||
if passengers.contains(&me) {
|
||||
if self.riding != Some(vehicle_id) {
|
||||
self.riding = Some(vehicle_id);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
}
|
||||
} else if self.riding == Some(vehicle_id) {
|
||||
// The server takes the player out, then teleports it.
|
||||
self.riding = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The local player's tick in its vehicle, in place of its own movement:
|
||||
/// false when it rides nothing.
|
||||
pub(super) fn remote_ride(&mut self, keys: Keys) -> bool {
|
||||
let Some(id) = self.riding else {
|
||||
return false;
|
||||
};
|
||||
if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
|
||||
// An animal moves on the server with the rider's input
|
||||
// (PlayerInput); here the jump bar charges for the HUD and the
|
||||
// rider sits where `positionRider` puts it.
|
||||
self.charge_mount_jump(i, keys);
|
||||
self.position_rider();
|
||||
return true;
|
||||
}
|
||||
let seat = if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
// AbstractBoat.tick on the controlling client: steer and move.
|
||||
let mut b = self.boats[i].clone();
|
||||
b.input = [keys.left, keys.right, keys.forward, keys.backward];
|
||||
let b = self.tick_boat(b, true);
|
||||
// positionRider: the seat turns with the boat; clampRotation.
|
||||
self.yaw += b.delta_rotation;
|
||||
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
self.yaw += delta.clamp(-105.0, 105.0) - delta;
|
||||
let seat = b.position;
|
||||
self.boats[i] = b;
|
||||
Some(seat)
|
||||
} else {
|
||||
// A minecart: the server moves it, the rider sits in it.
|
||||
self.carts.iter().find(|c| c.id == id).map(|c| c.position)
|
||||
};
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
if let Some(v) = seat {
|
||||
p.position = Vec3::new(v.x, v.y + super::boats::SEAT, v.z);
|
||||
}
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `LocalPlayer.tick` for a passenger: the boat it controls goes to the
|
||||
/// server every tick with its paddles (the rider's own position does not).
|
||||
pub(super) fn remote_vehicle_packets(&self) -> Vec<S> {
|
||||
let Some(b) = self
|
||||
.riding
|
||||
.and_then(|id| self.boats.iter().find(|b| b.id == id))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
vec![
|
||||
S::MoveVehicle(play::MoveVehicleRequest {
|
||||
position: [b.position.x, b.position.y, b.position.z],
|
||||
yaw: b.yaw,
|
||||
pitch: 0.0,
|
||||
on_ground: b.on_ground,
|
||||
}),
|
||||
S::PaddleBoat(play::PaddleBoat {
|
||||
left: b.paddling[0],
|
||||
right: b.paddling[1],
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleMoveVehicle`: the server put the boat the
|
||||
/// player drives back.
|
||||
pub(super) fn remote_move_vehicle(&mut self, position: [f64; 3], yaw: f32) {
|
||||
let Some(b) = self
|
||||
.riding
|
||||
.and_then(|id| self.boats.iter_mut().find(|b| b.id == id))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
b.previous = at;
|
||||
b.position = at;
|
||||
b.yaw = yaw;
|
||||
b.previous_yaw = yaw;
|
||||
b.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
@@ -431,20 +431,18 @@ impl Game<'_> {
|
||||
self.score.board.players_team(self.acting_player_name())
|
||||
}
|
||||
/// The teams of the player and of the mobs on one (empty without teams).
|
||||
pub(super) fn mob_teams(&self) -> horizon_client::mobs::Teams {
|
||||
pub(super) fn mob_teams(&self) -> horizon_client::mobs::Teams<'_> {
|
||||
let board = &self.score.board;
|
||||
if board.teams.is_empty() {
|
||||
return Default::default();
|
||||
}
|
||||
horizon_client::mobs::Teams {
|
||||
player: self.player_team().cloned(),
|
||||
player: self.player_team(),
|
||||
mobs: self
|
||||
.persist
|
||||
.uuids
|
||||
.iter()
|
||||
.filter_map(|(id, uuid)| {
|
||||
Some((*id, board.players_team(&uuid_text(*uuid))?.clone()))
|
||||
})
|
||||
.filter_map(|(id, uuid)| Some((*id, board.players_team(&uuid_text(*uuid))?)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
@@ -599,6 +597,11 @@ impl Game<'_> {
|
||||
/// objective.
|
||||
pub(super) fn draw_tab_list(&mut self, batch: &mut HudBatch) {
|
||||
use horizon_client::tab_list::{self, TabInputs};
|
||||
// Player heads and the player list both draw from the same skin slots.
|
||||
let ready: Vec<_> = self.skin_downloads.borrow_mut().take_ready();
|
||||
for (location, rgba) in ready {
|
||||
self.store_skin(location, rgba);
|
||||
}
|
||||
let key = self.screen == super::Screen::Playing
|
||||
&& self.keys.contains(&winit::keyboard::KeyCode::Tab);
|
||||
let (players, online_mode, upside_down) = match self.remote_tab_players() {
|
||||
|
||||
@@ -7,6 +7,28 @@ use horizon_client::{
|
||||
ui,
|
||||
};
|
||||
|
||||
impl Screen {
|
||||
/// Screen.extractBackground for non-isInGameUi screens, and
|
||||
/// PauseScreen.extractBackground only when showPauseMenu is true.
|
||||
pub(super) fn blurs_background(self, pause_menu: bool) -> bool {
|
||||
match self {
|
||||
Self::Paused => pause_menu,
|
||||
Self::Options
|
||||
| Self::Stats
|
||||
| Self::Advancements
|
||||
| Self::WorldOptions
|
||||
| Self::Friends => true,
|
||||
Self::Playing
|
||||
| Self::GameModeSwitcher
|
||||
| Self::Chat
|
||||
| Self::Creative
|
||||
| Self::Inventory
|
||||
| Self::Dead
|
||||
| Self::CommandBlock => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PauseScreen buttons: (x, y, width, label key, action id, active).
|
||||
/// https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/PauseScreen
|
||||
fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
|
||||
@@ -43,6 +65,9 @@ fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static s
|
||||
}
|
||||
|
||||
/// The icon row: report bugs, feedback, friends, player reporting.
|
||||
/// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens Horizon's
|
||||
/// Play with Friends; the others stay inactive.
|
||||
const FRIENDS_ICON: usize = 2;
|
||||
fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] {
|
||||
let x0 = ((w - 212.0) * 0.5).floor() + 60.0;
|
||||
let y = ((h - 166.0) * 0.25).floor() + 98.0;
|
||||
@@ -69,41 +94,8 @@ impl Game<'_> {
|
||||
pub(super) fn external_server(&self) -> bool {
|
||||
self.remote.is_some() && self.integrated.is_none()
|
||||
}
|
||||
/// Whether the pause menu is drawn in Horizon's style (over a blurred world).
|
||||
pub(super) fn horizon_pause(&self) -> bool {
|
||||
self.screen == Screen::Paused
|
||||
&& self.pause_menu
|
||||
&& self.title.is_none()
|
||||
&& horizon_client::ui_theme::horizon()
|
||||
}
|
||||
fn pause_labels(&self) -> (String, &'static str) {
|
||||
if let Some(server) = &self.integrated {
|
||||
return (server.world_name.clone(), "Save and quit");
|
||||
}
|
||||
if self.external_server() {
|
||||
("Multiplayer".to_owned(), "Disconnect")
|
||||
} else {
|
||||
(self.level.name.clone(), "Save and quit")
|
||||
}
|
||||
}
|
||||
pub(super) fn draw_pause_menu(&mut self, batch: &mut HudBatch) {
|
||||
if horizon_client::ui_theme::horizon() {
|
||||
let opened = *self.pause_since.get_or_insert_with(std::time::Instant::now);
|
||||
let (world, quit) = self.pause_labels();
|
||||
self.tick_friends();
|
||||
let friends = self.friends_view();
|
||||
let mut ui = horizon_client::ui_theme::Ui::new(&self.gui.atlas, batch, self.cursor);
|
||||
horizon_client::front::pause::draw(
|
||||
&mut ui,
|
||||
&world,
|
||||
quit,
|
||||
opened,
|
||||
&friends,
|
||||
self.external_server(),
|
||||
);
|
||||
ui.finish();
|
||||
return;
|
||||
}
|
||||
let friends = self.friends_view().shown;
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
@@ -131,12 +123,41 @@ impl Game<'_> {
|
||||
hover,
|
||||
);
|
||||
}
|
||||
for (x, y, sprite) in pause_icons(w, h) {
|
||||
ui::button(batch, atlas, x, y, 20.0, 20.0, &Text::empty(), false, false);
|
||||
let mut tooltip = None;
|
||||
for (i, (x, y, sprite)) in pause_icons(w, h).into_iter().enumerate() {
|
||||
let active = i == FRIENDS_ICON && friends;
|
||||
let hover = ui::hovered(self.cursor, x, y, 20.0, 20.0);
|
||||
// The icon has no label: name it for controller focus and the
|
||||
// control socket's widget tree.
|
||||
if i == FRIENDS_ICON {
|
||||
horizon_client::widgets::record(
|
||||
horizon_client::friends_screen::TITLE,
|
||||
[x, y, 20.0, 20.0],
|
||||
active,
|
||||
);
|
||||
}
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
y,
|
||||
20.0,
|
||||
20.0,
|
||||
&Text::empty(),
|
||||
active,
|
||||
active && hover,
|
||||
);
|
||||
// SpriteIconButton.CenteredIcon: the 15x15 sprite at
|
||||
// x + width / 2 - 15 / 2 in integer pixels, untinted even
|
||||
// when the button is inactive.
|
||||
batch.sprite_scaled(atlas, sprite, x + 3.0, y + 3.0, 15.0, 15.0, u32::MAX);
|
||||
if i == FRIENDS_ICON && hover {
|
||||
tooltip = Some(Text::literal(horizon_client::friends_screen::TITLE));
|
||||
}
|
||||
}
|
||||
// SpriteIconButton.withTootip: the label as a tooltip.
|
||||
if let Some(tip) = tooltip {
|
||||
ui::tooltip(batch, atlas, &[tip], self.cursor);
|
||||
}
|
||||
}
|
||||
pub(super) fn pause_click(&mut self) -> Result<()> {
|
||||
@@ -148,29 +169,16 @@ impl Game<'_> {
|
||||
let b = HudBatch::new(size);
|
||||
(b.width, b.height)
|
||||
};
|
||||
let hit = if horizon_client::ui_theme::horizon() {
|
||||
let size = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map_or([1, 1], |r| [r.config.width, r.config.height]);
|
||||
let mut b = HudBatch::new(size);
|
||||
let (world, quit) = self.pause_labels();
|
||||
horizon_client::front::pause::click(
|
||||
&self.gui.atlas,
|
||||
&mut b,
|
||||
self.cursor.0,
|
||||
self.cursor.1,
|
||||
&world,
|
||||
quit,
|
||||
&self.friends_view(),
|
||||
self.external_server(),
|
||||
)
|
||||
} else {
|
||||
pause_buttons(w, h, self.external_server())
|
||||
.into_iter()
|
||||
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
|
||||
.map(|b| b.4)
|
||||
};
|
||||
let friends = pause_icons(w, h)[FRIENDS_ICON];
|
||||
let hit = pause_buttons(w, h, self.external_server())
|
||||
.into_iter()
|
||||
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
|
||||
.map(|b| b.4)
|
||||
.or_else(|| {
|
||||
(self.friends_view().shown
|
||||
&& ui::hovered(self.cursor, friends.0, friends.1, 20.0, 20.0))
|
||||
.then_some(10)
|
||||
});
|
||||
match hit {
|
||||
Some(0) => {
|
||||
self.screen = Screen::Playing;
|
||||
@@ -180,17 +188,11 @@ impl Game<'_> {
|
||||
Some(3) => self.open_stats(),
|
||||
Some(4) => self.open_advancements(true),
|
||||
Some(9) => self.open_world_options(),
|
||||
Some(10) => {
|
||||
if let Err(e) = self.friends_start() {
|
||||
self.system_message(Text::literal(format!("Couldn't share the world: {e}")));
|
||||
}
|
||||
}
|
||||
Some(11) => self.friends_copy(),
|
||||
Some(12) => self.friends_stop(),
|
||||
Some(10) => self.screen = Screen::Friends,
|
||||
Some(2) => self.close_world()?,
|
||||
_ => {}
|
||||
}
|
||||
if hit.is_some() && !horizon_client::ui_theme::horizon() {
|
||||
if hit.is_some() {
|
||||
self.audio.play_button_click();
|
||||
}
|
||||
Ok(())
|
||||
@@ -334,7 +336,34 @@ impl Game<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::pause_buttons;
|
||||
use super::{Screen, pause_buttons};
|
||||
|
||||
#[test]
|
||||
fn menu_blur_matches_vanilla_screen_backgrounds() {
|
||||
for screen in [
|
||||
Screen::Options,
|
||||
Screen::Stats,
|
||||
Screen::Advancements,
|
||||
Screen::WorldOptions,
|
||||
] {
|
||||
assert!(screen.blurs_background(false));
|
||||
assert!(screen.blurs_background(true));
|
||||
}
|
||||
assert!(Screen::Paused.blurs_background(true));
|
||||
assert!(!Screen::Paused.blurs_background(false));
|
||||
for screen in [
|
||||
Screen::Playing,
|
||||
Screen::GameModeSwitcher,
|
||||
Screen::Chat,
|
||||
Screen::Creative,
|
||||
Screen::Inventory,
|
||||
Screen::Dead,
|
||||
Screen::CommandBlock,
|
||||
] {
|
||||
assert!(!screen.blurs_background(true));
|
||||
assert!(!screen.blurs_background(false));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() {
|
||||
|
||||
@@ -71,6 +71,8 @@ pub(super) struct SculkState {
|
||||
/// The sculk block entities in the order they were added.
|
||||
index: Index,
|
||||
pub known: HashSet<(i32, i32)>,
|
||||
/// The chunk map's membership revision `known` was taken at.
|
||||
pub known_revision: Option<u64>,
|
||||
// Keyed by mob ids the server gives out: the fast hasher is safe.
|
||||
steps: FxHashMap<Who, Steps>,
|
||||
damaged: FxHashMap<u32, i64>,
|
||||
@@ -336,7 +338,10 @@ impl Game<'_> {
|
||||
/// the block writes recorded since the last tick.
|
||||
pub(super) fn sculk_sync_index(&mut self) {
|
||||
// Chunks that arrived or left (every half second is plenty).
|
||||
if self.sculk.ticks.is_multiple_of(10) {
|
||||
// Nothing to do while no chunk came or went.
|
||||
let revision = self.world.chunks.membership_revision();
|
||||
if self.sculk.ticks.is_multiple_of(10) && self.sculk.known_revision != Some(revision) {
|
||||
self.sculk.known_revision = Some(revision);
|
||||
let loaded: HashSet<(i32, i32)> = self.world.chunks.keys().copied().collect();
|
||||
let arrived: Vec<(i32, i32)> = loaded.difference(&self.sculk.known).copied().collect();
|
||||
for key in arrived {
|
||||
|
||||
@@ -433,6 +433,7 @@ impl Game<'_> {
|
||||
});
|
||||
self.level_events.clear();
|
||||
self.sculk.known = self.world.chunks.keys().copied().collect();
|
||||
self.sculk.known_revision = Some(self.world.chunks.membership_revision());
|
||||
self.sculk_sync_index();
|
||||
let mut names: HashMap<u32, String> = HashMap::new();
|
||||
let mut sources: Vec<u32> = Vec::new();
|
||||
|
||||
@@ -37,11 +37,8 @@ impl ShaderPacks {
|
||||
}
|
||||
}
|
||||
|
||||
/// `shaderpacks/` beside `options.txt`.
|
||||
pub(super) fn dir() -> PathBuf {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join(shader_pack::DIR)
|
||||
shader_pack::folder()
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -77,6 +74,7 @@ impl Game<'_> {
|
||||
return self.shader_pack_error(&id, &e.to_string());
|
||||
}
|
||||
self.shader_packs.pack = pack;
|
||||
self.ui.options.shader_pack_error = None;
|
||||
}
|
||||
/// Give the loaded pack to the renderer (also after it is rebuilt).
|
||||
pub(super) fn apply_shader_pack(&mut self) -> horizon_client::Result<()> {
|
||||
@@ -105,6 +103,11 @@ impl Game<'_> {
|
||||
}
|
||||
fn shader_pack_error(&mut self, id: &str, error: &str) {
|
||||
log::warn!("Horizon: shader pack {id}: {error}");
|
||||
// The Shader Packs page says why on the pack's row: the first line,
|
||||
// since translator errors go on with a drawn source excerpt.
|
||||
let first = error.lines().map(str::trim).find(|l| !l.is_empty());
|
||||
self.ui.options.shader_pack_error =
|
||||
Some((id.to_owned(), first.unwrap_or(error).to_owned()));
|
||||
self.system_message(Text::literal(format!("Shader pack {id}: {error}")));
|
||||
}
|
||||
}
|
||||
@@ -73,11 +73,6 @@ impl StartupMenu {
|
||||
&self.root,
|
||||
);
|
||||
title.last_server = settings.file.string("lastServer");
|
||||
if horizon_client::ui_theme::horizon() {
|
||||
title
|
||||
.opening
|
||||
.set(Some(horizon_client::front::Opening::new()));
|
||||
}
|
||||
self.title = Some(title);
|
||||
}
|
||||
}
|
||||
@@ -152,9 +147,6 @@ impl StartupMenu {
|
||||
fn title_key(&mut self, event: &KeyEvent) {
|
||||
let shortcut = self.shortcut(event);
|
||||
let Some(title) = &mut self.title else { return };
|
||||
if title.skip_opening() {
|
||||
return;
|
||||
}
|
||||
if let Some(shortcut) = shortcut {
|
||||
if title.has_caret_field() {
|
||||
title.field_shortcut(shortcut, &mut self.clipboard);
|
||||
@@ -373,6 +365,11 @@ impl Loading {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
packs::open_folder(&dir);
|
||||
}
|
||||
Event::OpenShaderPackFolder => {
|
||||
let dir = horizon_client::shader_pack::folder();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
packs::open_folder(&dir);
|
||||
}
|
||||
Event::ButtonClick => {}
|
||||
}
|
||||
}
|
||||
@@ -417,14 +414,6 @@ impl Loading {
|
||||
event_loop: &ActiveEventLoop,
|
||||
event: WindowEvent,
|
||||
) -> Result<()> {
|
||||
if let WindowEvent::MouseInput {
|
||||
state,
|
||||
button: MouseButton::Left,
|
||||
..
|
||||
} = &event
|
||||
{
|
||||
horizon_client::ui_theme::set_pressed(*state == ElementState::Pressed);
|
||||
}
|
||||
if let WindowEvent::KeyboardInput { event, .. } = &event
|
||||
&& let PhysicalKey::Code(key) = event.physical_key
|
||||
{
|
||||
@@ -450,7 +439,6 @@ impl Loading {
|
||||
WindowEvent::Focused(false) => {
|
||||
self.menu.modifiers = ModifiersState::empty();
|
||||
self.menu.physical_keys.clear();
|
||||
horizon_client::ui_theme::set_pressed(false);
|
||||
self.menu.dragging = false;
|
||||
if let Some(title) = &mut self.menu.title {
|
||||
title.release();
|
||||
@@ -503,41 +491,23 @@ impl Loading {
|
||||
button: MouseButton::Left,
|
||||
..
|
||||
} => {
|
||||
let mut batch = HudBatch::new(self.menu_size());
|
||||
let batch = HudBatch::new(self.menu_size());
|
||||
if let (Some(title), Some(gui)) = (&mut self.menu.title, &self.gui) {
|
||||
if state == ElementState::Released {
|
||||
title.release();
|
||||
title.release_field();
|
||||
} else {
|
||||
let action = if horizon_client::ui_theme::horizon() {
|
||||
let action = title.click_horizon(
|
||||
&gui.atlas,
|
||||
&mut batch,
|
||||
self.menu.cursor.0,
|
||||
self.menu.cursor.1,
|
||||
);
|
||||
// Horizon's deeper world-management pages use classic fields.
|
||||
title.press_field(
|
||||
&gui.atlas,
|
||||
self.menu.cursor,
|
||||
(batch.width, batch.height),
|
||||
self.menu.modifiers.shift_key(),
|
||||
);
|
||||
action
|
||||
} else {
|
||||
let (action, _) = title.click(
|
||||
self.menu.cursor.0,
|
||||
self.menu.cursor.1,
|
||||
(batch.width, batch.height),
|
||||
);
|
||||
title.press_field(
|
||||
&gui.atlas,
|
||||
self.menu.cursor,
|
||||
(batch.width, batch.height),
|
||||
self.menu.modifiers.shift_key(),
|
||||
);
|
||||
action
|
||||
};
|
||||
let (action, _) = title.click(
|
||||
self.menu.cursor.0,
|
||||
self.menu.cursor.1,
|
||||
(batch.width, batch.height),
|
||||
);
|
||||
title.press_field(
|
||||
&gui.atlas,
|
||||
self.menu.cursor,
|
||||
(batch.width, batch.height),
|
||||
self.menu.modifiers.shift_key(),
|
||||
);
|
||||
self.menu_action(event_loop, action);
|
||||
}
|
||||
}
|
||||
@@ -612,18 +582,12 @@ impl Loading {
|
||||
} else if let Some(title) = &mut self.menu.title {
|
||||
title.poll_backup();
|
||||
let _ = title.update_toast(self.settings.notification_display_time);
|
||||
if horizon_client::ui_theme::horizon() {
|
||||
let mut ui = horizon_client::ui_theme::Ui::new(&gui.atlas, batch, self.menu.cursor);
|
||||
title.draw_horizon(&mut ui, 0.0);
|
||||
ui.finish();
|
||||
} else {
|
||||
title.draw(
|
||||
&gui.atlas,
|
||||
batch,
|
||||
self.menu.cursor,
|
||||
self.settings.file.bool("hideSplashTexts"),
|
||||
);
|
||||
}
|
||||
title.draw(
|
||||
&gui.atlas,
|
||||
batch,
|
||||
self.menu.cursor,
|
||||
self.settings.file.bool("hideSplashTexts"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -725,7 +689,6 @@ mod tests {
|
||||
// Navigate and enter a world name without a registry, biomes, pack
|
||||
// tables or a Game. Replacing fallback artwork preserves this state.
|
||||
let title = loading.menu.title.as_mut().unwrap();
|
||||
title.opening.set(None);
|
||||
title.mode = horizon_client::title::Mode::Create {
|
||||
name: String::new(),
|
||||
seed: "42".into(),
|
||||
|
||||
@@ -10,6 +10,7 @@ pub(super) const HELP: &[&str] = &[
|
||||
"use X Y Z open the block's menu as if right-clicked",
|
||||
"click-slot ID [right] [shift] click a menu slot (0-35 inventory, 300+ container, 600+ station, 650 result)",
|
||||
"click-gui X Y [right] [shift] click at GUI pixel X Y relative to the menu's top-left corner",
|
||||
"book [next|back|take|done] the open book or lectern: its page, or press one of its buttons",
|
||||
"type TEXT | type-back type into the anvil name box / delete one character",
|
||||
"enchant ID LEVEL enchant the held item (books store the enchantment)",
|
||||
"potion ITEM POTION give a potion item (potion, splash_potion, lingering_potion, tipped_arrow)",
|
||||
@@ -136,6 +137,7 @@ impl Game<'_> {
|
||||
self.apply_equipment();
|
||||
ok()
|
||||
}
|
||||
"book" => Ok(Some(self.book_control(words.get(1).copied())?)),
|
||||
"potion" => {
|
||||
let stack = ItemStack::new(&id(arg(1)?), 1).with_potion(&id(arg(2)?));
|
||||
let rest = self.give(stack);
|
||||
|
||||
@@ -13,7 +13,6 @@ use horizon_client::{
|
||||
stations::{self, AnvilResult, EnchantOffers},
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Slot ids of the open station: inputs from 600, the result at 650.
|
||||
pub(super) const INPUT: usize = 600;
|
||||
@@ -36,7 +35,7 @@ pub(super) enum Kind {
|
||||
Loom,
|
||||
}
|
||||
impl Kind {
|
||||
fn from_block(name: &str) -> Option<Self> {
|
||||
pub(super) fn from_block(name: &str) -> Option<Self> {
|
||||
Some(match name {
|
||||
"minecraft:enchanting_table" => Self::Enchanting,
|
||||
"minecraft:anvil" | "minecraft:chipped_anvil" | "minecraft:damaged_anvil" => {
|
||||
@@ -51,7 +50,7 @@ impl Kind {
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
fn inputs(self) -> usize {
|
||||
pub(super) fn inputs(self) -> usize {
|
||||
match self {
|
||||
Self::Smithing | Self::Loom => 3,
|
||||
Self::Stonecutter | Self::Beacon => 1,
|
||||
@@ -71,7 +70,7 @@ impl Kind {
|
||||
Self::Loom => "gui/container/loom",
|
||||
}
|
||||
}
|
||||
fn title_key(self) -> &'static str {
|
||||
pub(super) fn title_key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Enchanting => "container.enchant",
|
||||
Self::Anvil => "container.repair",
|
||||
@@ -108,6 +107,10 @@ impl Kind {
|
||||
};
|
||||
table[i.min(table.len() - 1)]
|
||||
}
|
||||
/// Whether the menu has a result slot after its inputs.
|
||||
pub(super) fn has_result(self) -> bool {
|
||||
self.result_pos().is_some()
|
||||
}
|
||||
fn result_pos(self) -> Option<(f32, f32)> {
|
||||
match self {
|
||||
Self::Enchanting => None,
|
||||
@@ -128,18 +131,20 @@ pub(super) struct Station {
|
||||
pub(super) pos: [i32; 3],
|
||||
pub(super) inputs: Vec<Option<ItemStack>>,
|
||||
pub(super) result: Option<ItemStack>,
|
||||
offers: EnchantOffers,
|
||||
pub(super) offers: EnchantOffers,
|
||||
bookcases: i32,
|
||||
anvil: AnvilResult,
|
||||
pub(super) anvil: AnvilResult,
|
||||
/// Anvil rename box text.
|
||||
pub(super) name: String,
|
||||
/// Stonecutter: recipe indices for the input, selection and first shown index.
|
||||
recipes: Vec<usize>,
|
||||
selected: Option<usize>,
|
||||
/// Stonecutter: the menu's recipe list (horizon_client::menu), its
|
||||
/// `selectedRecipeIndex` and the screen's first shown index.
|
||||
pub(super) stone: horizon_client::menu::Stonecutter,
|
||||
pub(super) selected: Option<usize>,
|
||||
start: usize,
|
||||
input_item: Option<Arc<str>>,
|
||||
/// Anvil: the input the rename box was last filled from.
|
||||
anvil_last: Option<ItemStack>,
|
||||
/// Anvil on a server: the name last sent (the client menu's `itemName`).
|
||||
pub(super) anvil_sent: Option<String>,
|
||||
/// When the screen opened: the rename box has the focus from then.
|
||||
opened: std::time::Instant,
|
||||
pub(super) last_sound: u64,
|
||||
@@ -147,6 +152,8 @@ pub(super) struct Station {
|
||||
pub(super) merchant: Option<Box<super::trading::MerchantState>>,
|
||||
/// Beacon menu: the selected primary and secondary power (EFFECTS index).
|
||||
pub(super) beacon: [Option<usize>; 2],
|
||||
/// A server's beacon menu: the pyramid's levels (its data slot 0).
|
||||
pub(super) beacon_levels: Option<i32>,
|
||||
/// The loom's patterns, selection and scroller (game/loom.rs).
|
||||
pub(super) loom: super::loom::LoomState,
|
||||
}
|
||||
@@ -161,21 +168,29 @@ impl Station {
|
||||
bookcases: 0,
|
||||
anvil: AnvilResult::default(),
|
||||
name: String::new(),
|
||||
recipes: Vec::new(),
|
||||
stone: Default::default(),
|
||||
selected: None,
|
||||
start: 0,
|
||||
input_item: None,
|
||||
anvil_last: None,
|
||||
anvil_sent: None,
|
||||
opened: std::time::Instant::now(),
|
||||
last_sound: 0,
|
||||
merchant: None,
|
||||
beacon: [None; 2],
|
||||
beacon_levels: None,
|
||||
loom: Default::default(),
|
||||
}
|
||||
}
|
||||
pub(super) fn new_kind(kind: Kind, pos: [i32; 3]) -> Self {
|
||||
Self::new(kind, pos)
|
||||
}
|
||||
/// `StonecutterScreen.containerChanged`: without recipes to show, the
|
||||
/// list scrolls back to the top.
|
||||
pub(super) fn stonecutter_changed(&mut self) {
|
||||
if self.inputs[0].is_none() || self.stone.recipes.is_empty() {
|
||||
self.start = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 108x19 enchantment button `i` (relative to the GUI origin).
|
||||
@@ -323,6 +338,10 @@ impl Game<'_> {
|
||||
let Some(mut station) = self.station.take() else {
|
||||
return;
|
||||
};
|
||||
if self.remote.is_some() && station.kind != Kind::Merchant {
|
||||
// A server's workstation: its removed() gives the inputs back.
|
||||
return;
|
||||
}
|
||||
if let Some(m) = station.merchant.as_mut() {
|
||||
self.merchant_closed(m);
|
||||
}
|
||||
@@ -375,7 +394,7 @@ impl Game<'_> {
|
||||
_ => &mut self.scratch_slot,
|
||||
}
|
||||
}
|
||||
fn count_bookshelves(&self, pos: [i32; 3]) -> i32 {
|
||||
pub(super) fn count_bookshelves(&self, pos: [i32; 3]) -> i32 {
|
||||
let tags = &self.res.items().block_tags;
|
||||
let is = |p: [i32; 3], tag: &str| {
|
||||
let name = &self
|
||||
@@ -399,7 +418,7 @@ impl Game<'_> {
|
||||
.count() as i32
|
||||
}
|
||||
/// The anvil name the menu works with (`AnvilScreen.onNameChanged`).
|
||||
fn anvil_item_name(&self, s: &Station) -> String {
|
||||
pub(super) fn anvil_item_name(&self, s: &Station) -> String {
|
||||
match &s.inputs[0] {
|
||||
Some(input)
|
||||
if input.custom_name().is_none() && s.name == self.pack.names.item(&input.item) =>
|
||||
@@ -419,6 +438,16 @@ impl Game<'_> {
|
||||
let data = self.res.stations();
|
||||
let items = self.res.items();
|
||||
match s.kind {
|
||||
// A server's table: its data slots carry the offers, and its
|
||||
// client menu only clears them without an enchantable item.
|
||||
Kind::Enchanting if self.remote.is_some() => {
|
||||
let enchantable = s.inputs[0].as_ref().is_some_and(|i| {
|
||||
data.enchantments.enchantable(&i.item) > 0 && i.enchantments().is_empty()
|
||||
});
|
||||
if !enchantable {
|
||||
s.offers = Default::default();
|
||||
}
|
||||
}
|
||||
Kind::Enchanting => {
|
||||
s.bookcases = self.count_bookshelves(s.pos);
|
||||
s.offers = stations::enchant_offers(
|
||||
@@ -465,35 +494,9 @@ impl Game<'_> {
|
||||
// The menu engine keeps the merchant's result (Game::menu_session).
|
||||
Kind::Merchant => {}
|
||||
Kind::Beacon => {}
|
||||
Kind::Cartography => s.result = self.cartography_result(&s),
|
||||
// The menu engine keeps the loom's state (Game::menu_session).
|
||||
Kind::Loom => {}
|
||||
Kind::Stonecutter => {
|
||||
let item = s.inputs[0].as_ref().map(|i| i.item.clone());
|
||||
if item != s.input_item {
|
||||
s.input_item = item;
|
||||
s.selected = None;
|
||||
s.start = 0;
|
||||
s.recipes = match &s.inputs[0] {
|
||||
Some(input) => data
|
||||
.recipes
|
||||
.stonecutting
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, r)| r.input.test(input))
|
||||
.map(|(i, _)| i)
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
}
|
||||
s.result = match (s.selected, &s.inputs[0]) {
|
||||
(Some(i), Some(_)) => s
|
||||
.recipes
|
||||
.get(i)
|
||||
.map(|&r| data.recipes.stonecutting[r].result.clone()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
// The menu engine keeps the cartography table's, the loom's and
|
||||
// the stonecutter's state (Game::menu_session).
|
||||
Kind::Cartography | Kind::Loom | Kind::Stonecutter => {}
|
||||
}
|
||||
self.station = Some(s);
|
||||
}
|
||||
@@ -505,7 +508,6 @@ impl Game<'_> {
|
||||
}
|
||||
/// `Slot.mayPlace` and `getMaxStackSize` for station slots.
|
||||
fn slot_rules(&self, slot: usize, stack: &ItemStack) -> (bool, u32) {
|
||||
let data = self.data();
|
||||
if slot >= super::mount_menu::SADDLE {
|
||||
return self.mount_slot_rules(slot, stack);
|
||||
}
|
||||
@@ -528,24 +530,7 @@ impl Game<'_> {
|
||||
if slot == RESULT {
|
||||
return (false, 64);
|
||||
}
|
||||
let i = slot - INPUT;
|
||||
match s.kind {
|
||||
Kind::Enchanting if i == 0 => (true, 1),
|
||||
Kind::Beacon => (super::beacon::is_payment(&stack.item), 1),
|
||||
Kind::Loom => (false, 64),
|
||||
Kind::Cartography if i == 0 => (super::maps::map_id(stack).is_some(), 64),
|
||||
Kind::Cartography => (
|
||||
matches!(
|
||||
&*stack.item,
|
||||
"minecraft:paper" | "minecraft:map" | "minecraft:glass_pane"
|
||||
),
|
||||
64,
|
||||
),
|
||||
Kind::Enchanting => (&*stack.item == "minecraft:lapis_lazuli", 64),
|
||||
Kind::Grindstone => (stations::grindstone_accepts(self.res.items(), stack), 64),
|
||||
Kind::Smithing => (data.recipes.smithing_accepts(i, stack), 64),
|
||||
_ => (true, 64),
|
||||
}
|
||||
(s.kind != Kind::Loom, 64)
|
||||
}
|
||||
/// `AbstractContainerMenu.doClick` PICKUP on a filtered slot.
|
||||
pub(super) fn click_filtered(&mut self, slot: usize, right: bool) {
|
||||
@@ -644,112 +629,80 @@ impl Game<'_> {
|
||||
}
|
||||
stack.count != start
|
||||
}
|
||||
/// The player inventory in menu order: main rows, then the hotbar.
|
||||
fn player_slots() -> Vec<usize> {
|
||||
(9..36).chain(0..9).collect()
|
||||
/// After an anvil or smithing click on the menu engine: the anvil's cost
|
||||
/// and state back into the station and the rename box following the
|
||||
/// input (`AnvilScreen.slotChanged`), then the rest of a taken result's
|
||||
/// `onTake` (the levels, the anvil's wear, the use sound) unless a
|
||||
/// server does it.
|
||||
pub(super) fn combiner_session_end(
|
||||
&mut self,
|
||||
menu: &horizon_client::menu::Menu,
|
||||
predicting: bool,
|
||||
) {
|
||||
let Some(s) = self.station.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let (pos, kind) = (s.pos, s.kind);
|
||||
if kind == Kind::Anvil {
|
||||
s.anvil.cost = menu.data[0];
|
||||
s.anvil.repair_item_count_cost = menu.anvil.repair_count;
|
||||
s.anvil.only_renaming = menu.anvil.only_renaming;
|
||||
s.anvil.result = s.result.clone();
|
||||
}
|
||||
self.station_after_input();
|
||||
if !menu.combined || predicting {
|
||||
return;
|
||||
}
|
||||
if kind == Kind::Smithing {
|
||||
self.level_event_at(1044, pos, 0);
|
||||
return;
|
||||
}
|
||||
self.give_levels(menu.level_change);
|
||||
if self.anvil_used(pos, self.creative) {
|
||||
self.close_station();
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
/// A shift-click: `AbstractContainerMenu.doClick`'s QUICK_MOVE repeats
|
||||
/// `quickMoveStack` while it moves something and the slot still holds the
|
||||
/// same item.
|
||||
fn quick_move(&mut self, slot: usize) {
|
||||
while let Some(item) = self.slot_mut(slot).as_ref().map(|s| s.item.clone())
|
||||
&& self.quick_move_once(slot)
|
||||
&& self.slot_mut(slot).as_ref().is_some_and(|s| s.item == item)
|
||||
{}
|
||||
}
|
||||
/// The station menu's `quickMoveStack` for one slot (`EnchantmentMenu`,
|
||||
/// `BeaconMenu`, `GrindstoneMenu`, `StonecutterMenu`,
|
||||
/// `CartographyTableMenu`, and `ItemCombinerMenu` with `SmithingMenu`'s
|
||||
/// `canMoveIntoInputSlots`); whether the stack's count changed. Each
|
||||
/// rule that names its target slots moves nowhere else when they are full.
|
||||
fn quick_move_once(&mut self, slot: usize) -> bool {
|
||||
let Some(kind) = self.station.as_ref().map(|s| s.kind) else {
|
||||
return false;
|
||||
};
|
||||
let Some(mut stack) = self.slot_mut(slot).take() else {
|
||||
return false;
|
||||
};
|
||||
let start = stack.count;
|
||||
let empty = |game: &mut Self, i: usize| game.station_slot_mut(INPUT + i).is_none();
|
||||
// The other half of the inventory: main rows to the hotbar and back.
|
||||
let halves: Vec<usize> = if slot < 9 {
|
||||
(9..36).collect()
|
||||
} else {
|
||||
(0..9).collect()
|
||||
};
|
||||
if slot >= INPUT {
|
||||
// An input slot: into the inventory, the hotbar's last slot first
|
||||
// where vanilla moves in reverse.
|
||||
let reverse = matches!(kind, Kind::Enchanting | Kind::Beacon);
|
||||
self.move_to(&mut stack, &Self::player_slots(), reverse);
|
||||
} else {
|
||||
match kind {
|
||||
Kind::Enchanting => {
|
||||
if &*stack.item == "minecraft:lapis_lazuli" {
|
||||
self.move_to(&mut stack, &[INPUT + 1], true);
|
||||
} else if empty(self, 0) {
|
||||
*self.station_slot_mut(INPUT) = Some(ItemStack {
|
||||
count: 1,
|
||||
..stack.clone()
|
||||
});
|
||||
stack.count -= 1;
|
||||
}
|
||||
/// `AnvilMenu.onTake`'s level part: out of creative a 12% chance
|
||||
/// (`AnvilBlock.damage`) to wear the anvil a stage, or break a damaged one
|
||||
/// (level event 1029); otherwise level event 1030. True when it broke.
|
||||
pub(super) fn anvil_used(&mut self, pos: [i32; 3], creative: bool) -> bool {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let name = self.res.facts().get(state).name.clone();
|
||||
let anvil = matches!(
|
||||
&*name,
|
||||
"minecraft:anvil" | "minecraft:chipped_anvil" | "minecraft:damaged_anvil"
|
||||
);
|
||||
if !creative && anvil && self.world.random.next_float() < 0.12 {
|
||||
match &*name {
|
||||
"minecraft:anvil" => {
|
||||
let _ = self.set_state_named(pos, "minecraft:chipped_anvil", &["facing"]);
|
||||
}
|
||||
Kind::Beacon => {
|
||||
if empty(self, 0) && super::beacon::is_payment(&stack.item) && stack.count == 1
|
||||
{
|
||||
self.move_to(&mut stack, &[INPUT], false);
|
||||
} else {
|
||||
self.move_to(&mut stack, &halves, false);
|
||||
}
|
||||
"minecraft:chipped_anvil" => {
|
||||
let _ = self.set_state_named(pos, "minecraft:damaged_anvil", &["facing"]);
|
||||
}
|
||||
Kind::Grindstone => {
|
||||
if empty(self, 0) || empty(self, 1) {
|
||||
self.move_to(&mut stack, &[INPUT, INPUT + 1], false);
|
||||
} else {
|
||||
self.move_to(&mut stack, &halves, false);
|
||||
}
|
||||
_ => {
|
||||
let _ = self.world.set(pos[0], pos[1], pos[2], self.world.air);
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
self.level_event_at(1029, pos, 0);
|
||||
return true;
|
||||
}
|
||||
Kind::Stonecutter => {
|
||||
if self.data().recipes.stonecutter(&stack).is_empty() {
|
||||
self.move_to(&mut stack, &halves, false);
|
||||
} else {
|
||||
self.move_to(&mut stack, &[INPUT], false);
|
||||
}
|
||||
}
|
||||
Kind::Cartography => {
|
||||
if super::maps::map_id(&stack).is_some() {
|
||||
self.move_to(&mut stack, &[INPUT], false);
|
||||
} else if matches!(
|
||||
&*stack.item,
|
||||
"minecraft:paper" | "minecraft:map" | "minecraft:glass_pane"
|
||||
) {
|
||||
self.move_to(&mut stack, &[INPUT + 1], false);
|
||||
} else {
|
||||
self.move_to(&mut stack, &halves, false);
|
||||
}
|
||||
}
|
||||
Kind::Anvil | Kind::Smithing => {
|
||||
let inputs: Vec<usize> = (0..kind.inputs()).map(|i| INPUT + i).collect();
|
||||
// ItemCombinerMenu.canMoveIntoInputSlots: always for the
|
||||
// anvil; the smithing table needs an empty slot whose
|
||||
// ingredient test takes the stack.
|
||||
let into_inputs = kind == Kind::Anvil
|
||||
|| (0..3).any(|i| {
|
||||
empty(self, i) && self.data().recipes.smithing_accepts(i, &stack)
|
||||
});
|
||||
let targets = if into_inputs { &inputs } else { &halves };
|
||||
self.move_to(&mut stack, targets, false);
|
||||
}
|
||||
// Their clicks run on the menu engine.
|
||||
Kind::Merchant | Kind::Loom => {}
|
||||
}
|
||||
}
|
||||
let moved = stack.count != start;
|
||||
if stack.count > 0 {
|
||||
*self.slot_mut(slot) = Some(stack);
|
||||
self.level_event_at(1030, pos, 0);
|
||||
false
|
||||
}
|
||||
/// The grindstone result's `onTake` with a level (`amount`: the menu's
|
||||
/// `getExperienceAmount` sum): the orbs at the block's centre
|
||||
/// (`ExperienceOrb.award`), then level event 1042, the grindstone's sound
|
||||
/// for everyone around.
|
||||
pub(super) fn grindstone_taken(&mut self, pos: [i32; 3], amount: i32) {
|
||||
let xp = stations::grindstone_xp_roll(amount, &mut self.world.random);
|
||||
if xp > 0 {
|
||||
self.spawn_orbs(pos.map(|v| f64::from(v) + 0.5), xp);
|
||||
}
|
||||
moved
|
||||
self.level_event_at(1042, pos, 0);
|
||||
}
|
||||
fn play_at(&mut self, sound: &str, pos: [i32; 3], volume: f32, pitch: f32) {
|
||||
let at = [
|
||||
@@ -788,185 +741,6 @@ impl Game<'_> {
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
/// The result slot's `onTake`.
|
||||
fn take_result(&mut self) {
|
||||
let Some(mut s) = self.station.take() else {
|
||||
return;
|
||||
};
|
||||
let pos = s.pos;
|
||||
match s.kind {
|
||||
Kind::Anvil => {
|
||||
let cost = s.anvil.cost;
|
||||
let repair = s.anvil.repair_item_count_cost;
|
||||
if repair > 0 {
|
||||
if let Some(a) = s.inputs[1].as_mut()
|
||||
&& a.count > repair
|
||||
{
|
||||
a.count -= repair;
|
||||
} else {
|
||||
s.inputs[1] = None;
|
||||
}
|
||||
} else if !s.anvil.only_renaming {
|
||||
s.inputs[1] = None;
|
||||
}
|
||||
s.inputs[0] = None;
|
||||
s.name.clear();
|
||||
if !self.creative {
|
||||
self.give_levels(-cost);
|
||||
}
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
if !self.creative && self.world.random.next_float() < 0.12 {
|
||||
// AnvilBlock.damage
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let name = self.res.facts().get(state).name.clone();
|
||||
let next = match &*name {
|
||||
"minecraft:anvil" => Some("minecraft:chipped_anvil"),
|
||||
"minecraft:chipped_anvil" => Some("minecraft:damaged_anvil"),
|
||||
_ => None,
|
||||
};
|
||||
match next {
|
||||
Some(next) => {
|
||||
let _ = self.set_state_named(pos, next, &["facing"]);
|
||||
self.play_at("minecraft:block.anvil.use", pos, 1.0, pitch);
|
||||
}
|
||||
None => {
|
||||
let _ = self.world.set(pos[0], pos[1], pos[2], self.world.air);
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
self.play_at("minecraft:block.anvil.destroy", pos, 1.0, pitch);
|
||||
self.station = Some(s);
|
||||
self.close_station();
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.play_at("minecraft:block.anvil.use", pos, 1.0, pitch);
|
||||
}
|
||||
}
|
||||
Kind::Grindstone => {
|
||||
let (a, b) = (s.inputs[0].take(), s.inputs[1].take());
|
||||
let mut random = LegacyRandom::new(self.world.random.next_long());
|
||||
let xp = stations::grindstone_experience(
|
||||
&self.res.stations().enchantments,
|
||||
&mut random,
|
||||
a.as_ref(),
|
||||
b.as_ref(),
|
||||
);
|
||||
if xp > 0 {
|
||||
self.spawn_orbs(
|
||||
[
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
],
|
||||
xp,
|
||||
);
|
||||
}
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
self.play_at("minecraft:block.grindstone.use", pos, 1.0, pitch);
|
||||
}
|
||||
Kind::Smithing => {
|
||||
for slot in s.inputs.iter_mut() {
|
||||
if let Some(x) = slot.as_mut() {
|
||||
x.count -= 1;
|
||||
if x.count == 0 {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
self.play_at("minecraft:block.smithing_table.use", pos, 1.0, pitch);
|
||||
}
|
||||
Kind::Stonecutter => {
|
||||
if let Some(x) = s.inputs[0].as_mut() {
|
||||
x.count -= 1;
|
||||
if x.count == 0 {
|
||||
s.inputs[0] = None;
|
||||
}
|
||||
}
|
||||
if s.last_sound != self.ticks {
|
||||
s.last_sound = self.ticks;
|
||||
self.play_at("minecraft:ui.stonecutter.take_result", pos, 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
Kind::Merchant => {}
|
||||
Kind::Beacon => {}
|
||||
Kind::Cartography => {
|
||||
for slot in &mut s.inputs {
|
||||
if let Some(x) = slot.as_mut() {
|
||||
x.count -= 1;
|
||||
if x.count == 0 {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.last_sound != self.ticks {
|
||||
s.last_sound = self.ticks;
|
||||
self.cartography_taken(pos);
|
||||
}
|
||||
}
|
||||
Kind::Loom => {}
|
||||
Kind::Enchanting => {}
|
||||
}
|
||||
self.station = Some(s);
|
||||
self.station_changed();
|
||||
}
|
||||
fn click_result(&mut self, shift: bool) {
|
||||
loop {
|
||||
let Some(result) = self.station.as_ref().and_then(|s| s.result.clone()) else {
|
||||
return;
|
||||
};
|
||||
if !self.may_take_result() {
|
||||
return;
|
||||
}
|
||||
let result = if self
|
||||
.station
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.kind == Kind::Cartography)
|
||||
{
|
||||
self.cartography_post(result)
|
||||
} else {
|
||||
result
|
||||
};
|
||||
if shift {
|
||||
let mut stack = result.clone();
|
||||
let player = Self::player_slots();
|
||||
// Simulate first so nothing is lost when the inventory is full.
|
||||
let saved: Vec<Option<ItemStack>> = self.inventory.clone();
|
||||
self.move_to(&mut stack, &player, true);
|
||||
if stack.count > 0 {
|
||||
self.inventory = saved;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
match &mut self.carried {
|
||||
None => self.carried = Some(result.clone()),
|
||||
Some(c)
|
||||
if c.same_item_same_components(&result)
|
||||
&& c.count + result.count
|
||||
<= self.res.items().get(&c.item).map_or(64, |i| i.max_stack) =>
|
||||
{
|
||||
c.count += result.count
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
if let Some(s) = self.station.as_mut() {
|
||||
s.result = None;
|
||||
}
|
||||
self.take_result();
|
||||
let again = shift
|
||||
&& self
|
||||
.station
|
||||
.as_ref()
|
||||
.and_then(|s| s.result.as_ref())
|
||||
.is_some_and(|r| r.item == result.item);
|
||||
if !again {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `EnchantmentMenu.clickMenuButton`.
|
||||
fn click_enchant(&mut self, button: usize) {
|
||||
let Some(s) = self.station.as_ref() else {
|
||||
@@ -987,6 +761,11 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
// EnchantmentScreen.mouseClicked: the client menu took it, the server enchants.
|
||||
self.remote_station_button(button as i32);
|
||||
return;
|
||||
}
|
||||
let Some(enchanted) = stations::apply_enchantment(
|
||||
&self.res.stations().enchantments,
|
||||
self.res.items(),
|
||||
@@ -1036,7 +815,7 @@ impl Game<'_> {
|
||||
})
|
||||
.collect(),
|
||||
Kind::Stonecutter if s.inputs[0].is_some() => (s.start
|
||||
..(s.start + 12).min(s.recipes.len()))
|
||||
..(s.start + 12).min(s.stone.recipes.len()))
|
||||
.map(|index| {
|
||||
let p = index - s.start;
|
||||
[
|
||||
@@ -1089,64 +868,21 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if kind == Kind::Stonecutter
|
||||
&& let Some(s) = self.station.as_ref()
|
||||
&& s.inputs[0].is_some()
|
||||
{
|
||||
for index in s.start..(s.start + 12).min(s.recipes.len()) {
|
||||
let p = index - s.start;
|
||||
let (x, y) = (52.0 + (p % 4) as f32 * 16.0, 14.0 + (p / 4) as f32 * 18.0);
|
||||
if inside(local, x, y, 16.0, 18.0) {
|
||||
if s.selected != Some(index) {
|
||||
if let Some(st) = self.station.as_mut() {
|
||||
st.selected = Some(index);
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:ui.stonecutter.select_recipe",
|
||||
None,
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
self.station_changed();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if kind == Kind::Stonecutter && self.stonecutter_click(local) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let Some(slot) = self.slot_at(&batch, self.cursor.0, self.cursor.1) else {
|
||||
return true;
|
||||
};
|
||||
if self
|
||||
.station
|
||||
.as_ref()
|
||||
.is_none_or(|s| matches!(s.kind, Kind::Loom | Kind::Merchant))
|
||||
{
|
||||
// LoomMenu, MerchantMenu and the brewing stand's BrewingStandMenu
|
||||
// clicks run on the verified menu engine.
|
||||
let input = if shift {
|
||||
horizon_client::menu::Input::QuickMove
|
||||
} else {
|
||||
horizon_client::menu::Input::Pickup
|
||||
};
|
||||
self.menu_click(Some(slot), i32::from(right), input);
|
||||
return true;
|
||||
}
|
||||
if slot == RESULT {
|
||||
self.click_result(shift);
|
||||
return true;
|
||||
}
|
||||
if shift {
|
||||
self.quick_move(slot);
|
||||
} else if slot >= INPUT {
|
||||
self.click_filtered(slot, right);
|
||||
// Every station's clicks (and the brewing stand's) run on the
|
||||
// verified menu engine.
|
||||
let input = if shift {
|
||||
horizon_client::menu::Input::QuickMove
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if let Some(key) = self.container {
|
||||
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
|
||||
}
|
||||
self.station_after_input();
|
||||
horizon_client::menu::Input::Pickup
|
||||
};
|
||||
self.menu_click(Some(slot), i32::from(right), input);
|
||||
true
|
||||
}
|
||||
/// Recompute after slot changes; the anvil box follows its input item.
|
||||
@@ -1164,16 +900,49 @@ impl Game<'_> {
|
||||
}
|
||||
self.station_changed();
|
||||
}
|
||||
/// `StonecutterScreen.mouseClicked` on its recipe grid: `clickMenuButton`
|
||||
/// with the cell's index (an empty cell's too), and the select sound when
|
||||
/// the menu took it.
|
||||
fn stonecutter_click(&mut self, local: (f32, f32)) -> bool {
|
||||
let Some(start) = self
|
||||
.station
|
||||
.as_ref()
|
||||
.filter(|s| s.inputs[0].is_some() && !s.stone.recipes.is_empty())
|
||||
.map(|s| s.start)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
for index in start..start + 12 {
|
||||
let p = index - start;
|
||||
let (x, y) = (52.0 + (p % 4) as f32 * 16.0, 14.0 + (p / 4) as f32 * 18.0);
|
||||
if !inside(local, x, y, 16.0, 18.0) {
|
||||
continue;
|
||||
}
|
||||
let mut taken = false;
|
||||
let flag = &mut taken;
|
||||
self.menu_session(|menu, env, _| {
|
||||
*flag = menu.button(env, index as i32);
|
||||
Some(*flag)
|
||||
});
|
||||
if taken {
|
||||
self.audio
|
||||
.play("minecraft:ui.stonecutter.select_recipe", None, 1.0, 1.0);
|
||||
self.remote_station_button(index as i32);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
pub(super) fn station_scroll(&mut self, d: f32) {
|
||||
self.merchant_scroll(d);
|
||||
self.loom_scroll(d);
|
||||
let Some(s) = self.station.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if s.kind != Kind::Stonecutter || s.recipes.len() <= 12 {
|
||||
if s.kind != Kind::Stonecutter || s.stone.recipes.len() <= 12 {
|
||||
return;
|
||||
}
|
||||
let rows = s.recipes.len().div_ceil(4) - 3;
|
||||
let rows = s.stone.recipes.len().div_ceil(4) - 3;
|
||||
let first_row = (s.start / 4) as i32 - d.signum() as i32;
|
||||
s.start = first_row.clamp(0, rows as i32) as usize * 4;
|
||||
}
|
||||
@@ -1207,6 +976,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.station_changed();
|
||||
self.remote_anvil_rename();
|
||||
}
|
||||
/// Ctrl+A/C/V/X in the anvil's rename box (an `EditBox` of 50) or the
|
||||
/// recipe book's search box.
|
||||
@@ -1227,6 +997,7 @@ impl Game<'_> {
|
||||
ANVIL_NAME_CHARS,
|
||||
) {
|
||||
self.station_changed();
|
||||
self.remote_anvil_rename();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1740,9 +1511,9 @@ impl Game<'_> {
|
||||
}
|
||||
Kind::Merchant | Kind::Beacon | Kind::Cartography | Kind::Loom => {}
|
||||
Kind::Stonecutter => {
|
||||
let display = s.inputs[0].is_some() && !s.recipes.is_empty();
|
||||
let active = display && s.recipes.len() > 12;
|
||||
let rows = s.recipes.len().div_ceil(4).saturating_sub(3).max(1);
|
||||
let display = s.inputs[0].is_some() && !s.stone.recipes.is_empty();
|
||||
let active = display && s.stone.recipes.len() > 12;
|
||||
let rows = s.stone.recipes.len().div_ceil(4).saturating_sub(3).max(1);
|
||||
let offs = if active {
|
||||
(s.start / 4) as f32 / rows as f32
|
||||
} else {
|
||||
@@ -1763,13 +1534,14 @@ impl Game<'_> {
|
||||
let (start, selected) = (s.start, s.selected);
|
||||
// graphics.item: the icon only, without the count.
|
||||
let shown: Vec<(usize, ItemStack)> = (start
|
||||
..(start + 12).min(s.recipes.len()))
|
||||
..(start + 12).min(s.stone.recipes.len()))
|
||||
.map(|i| {
|
||||
(
|
||||
i,
|
||||
ItemStack {
|
||||
count: 1,
|
||||
..self.res.stations().recipes.stonecutting[s.recipes[i]]
|
||||
..self.res.stations().recipes.stonecutting
|
||||
[s.stone.recipes[i]]
|
||||
.result
|
||||
.clone()
|
||||
},
|
||||
@@ -2082,7 +1854,7 @@ impl Game<'_> {
|
||||
"bookcases": s.bookcases,
|
||||
"anvil_cost": s.anvil.cost,
|
||||
"result": s.result.as_ref().map(|r| format!("{} x{}", r.item, r.count)),
|
||||
"recipes": s.recipes.iter().map(|&i| recipes[i].id.clone()).collect::<Vec<_>>(),
|
||||
"recipes": s.stone.recipes.iter().map(|&i| recipes[i].id.clone()).collect::<Vec<_>>(),
|
||||
"selected": s.selected,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,6 +122,38 @@ impl Game<'_> {
|
||||
let sky = self.world.light(x, y + 1, z) >> 4 == 15;
|
||||
water || (self.world.weather.raining && sky)
|
||||
}
|
||||
/// The living entities a spinning player can touch this tick, for
|
||||
/// `LivingEntity.checkAutoSpinAttack` (none when not spinning).
|
||||
pub(super) fn spin_targets(&self) -> Vec<(u32, Aabb)> {
|
||||
let Some(p) = self
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|p| p.auto_spin_attack_ticks > 0)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
// A spin moves a few blocks a tick at most.
|
||||
let near = p.bounds().inflate(Vec3::new(16.0, 16.0, 16.0));
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive() && m.bounds().intersects(near))
|
||||
.map(|m| (m.id, m.bounds()))
|
||||
.collect()
|
||||
}
|
||||
/// `Player.doAutoAttackOnTouch`: the touched entity takes `Player.attack`
|
||||
/// with the spin's damage; once the spin is over its damage is cleared.
|
||||
pub(super) fn end_spin_attack(&mut self, hit: Option<u32>) {
|
||||
if let Some(id) = hit
|
||||
&& let Some(index) = self.mobs.iter().position(|m| m.id == id)
|
||||
{
|
||||
self.attack_mob(index);
|
||||
}
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& p.auto_spin_attack_ticks <= 0
|
||||
{
|
||||
p.auto_spin_attack_dmg = 0.0;
|
||||
}
|
||||
}
|
||||
/// `TridentItem.releaseUsing`.
|
||||
pub(super) fn release_trident(&mut self, ticks: u32) {
|
||||
if ticks < 10 {
|
||||
@@ -152,6 +184,9 @@ impl Game<'_> {
|
||||
p.velocity.y + d[1] * f64::from(strength),
|
||||
p.velocity.z + d[2] * f64::from(strength),
|
||||
);
|
||||
// Player.startAutoSpinAttack(20, 8, stack).
|
||||
p.auto_spin_attack_ticks = 20;
|
||||
p.auto_spin_attack_dmg = 8.0;
|
||||
if p.ground.on_ground {
|
||||
p.position.y += 1.2;
|
||||
}
|
||||
|
||||
@@ -194,7 +194,9 @@ fn carry_over(from: &Mob, to: &mut Mob) {
|
||||
to.fire_ticks = from.fire_ticks;
|
||||
to.persistent = from.persistent;
|
||||
to.custom_name = from.custom_name.clone();
|
||||
to.custom_name_text = from.custom_name_text.clone();
|
||||
to.name_visible = from.name_visible;
|
||||
to.invisible = from.invisible;
|
||||
to.extra.pick_up_loot = from.extra.pick_up_loot;
|
||||
to.extra.main_hand = from.extra.main_hand.clone();
|
||||
to.extra.off_hand = from.extra.off_hand.clone();
|
||||
|
||||
@@ -33,6 +33,7 @@ fn entity_shader() -> String {
|
||||
}
|
||||
|
||||
mod entity_batch;
|
||||
mod weather;
|
||||
use entity_batch::EntityBatch;
|
||||
pub use entity_batch::WATER_MASK_OVERLAY;
|
||||
|
||||
@@ -61,8 +62,10 @@ pub fn outline_vertices(edges: &[[f64; 6]], camera: glam::DVec3) -> Vec<[f32; 6]
|
||||
|
||||
mod arena;
|
||||
mod cull;
|
||||
mod end_sky;
|
||||
mod flat;
|
||||
mod java_stages;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
mod occlusion;
|
||||
mod pack_stages;
|
||||
@@ -107,13 +110,16 @@ struct Globals {
|
||||
fog_ends: [f32; 4],
|
||||
/// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png.
|
||||
end_portal_sprites: [[f32; 4]; 2],
|
||||
/// x: vanilla's `GameTime` uniform; y: nether cardinal lighting, zw unused.
|
||||
/// x: vanilla's `GameTime` uniform; y: nether cardinal lighting; z: how many
|
||||
/// `dynamic_lights` are set; w unused.
|
||||
time: [f32; 4],
|
||||
/// Atlas rect of a destroy stage (entity.wgsl takes only its size: every stage's is the same,
|
||||
/// and each crack carries its own stage's origin).
|
||||
crumbling_sprite: [f32; 4],
|
||||
/// Framebuffer size, selection stroke width, perspective layering scale.
|
||||
outline_params: [f32; 4],
|
||||
/// Horizon Extras' dynamic lights: camera-relative position and level (`dynamic_light`).
|
||||
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
|
||||
}
|
||||
pub type SectionKey = (i32, i32, i32);
|
||||
struct SectionGpu {
|
||||
@@ -160,8 +166,10 @@ pub struct Frame<'a> {
|
||||
pub title: bool,
|
||||
/// Dimensions without a skybox (the Nether) clear to the fog colour.
|
||||
pub skybox: bool,
|
||||
/// The End: clear to black and draw `celestial` (the End sky box) behind the world.
|
||||
/// The End: clear to black and draw the resident sky box behind the world.
|
||||
pub end_sky: bool,
|
||||
/// End sky atlas bounds (u0, v0, u1, v1); absent when the sprite is missing.
|
||||
pub end_sky_sprite: Option<[f32; 4]>,
|
||||
/// Environmental fog start/end (Nether 10..96), if any.
|
||||
pub environment_fog: Option<(f32, f32)>,
|
||||
pub water_fog: [f32; 3],
|
||||
@@ -186,14 +194,20 @@ pub struct Frame<'a> {
|
||||
pub nether_lighting: bool,
|
||||
/// Camera-relative entity geometry textured from the shared atlas.
|
||||
pub entities: &'a [Vertex],
|
||||
pub weather: &'a [crate::weather::Vertex],
|
||||
pub weather_time: crate::weather::Time,
|
||||
/// Horizon Extras' dynamic lights, nearest first (`dynamic_light::select`); empty when off.
|
||||
pub dynamic_lights: &'a [[f32; 4]],
|
||||
/// Cloud mesh inputs; geometry and GPU storage are retained by the renderer.
|
||||
pub clouds: Option<crate::clouds::Input<'a>>,
|
||||
/// Additive translucent geometry after the entities (lightning, dragon rays).
|
||||
/// Retained lightning bolts, drawn before the other additive entities.
|
||||
pub lightning: lightning::Input<'a>,
|
||||
/// Additive translucent geometry after lightning (dragon rays).
|
||||
pub additive: &'a [Vertex],
|
||||
/// Name tags over everything, without depth (`TEXT_SEE_THROUGH`).
|
||||
pub see_through: &'a [Vertex],
|
||||
/// Camera-relative sun and moon, or the End sky box (drawn behind
|
||||
/// everything, additive).
|
||||
/// Camera-relative sun and moon, or the animated End flash (additive,
|
||||
/// behind everything and after the resident End sky box).
|
||||
pub celestial: &'a [Vertex],
|
||||
/// The stars, drawn from their static buffer after the sun and moon:
|
||||
/// the sky's rotation (camera-relative world from star space) and the
|
||||
@@ -225,8 +239,6 @@ pub struct Frame<'a> {
|
||||
pub night_vision: f32,
|
||||
/// Torch flicker added to the block light factor of 1.4.
|
||||
pub block_flicker: f32,
|
||||
/// Blur and grade the frame under a Horizon-style menu (`post`).
|
||||
pub post: Option<crate::post::PostGrade>,
|
||||
/// `Screen.extractBlurredBackground`: blur the frame by this radius
|
||||
/// (`menuBackgroundBlurriness`) before the screen is drawn over it.
|
||||
pub menu_blur: Option<f32>,
|
||||
@@ -319,6 +331,7 @@ pub struct Renderer {
|
||||
crosshair_group: wgpu::BindGroup,
|
||||
crosshair_globals: wgpu::Buffer,
|
||||
entity_pipeline: wgpu::RenderPipeline,
|
||||
weather: weather::Weather,
|
||||
crumbling_pipeline: wgpu::RenderPipeline,
|
||||
/// `RenderPipelines.WATER_MASK`: depth writes only, no colour.
|
||||
water_mask_pipeline: wgpu::RenderPipeline,
|
||||
@@ -329,9 +342,12 @@ pub struct Renderer {
|
||||
cloud_uniform: wgpu::Buffer,
|
||||
cloud_group: wgpu::BindGroup,
|
||||
additive_pipeline: wgpu::RenderPipeline,
|
||||
lightning_pipeline: wgpu::RenderPipeline,
|
||||
lightning: lightning::Renderer,
|
||||
see_through_pipeline: wgpu::RenderPipeline,
|
||||
celestial_pipeline: wgpu::RenderPipeline,
|
||||
celestial_buffer: wgpu::Buffer,
|
||||
end_sky: end_sky::EndSky,
|
||||
stars: stars::Stars,
|
||||
panorama: panorama::Panorama,
|
||||
/// `RenderPipelines.WORLD_BORDER` and its buffers.
|
||||
@@ -432,8 +448,6 @@ pub struct Renderer {
|
||||
/// The selected shader pack's stages (Horizon setting; none by default).
|
||||
pack: Option<Pack>,
|
||||
pub hud: HudRenderer,
|
||||
/// The menus' backdrop blur, made on first use.
|
||||
post: Option<crate::post::Post>,
|
||||
/// Vanilla's menu background blur, made on first use.
|
||||
menu_blur: Option<crate::menu_blur::MenuBlur>,
|
||||
pub info: wgpu::AdapterInfo,
|
||||
@@ -1440,6 +1454,12 @@ impl Renderer {
|
||||
),
|
||||
],
|
||||
});
|
||||
let weather = weather::Weather::new(
|
||||
&device,
|
||||
config.format.remove_srgb_suffix(),
|
||||
&entity_bindings,
|
||||
&entity_shader,
|
||||
);
|
||||
let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Atlas"),
|
||||
..Default::default()
|
||||
@@ -1659,8 +1679,17 @@ impl Renderer {
|
||||
alpha_blend,
|
||||
false,
|
||||
);
|
||||
let lightning_attributes = wgpu::vertex_attr_array![6=>Float32x3];
|
||||
let lightning_vertex = [
|
||||
entity_vertex[0].clone(),
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<[f32; 3]>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &lightning_attributes,
|
||||
}),
|
||||
];
|
||||
// BlendFunction.LIGHTNING: src alpha, one; no depth writes.
|
||||
let additive_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
let additive_descriptor = wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Additive entities"),
|
||||
layout: Some(&entity_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
@@ -1704,6 +1733,16 @@ impl Renderer {
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
};
|
||||
let additive_pipeline = device.create_render_pipeline(&additive_descriptor);
|
||||
let lightning_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Lightning"),
|
||||
vertex: wgpu::VertexState {
|
||||
entry_point: Some("vs_lightning"),
|
||||
buffers: &lightning_vertex,
|
||||
..additive_descriptor.vertex.clone()
|
||||
},
|
||||
..additive_descriptor
|
||||
});
|
||||
let celestial_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Celestial bodies"),
|
||||
@@ -2096,6 +2135,7 @@ impl Renderer {
|
||||
crosshair_group,
|
||||
crosshair_globals,
|
||||
entity_pipeline,
|
||||
weather,
|
||||
crumbling_pipeline,
|
||||
water_mask_pipeline,
|
||||
cloud_pipeline,
|
||||
@@ -2105,9 +2145,12 @@ impl Renderer {
|
||||
cloud_uniform,
|
||||
cloud_group,
|
||||
additive_pipeline,
|
||||
lightning_pipeline,
|
||||
lightning: lightning::Renderer::new(),
|
||||
see_through_pipeline,
|
||||
celestial_pipeline,
|
||||
celestial_buffer,
|
||||
end_sky: end_sky::EndSky::default(),
|
||||
stars,
|
||||
panorama,
|
||||
border_pipeline,
|
||||
@@ -2176,7 +2219,6 @@ impl Renderer {
|
||||
world_post,
|
||||
pack: None,
|
||||
hud,
|
||||
post: None,
|
||||
menu_blur: None,
|
||||
info,
|
||||
suspended: size.width == 0 || size.height == 0,
|
||||
@@ -2908,10 +2950,17 @@ impl Renderer {
|
||||
time: [
|
||||
frame.shader_game_time,
|
||||
f32::from(frame.nether_lighting),
|
||||
0.0,
|
||||
frame.dynamic_lights.len().min(crate::dynamic_light::MAX) as f32,
|
||||
0.0,
|
||||
],
|
||||
outline_params: outline_params(self.config.width, self.config.height),
|
||||
dynamic_lights: {
|
||||
let mut lights = [[0.0; 4]; crate::dynamic_light::MAX];
|
||||
for (to, from) in lights.iter_mut().zip(frame.dynamic_lights) {
|
||||
*to = *from;
|
||||
}
|
||||
lights
|
||||
},
|
||||
};
|
||||
self.uploads
|
||||
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
|
||||
@@ -3217,6 +3266,13 @@ impl Renderer {
|
||||
bytemuck::cast_slice(&all),
|
||||
);
|
||||
}
|
||||
self.weather.prepare(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
&mut self.uploads,
|
||||
frame.weather,
|
||||
frame.weather_time,
|
||||
);
|
||||
let outline_edges = frame.outline.len().min(MAX_OUTLINE_EDGES as usize);
|
||||
if outline_edges > 0 {
|
||||
let lines = outline_vertices(&frame.outline[..outline_edges], camera);
|
||||
@@ -3234,6 +3290,7 @@ impl Renderer {
|
||||
frame.hand,
|
||||
frame.see_through,
|
||||
);
|
||||
let weather_quads = frame.weather.len() as u32 / 4;
|
||||
let world_quads =
|
||||
entity_quads + crumbling_quads + translucent_crumbling_quads + water_mask_quads;
|
||||
let cloud_quads = if let Some(input) = &frame.clouds {
|
||||
@@ -3267,6 +3324,13 @@ impl Renderer {
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.lightning.prepare(
|
||||
&self.device,
|
||||
&mut self.uploads,
|
||||
&mut encoder,
|
||||
&frame.lightning,
|
||||
camera,
|
||||
);
|
||||
let all_entities = &self.entity_batch.combined;
|
||||
let hand_quads = (frame.hand.len() / 4) as u32;
|
||||
let additive_quads = (frame.additive.len() / 4) as u32;
|
||||
@@ -3298,6 +3362,9 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
let celestial_vertices = frame.celestial.len().min(2048 * 4);
|
||||
if frame.end_sky && !frame.title {
|
||||
self.end_sky.prepare(&self.device, frame.end_sky_sprite);
|
||||
}
|
||||
if celestial_vertices > 0 {
|
||||
self.uploads.buffer(
|
||||
&mut encoder,
|
||||
@@ -3306,8 +3373,14 @@ impl Renderer {
|
||||
bytemuck::cast_slice(&frame.celestial[..celestial_vertices]),
|
||||
);
|
||||
}
|
||||
let max_quads =
|
||||
(world_quads + additive_quads + hand_quads + see_through_quads).max(cloud_quads);
|
||||
let max_quads = (world_quads + additive_quads + hand_quads + see_through_quads)
|
||||
.max(cloud_quads)
|
||||
.max(if frame.lightning.bolts.is_empty() {
|
||||
0
|
||||
} else {
|
||||
lightning::QUADS
|
||||
})
|
||||
.max(weather_quads);
|
||||
if max_quads > self.index_quads {
|
||||
self.index_quads = max_quads.next_power_of_two();
|
||||
self.indices = Self::quad_indices(&self.device, self.index_quads);
|
||||
@@ -3498,12 +3571,17 @@ impl Renderer {
|
||||
if frame.title {
|
||||
self.panorama.draw(&mut pass);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
} else if celestial_vertices > 0 {
|
||||
} else if celestial_vertices > 0 || frame.end_sky {
|
||||
pass.set_pipeline(&self.celestial_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.celestial_buffer.slice(..));
|
||||
pass.draw_indexed(0..(celestial_vertices as u32 / 4) * 6, 0, 0..1);
|
||||
if frame.end_sky {
|
||||
self.end_sky.draw(&mut pass);
|
||||
}
|
||||
if celestial_vertices > 0 {
|
||||
pass.set_vertex_buffer(0, self.celestial_buffer.slice(..));
|
||||
pass.draw_indexed(0..(celestial_vertices as u32 / 4) * 6, 0, 0..1);
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if !frame.title && frame.stars.is_some() {
|
||||
@@ -3590,6 +3668,8 @@ impl Renderer {
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
self.weather.draw(&mut pass, &self.entity_group);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
if crumbling_quads > 0 {
|
||||
pass.set_pipeline(&self.crumbling_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
@@ -3610,6 +3690,12 @@ impl Renderer {
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if !frame.lightning.bolts.is_empty() {
|
||||
pass.set_pipeline(&self.lightning_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
self.lightning.draw(&mut pass, &frame.lightning);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if additive_quads > 0 {
|
||||
pass.set_pipeline(&self.additive_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
@@ -3829,9 +3915,19 @@ impl Renderer {
|
||||
let first = debug_depth + debug_top;
|
||||
pass.draw(0..4, first..first + crosshair);
|
||||
}
|
||||
if let Some(radius) = frame.menu_blur
|
||||
&& self.config.usage.contains(wgpu::TextureUsages::COPY_SRC)
|
||||
{
|
||||
let menu_blur = frame
|
||||
.menu_blur
|
||||
.filter(|_| self.config.usage.contains(wgpu::TextureUsages::COPY_SRC));
|
||||
let blur_at = menu_blur.map_or(0, |_| frame.hud.blur_at);
|
||||
self.hud.draw_range(
|
||||
&mut encoder,
|
||||
&view_target,
|
||||
&self.depth,
|
||||
frame.hud,
|
||||
[self.config.width, self.config.height],
|
||||
0..blur_at,
|
||||
);
|
||||
if let Some(radius) = menu_blur {
|
||||
let format = self.config.format;
|
||||
let device = &self.device;
|
||||
self.menu_blur
|
||||
@@ -3846,29 +3942,13 @@ impl Renderer {
|
||||
radius,
|
||||
);
|
||||
}
|
||||
if let Some(grade) = &frame.post
|
||||
&& self.config.usage.contains(wgpu::TextureUsages::COPY_SRC)
|
||||
{
|
||||
let format = self.config.format;
|
||||
let device = &self.device;
|
||||
self.post
|
||||
.get_or_insert_with(|| crate::post::Post::new(device, format))
|
||||
.apply(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&mut encoder,
|
||||
&target_texture,
|
||||
&view_target,
|
||||
[self.config.width, self.config.height],
|
||||
grade,
|
||||
);
|
||||
}
|
||||
self.hud.draw(
|
||||
self.hud.draw_range(
|
||||
&mut encoder,
|
||||
&view_target,
|
||||
&self.depth,
|
||||
frame.hud,
|
||||
[self.config.width, self.config.height],
|
||||
blur_at..frame.hud.vertices.len() as u32,
|
||||
);
|
||||
if culling && let Some(cull) = &mut self.cull {
|
||||
cull.end(&self.device, &mut encoder);
|
||||
@@ -4155,6 +4235,34 @@ mod tests {
|
||||
);
|
||||
assert_eq!(surface_format(&[Rgba16Float], Bgra8UnormSrgb), Rgba16Float);
|
||||
}
|
||||
/// A Wayland surface on a compositor with colour management, as wgpu lists it (Mesa 26.2
|
||||
/// ANV on an Intel HD 620 under weston 16): wide formats first. The pre-#1075 choice,
|
||||
/// the first non-sRGB format, took `Rgba16Float` and the window looked washed out, as if
|
||||
/// under a white overlay (#1007); XWayland lists no such format, which is why it looked fine.
|
||||
#[test]
|
||||
fn a_colour_managed_wayland_surface_still_gets_8_bit() {
|
||||
use super::surface_format;
|
||||
use wgpu::TextureFormat::*;
|
||||
let wayland = [
|
||||
Rgba16Float,
|
||||
Rgba16Unorm,
|
||||
Rgb10a2Unorm,
|
||||
Bgra8UnormSrgb,
|
||||
Bgra8Unorm,
|
||||
Rgba8UnormSrgb,
|
||||
Rgba8Unorm,
|
||||
];
|
||||
assert_eq!(surface_format(&wayland, Bgra8UnormSrgb), Bgra8Unorm);
|
||||
// Without Bgra8: Rgba8, still not a 16- or 10-bit format.
|
||||
let without_bgra = [
|
||||
Rgba16Float,
|
||||
Rgba16Unorm,
|
||||
Rgb10a2Unorm,
|
||||
Rgba8UnormSrgb,
|
||||
Rgba8Unorm,
|
||||
];
|
||||
assert_eq!(surface_format(&without_bgra, Rgba8UnormSrgb), Rgba8Unorm);
|
||||
}
|
||||
#[test]
|
||||
fn special_material_batches_preserve_quad_order_and_offsets() {
|
||||
let quad = |layer, id| {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! The End sky's six tiled faces, retained for the lifetime of the renderer's atlas.
|
||||
use crate::mesher::Vertex;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
const VERTICES: u32 = 6 * 16 * 16 * 4;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct EndSky {
|
||||
mesh: Option<([f32; 4], wgpu::Buffer)>,
|
||||
}
|
||||
|
||||
impl EndSky {
|
||||
/// Called only when the End sky is enabled. Rebuild on atlas UV changes;
|
||||
/// resource reload and device recreation also replace the owning renderer.
|
||||
pub(super) fn prepare(&mut self, device: &wgpu::Device, uv: Option<[f32; 4]>) {
|
||||
let Some(uv) = uv else {
|
||||
self.mesh = None;
|
||||
return;
|
||||
};
|
||||
if self.mesh.as_ref().is_some_and(|(old, _)| *old == uv) {
|
||||
return;
|
||||
}
|
||||
let vertices = vertices(uv);
|
||||
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("End sky vertices"),
|
||||
contents: bytemuck::cast_slice(&vertices),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
self.mesh = Some((uv, buffer));
|
||||
}
|
||||
|
||||
/// The celestial pipeline, atlas bind group and quad indices are already bound.
|
||||
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
|
||||
if let Some((_, buffer)) = &self.mesh {
|
||||
pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
pass.draw_indexed(0..VERTICES / 4 * 6, 0, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn vertices(uv: [f32; 4]) -> Vec<Vertex> {
|
||||
let mut out = Vec::with_capacity(VERTICES as usize);
|
||||
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, [uv[0], uv[1]]),
|
||||
(x0, z1, [uv[0], uv[3]]),
|
||||
(x1, z1, [uv[2], uv[3]]),
|
||||
(x1, z0, [uv[2], uv[1]]),
|
||||
];
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,330 @@
|
||||
use super::*;
|
||||
use crate::gfx::{Globals, Renderer};
|
||||
use bytemuck::Zeroable;
|
||||
use glam::Mat4;
|
||||
|
||||
#[test]
|
||||
fn geometry_matches_pre_residency_vertex_snapshots() {
|
||||
use sha2::{Digest, Sha256};
|
||||
// Captured from game/end.rs at 34c3c1cf for full, tiled and non-binary UVs.
|
||||
// Quantize positions only, avoiding platform sin/cos rounding differences;
|
||||
// quad order, UVs, tint, material and light retain their exact byte values.
|
||||
for (uv, expected) in [
|
||||
(
|
||||
[0.0, 0.0, 1.0, 1.0],
|
||||
"f7ee23457fc557b33ecf09c9afaf858f3d8907e108abf06055f627523f733536",
|
||||
),
|
||||
(
|
||||
[0.125, 0.25, 0.375, 0.5],
|
||||
"f74c4b8d23ec2b7c11b5752821d0733851bf11358f1b26f9448226ff62a6a318",
|
||||
),
|
||||
(
|
||||
[0.13, 0.27, 0.41, 0.59],
|
||||
"f0aaa39c5b22168bdc7c6c329acb4d3598a2feb61ff7b6f2850b644682749b39",
|
||||
),
|
||||
] {
|
||||
let mut mesh = vertices(uv);
|
||||
assert_eq!(mesh.len(), VERTICES as usize);
|
||||
for vertex in &mut mesh {
|
||||
vertex.pos = vertex.pos.map(|x| {
|
||||
let n = (x * 1024.0).round() / 1024.0;
|
||||
if n == 0.0 { 0.0 } else { n }
|
||||
});
|
||||
}
|
||||
assert_eq!(
|
||||
format!("{:x}", Sha256::digest(bytemuck::cast_slice(&mesh))),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
eprintln!("End sky test adapter: {:?}", adapter.get_info());
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resident_buffer_survives_frames_and_refreshes_with_the_atlas() {
|
||||
let Some((device, _)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let mut sky = EndSky::default();
|
||||
let uv = [0.125, 0.25, 0.375, 0.5];
|
||||
sky.prepare(&device, Some(uv));
|
||||
let first = sky.mesh.as_ref().unwrap().1.clone();
|
||||
assert_eq!(
|
||||
first.size(),
|
||||
u64::from(VERTICES) * size_of::<Vertex>() as u64
|
||||
);
|
||||
// Immutable: steady-state frames cannot upload into this buffer.
|
||||
assert_eq!(first.usage(), wgpu::BufferUsages::VERTEX);
|
||||
for _ in 0..600 {
|
||||
sky.prepare(&device, Some(uv));
|
||||
assert_eq!(sky.mesh.as_ref().unwrap().1, first);
|
||||
}
|
||||
let moved = [0.5, 0.5, 0.75, 0.75];
|
||||
sky.prepare(&device, Some(moved));
|
||||
assert_ne!(sky.mesh.as_ref().unwrap().1, first);
|
||||
assert_eq!(sky.mesh.as_ref().unwrap().0, moved);
|
||||
sky.prepare(&device, None);
|
||||
assert!(sky.mesh.is_none());
|
||||
sky.prepare(&device, Some(uv));
|
||||
assert_ne!(sky.mesh.as_ref().unwrap().1, first);
|
||||
// A resource reload/device recreation starts a new renderer-owned cache,
|
||||
// even if the replacement atlas happens to use exactly the same UVs.
|
||||
let mut replacement = EndSky::default();
|
||||
replacement.prepare(&device, Some(uv));
|
||||
assert_ne!(
|
||||
replacement.mesh.as_ref().unwrap().1,
|
||||
sky.mesh.as_ref().unwrap().1
|
||||
);
|
||||
}
|
||||
|
||||
/// The actual entity shader and celestial blend, comparing the old combined
|
||||
/// vertex stream with a resident sky draw followed by a separate flash draw.
|
||||
#[test]
|
||||
fn separate_draws_match_combined_celestial_pixels() {
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("End sky differential shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(crate::gfx::entity_shader().into()),
|
||||
});
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4];
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("End sky differential pipeline"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_entity"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_celestial"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent::OVER,
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let texture = |pixels: &[u8], width, height| {
|
||||
device.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
pixels,
|
||||
)
|
||||
};
|
||||
// Asymmetric, translucent texels expose UV/winding and blend-order errors.
|
||||
let pixels: Vec<u8> = (0..16 * 16)
|
||||
.flat_map(|i| {
|
||||
[
|
||||
(i * 17) as u8,
|
||||
(i * 31) as u8,
|
||||
(i * 7) as u8,
|
||||
(80 + i % 176) as u8,
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let atlas = texture(&pixels, 16, 16).create_view(&Default::default());
|
||||
let lightmap = texture(&[255; 4], 1, 1).create_view(&Default::default());
|
||||
let sampler = device.create_sampler(&Default::default());
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: size_of::<Globals>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniform.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&atlas),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&lightmap),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
let indices = Renderer::quad_indices(&device, VERTICES / 4 + 1);
|
||||
let mut resident = EndSky::default();
|
||||
let uv = [0.125, 0.25, 0.375, 0.5];
|
||||
resident.prepare(&device, Some(uv));
|
||||
let first = resident.mesh.as_ref().unwrap().1.clone();
|
||||
let render = |sky: &EndSky, flash: &[Vertex], combined: bool| {
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 64,
|
||||
height: 64,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = target.create_view(&Default::default());
|
||||
let mut vertices = if combined { vertices(uv) } else { Vec::new() };
|
||||
vertices.extend_from_slice(flash);
|
||||
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&vertices),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 64 * 64 * 4,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
if !combined {
|
||||
sky.draw(&mut pass);
|
||||
}
|
||||
if !vertices.is_empty() {
|
||||
pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
pass.draw_indexed(0..vertices.len() as u32 / 4 * 6, 0, 0..1);
|
||||
}
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
target.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &readback,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: Some(64),
|
||||
},
|
||||
},
|
||||
target.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
readback
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
readback.slice(..).get_mapped_range().unwrap().to_vec()
|
||||
};
|
||||
let mut cases = 0;
|
||||
let mut visible_flash_cases = 0;
|
||||
for yaw in [0.0_f32, 45.0, 90.0, 180.0, 270.0] {
|
||||
for pitch in [-85.0_f32, -30.0, 0.0, 45.0, 85.0] {
|
||||
let mut globals = Globals::zeroed();
|
||||
globals.view_proj = (Mat4::perspective_rh(90_f32.to_radians(), 1.0, 0.05, 1024.0)
|
||||
* Mat4::from_rotation_x(pitch.to_radians())
|
||||
* Mat4::from_rotation_y(yaw.to_radians()))
|
||||
.to_cols_array_2d();
|
||||
queue.write_buffer(&uniform, 0, bytemuck::bytes_of(&globals));
|
||||
let no_flash = render(&resident, &[], true);
|
||||
for level in [0_u32, 1, 127, 255] {
|
||||
let flash: Vec<_> = if level == 0 {
|
||||
Vec::new()
|
||||
} else {
|
||||
// Same flash size/height, with an arbitrary EndFlashState pose.
|
||||
let pose = Mat4::from_rotation_y(1.3) * Mat4::from_rotation_x(-1.6);
|
||||
[(-60.0, -60.0), (60.0, -60.0), (60.0, 60.0), (-60.0, 60.0)]
|
||||
.map(|(x, z)| Vertex {
|
||||
pos: pose.transform_point3(glam::vec3(x, 100.0, z)).to_array(),
|
||||
uv: [0.75 + x / 480.0, 0.75 + z / 480.0],
|
||||
layer: 2,
|
||||
color: level * 0x01010101,
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
})
|
||||
.to_vec()
|
||||
};
|
||||
resident.prepare(&device, Some(uv));
|
||||
let expected = render(&resident, &flash, true);
|
||||
let actual = render(&resident, &flash, false);
|
||||
visible_flash_cases += usize::from(expected != no_flash);
|
||||
assert_eq!(actual, expected, "yaw={yaw} pitch={pitch} flash={level}");
|
||||
assert!(
|
||||
actual.as_chunks::<4>().0.iter().any(|p| p[..3] != [0; 3]),
|
||||
"empty frame"
|
||||
);
|
||||
assert_eq!(resident.mesh.as_ref().unwrap().1, first);
|
||||
cases += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
visible_flash_cases > 0,
|
||||
"the flash must affect the comparison"
|
||||
);
|
||||
eprintln!("{cases} combined/resident framebuffer cases matched exactly");
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Seeded, bolt-local lightning meshes retained until their last active bolt disappears.
|
||||
use crate::{gfx::upload::Uploads, mesher::Vertex};
|
||||
use glam::DVec3;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use std::collections::HashMap;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
/// A bolt's render state. Simulation lifetime and flashes stay in the game.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Bolt {
|
||||
pub at: [f64; 3],
|
||||
pub seed: i64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Input<'a> {
|
||||
pub bolts: &'a [Bolt],
|
||||
/// Current atlas white texel; a changed rect invalidates retained vertices.
|
||||
pub uv: [f32; 2],
|
||||
}
|
||||
|
||||
// Four layers, fourteen segments (eight trunk, two branches of three), four quads.
|
||||
pub(super) const QUADS: u32 = 4 * 14 * 4;
|
||||
|
||||
/// `LightningBoltRenderer.submit`: keep random draws, layers and quad order unchanged.
|
||||
fn geometry(seed: i64, uv: [f32; 2]) -> Vec<Vertex> {
|
||||
let mut out = Vec::with_capacity(QUADS as usize * 4);
|
||||
// ARGB 0.45, 0.45, 0.5 at 0.3 alpha, packed RGBA.
|
||||
let color = (76u32 << 24) | (127 << 16) | (114 << 8) | 114;
|
||||
let mut random = LegacyRandom::new(seed);
|
||||
let (mut xs, mut zs) = ([0f32; 8], [0f32; 8]);
|
||||
let (mut x_off, mut z_off) = (0f32, 0f32);
|
||||
for i in (0..8).rev() {
|
||||
xs[i] = x_off;
|
||||
zs[i] = z_off;
|
||||
x_off += (random.next_int_bound(11).unwrap_or(5) - 5) as f32;
|
||||
z_off += (random.next_int_bound(11).unwrap_or(5) - 5) as f32;
|
||||
}
|
||||
for layer in 0..4 {
|
||||
let mut random = LegacyRandom::new(seed);
|
||||
for branch in 0..3 {
|
||||
let trunk = branch == 0;
|
||||
let start = 7 - branch;
|
||||
let end = if trunk { 0 } else { start - 3 + 1 };
|
||||
let mut sx = xs[start] - x_off;
|
||||
let mut sz = zs[start] - z_off;
|
||||
for segment in (end..=start).rev() {
|
||||
let (ex, ez) = (sx, sz);
|
||||
let spread = if trunk { 11 } else { 31 };
|
||||
sx += (random.next_int_bound(spread).unwrap_or(0) - spread / 2) as f32;
|
||||
sz += (random.next_int_bound(spread).unwrap_or(0) - spread / 2) as f32;
|
||||
let mut top = 0.1 + layer as f32 * 0.2;
|
||||
let mut bottom = top;
|
||||
if trunk {
|
||||
top *= segment as f32 * 0.1 + 1.0;
|
||||
bottom *= (segment as f32 - 1.0) * 0.1 + 1.0;
|
||||
}
|
||||
for (rx, rz, lx, lz) in [
|
||||
(false, false, true, false),
|
||||
(true, false, true, true),
|
||||
(true, true, false, true),
|
||||
(false, true, false, false),
|
||||
] {
|
||||
let s = |p: bool, r: f32| if p { r } else { -r };
|
||||
let y0 = segment as f32 * 16.0;
|
||||
let corners = [
|
||||
[sx + s(rx, bottom), y0, sz + s(rz, bottom)],
|
||||
[ex + s(rx, top), y0 + 16.0, ez + s(rz, top)],
|
||||
[ex + s(lx, top), y0 + 16.0, ez + s(lz, top)],
|
||||
[sx + s(lx, bottom), y0, sz + s(lz, bottom)],
|
||||
];
|
||||
for c in corners {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: c,
|
||||
uv,
|
||||
layer: 2,
|
||||
color,
|
||||
light: 240 | 240 << 8,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
struct Cached<T> {
|
||||
mesh: T,
|
||||
used: bool,
|
||||
}
|
||||
|
||||
/// Shared seeds use the same mesh; storage is bounded by active unique seeds,
|
||||
/// including after seed changes, removals, dimension switches and disconnects.
|
||||
struct Cache<T> {
|
||||
uv: Option<[f32; 2]>,
|
||||
meshes: HashMap<i64, Cached<T>>,
|
||||
}
|
||||
impl<T> Cache<T> {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
uv: None,
|
||||
meshes: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(&mut self, input: &Input<'_>, mut build: impl FnMut(i64, [f32; 2]) -> T) {
|
||||
if self.uv != Some(input.uv) {
|
||||
self.meshes.clear();
|
||||
self.uv = Some(input.uv);
|
||||
}
|
||||
for cached in self.meshes.values_mut() {
|
||||
cached.used = false;
|
||||
}
|
||||
for bolt in input.bolts {
|
||||
self.meshes
|
||||
.entry(bolt.seed)
|
||||
.or_insert_with(|| Cached {
|
||||
mesh: build(bolt.seed, input.uv),
|
||||
used: true,
|
||||
})
|
||||
.used = true;
|
||||
}
|
||||
self.meshes.retain(|_, cached| cached.used);
|
||||
// HashMap::retain keeps its allocation. Release historical peak capacity
|
||||
// as well as meshes once the storm (or connection) is gone.
|
||||
if self.meshes.is_empty() {
|
||||
self.meshes = HashMap::new();
|
||||
} else if self.meshes.capacity() > self.meshes.len() * 4 {
|
||||
self.meshes.shrink_to_fit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn offset(bolt: &Bolt, camera: DVec3) -> [f32; 3] {
|
||||
(DVec3::from_array(bolt.at) - camera).as_vec3().to_array()
|
||||
}
|
||||
|
||||
pub(super) struct Renderer {
|
||||
cache: Cache<wgpu::Buffer>,
|
||||
instances: Option<wgpu::Buffer>,
|
||||
capacity: usize,
|
||||
}
|
||||
impl Renderer {
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
cache: Cache::new(),
|
||||
instances: None,
|
||||
capacity: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
uploads: &mut Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
input: &Input<'_>,
|
||||
camera: DVec3,
|
||||
) {
|
||||
self.cache.prepare(input, |seed, uv| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Lightning vertices"),
|
||||
contents: bytemuck::cast_slice(&geometry(seed, uv)),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
})
|
||||
});
|
||||
if input.bolts.is_empty() {
|
||||
self.instances = None;
|
||||
self.capacity = 0;
|
||||
return;
|
||||
}
|
||||
if input.bolts.len() > self.capacity || input.bolts.len() < self.capacity / 4 {
|
||||
self.capacity = input.bolts.len().next_power_of_two();
|
||||
self.instances = Some(device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Lightning offsets"),
|
||||
size: (self.capacity * std::mem::size_of::<[f32; 3]>()) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}));
|
||||
}
|
||||
let offsets: Vec<_> = input
|
||||
.bolts
|
||||
.iter()
|
||||
.map(|bolt| offset(bolt, camera))
|
||||
.collect();
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
self.instances.as_ref().unwrap(),
|
||||
0,
|
||||
bytemuck::cast_slice(&offsets),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>, input: &Input<'_>) {
|
||||
let Some(instances) = &self.instances else {
|
||||
return;
|
||||
};
|
||||
pass.set_vertex_buffer(1, instances.slice(..));
|
||||
// Follow simulation order, including repeated seeds; HashMap order must
|
||||
// never change additive accumulation or the order before dragon rays.
|
||||
for (i, bolt) in input.bolts.iter().enumerate() {
|
||||
pass.set_vertex_buffer(0, self.cache.meshes[&bolt.seed].mesh.slice(..));
|
||||
pass.draw_indexed(0..QUADS * 6, 0, i as u32..i as u32 + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,495 @@
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
|
||||
// Frozen pre-cache generator: compare every Vertex byte, including primitive
|
||||
// order and the original camera-relative rounding, without game resources.
|
||||
fn reference(seed: i64, at: [f64; 3], camera: DVec3, uv: [f32; 2]) -> Vec<Vertex> {
|
||||
let mut out = Vec::new();
|
||||
// ARGB 0.45, 0.45, 0.5 at 0.3 alpha, packed RGBA.
|
||||
let color = (76u32 << 24) | (127 << 16) | (114 << 8) | 114;
|
||||
let b = Bolt { seed, at };
|
||||
{
|
||||
let o = (glam::DVec3::from_array(b.at) - camera).as_vec3();
|
||||
let mut random = LegacyRandom::new(b.seed);
|
||||
let (mut xs, mut zs) = ([0f32; 8], [0f32; 8]);
|
||||
let (mut x_off, mut z_off) = (0f32, 0f32);
|
||||
for i in (0..8).rev() {
|
||||
xs[i] = x_off;
|
||||
zs[i] = z_off;
|
||||
x_off += (random.next_int_bound(11).unwrap_or(5) - 5) as f32;
|
||||
z_off += (random.next_int_bound(11).unwrap_or(5) - 5) as f32;
|
||||
}
|
||||
for layer in 0..4 {
|
||||
let mut random = LegacyRandom::new(b.seed);
|
||||
for branch in 0..3 {
|
||||
let trunk = branch == 0;
|
||||
let start = 7 - branch;
|
||||
let end = if trunk { 0 } else { start - 3 + 1 };
|
||||
let mut sx = xs[start] - x_off;
|
||||
let mut sz = zs[start] - z_off;
|
||||
for segment in (end..=start).rev() {
|
||||
let (ex, ez) = (sx, sz);
|
||||
let spread = if trunk { 11 } else { 31 };
|
||||
sx += (random.next_int_bound(spread).unwrap_or(0) - spread / 2) as f32;
|
||||
sz += (random.next_int_bound(spread).unwrap_or(0) - spread / 2) as f32;
|
||||
let mut top = 0.1 + layer as f32 * 0.2;
|
||||
let mut bottom = top;
|
||||
if trunk {
|
||||
top *= segment as f32 * 0.1 + 1.0;
|
||||
bottom *= (segment as f32 - 1.0) * 0.1 + 1.0;
|
||||
}
|
||||
for (rx, rz, lx, lz) in [
|
||||
(false, false, true, false),
|
||||
(true, false, true, true),
|
||||
(true, true, false, true),
|
||||
(false, true, false, false),
|
||||
] {
|
||||
let s = |p: bool, r: f32| if p { r } else { -r };
|
||||
let y0 = segment as f32 * 16.0;
|
||||
let corners = [
|
||||
[sx + s(rx, bottom), y0, sz + s(rz, bottom)],
|
||||
[ex + s(rx, top), y0 + 16.0, ez + s(rz, top)],
|
||||
[ex + s(lx, top), y0 + 16.0, ez + s(lz, top)],
|
||||
[sx + s(lx, bottom), y0, sz + s(lz, bottom)],
|
||||
];
|
||||
for c in corners {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: (o + glam::Vec3::from_array(c)).to_array(),
|
||||
uv,
|
||||
layer: 2,
|
||||
color,
|
||||
light: 240 | 240 << 8,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn seeds() -> Vec<i64> {
|
||||
let mut seeds = vec![0, 1, -1, i64::MIN, i64::MAX, 0x123456789abcdef];
|
||||
let mut random = LegacyRandom::new(42);
|
||||
seeds.extend((0..256).map(|_| random.next_long()));
|
||||
seeds
|
||||
}
|
||||
|
||||
/// Pinned 26.3 LightningBoltRenderer.lambda$submit$0, captured with an
|
||||
/// identity PoseStack and a VertexConsumer recording big-endian xyz floats.
|
||||
#[test]
|
||||
fn geometry_matches_pinned_vanilla_vertex_digest() {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut digest = Sha256::new();
|
||||
for seed in seeds() {
|
||||
for vertex in geometry(seed, [0.0; 2]) {
|
||||
for value in vertex.pos {
|
||||
digest.update(value.to_be_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
format!("{:x}", digest.finalize()),
|
||||
"a9ed25caa50b9aa340faa4bb417b3d533b061f0dce66514e4bb212931444dee6"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_geometry_matches_original_generator() {
|
||||
let uv = [0.8125, 0.03125];
|
||||
for seed in seeds() {
|
||||
let local = geometry(seed, uv);
|
||||
assert_eq!(local.len(), QUADS as usize * 4);
|
||||
for (at, camera) in [
|
||||
([0.0; 3], DVec3::ZERO),
|
||||
([12.5, 64.0, -12.5], DVec3::new(-4.25, 71.125, 32.5)),
|
||||
(
|
||||
[29_999_999.5, -64.0, -29_999_999.5],
|
||||
DVec3::new(29_999_987.125, 67.75, -29_999_983.875),
|
||||
),
|
||||
] {
|
||||
let offset = offset(&Bolt { seed, at }, camera);
|
||||
let translated: Vec<_> = local
|
||||
.iter()
|
||||
.map(|v| Vertex {
|
||||
pos: (glam::Vec3::from_array(offset) + glam::Vec3::from_array(v.pos))
|
||||
.to_array(),
|
||||
..*v
|
||||
})
|
||||
.collect();
|
||||
let expected = reference(seed, at, camera, uv);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&translated),
|
||||
bytemuck::cast_slice::<_, u8>(&expected),
|
||||
"seed {seed}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_tracks_seed_lifecycle_movement_and_atlas_reload() {
|
||||
let mut cache = Cache::new();
|
||||
let builds = Cell::new(0);
|
||||
let build = |seed, uv| {
|
||||
builds.set(builds.get() + 1);
|
||||
geometry(seed, uv)
|
||||
};
|
||||
let mut bolts = [
|
||||
Bolt {
|
||||
seed: 123,
|
||||
at: [0.0; 3],
|
||||
},
|
||||
Bolt {
|
||||
seed: 456,
|
||||
at: [1.0; 3],
|
||||
},
|
||||
];
|
||||
fn input(bolts: &[Bolt]) -> Input<'_> {
|
||||
Input {
|
||||
bolts,
|
||||
uv: [0.25, 0.5],
|
||||
}
|
||||
}
|
||||
cache.prepare(&input(&bolts), build);
|
||||
assert_eq!(builds.get(), 2);
|
||||
assert_eq!(cache.meshes.len(), 2);
|
||||
// Any number of rendered frames or multiplayer position updates reuse meshes.
|
||||
for i in 0..100 {
|
||||
bolts[0].at = [i as f64; 3];
|
||||
cache.prepare(&input(&bolts), build);
|
||||
}
|
||||
assert_eq!(builds.get(), 2);
|
||||
// A flash changes the seed; retire its previous mesh immediately.
|
||||
bolts[0].seed = 789;
|
||||
cache.prepare(&input(&bolts), build);
|
||||
assert_eq!(builds.get(), 3);
|
||||
assert!(!cache.meshes.contains_key(&123));
|
||||
// Removal retains other bolts, and repeated seeds still share geometry.
|
||||
bolts[0].seed = 456;
|
||||
cache.prepare(&input(&bolts), build);
|
||||
assert_eq!(builds.get(), 3);
|
||||
assert_eq!(cache.meshes.len(), 1);
|
||||
// Atlas replacement must update white texel UVs without changing the seed.
|
||||
cache.prepare(
|
||||
&Input {
|
||||
bolts: &bolts,
|
||||
uv: [0.75, 0.25],
|
||||
},
|
||||
build,
|
||||
);
|
||||
assert_eq!(builds.get(), 4);
|
||||
assert_eq!(cache.meshes[&456].mesh[0].uv, [0.75, 0.25]);
|
||||
// Disconnect/dimension switch/expiry drops every retained resource.
|
||||
cache.prepare(&Input::default(), build);
|
||||
assert!(cache.meshes.is_empty());
|
||||
assert_eq!(cache.meshes.capacity(), 0);
|
||||
cache.prepare(&input(&bolts), build);
|
||||
assert_eq!(builds.get(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_flash_sequence_keeps_only_active_seeds() {
|
||||
let mut cache = Cache::new();
|
||||
for seed in 0..10_000 {
|
||||
cache.prepare(
|
||||
&Input {
|
||||
bolts: &[Bolt { seed, at: [0.0; 3] }],
|
||||
uv: [0.0; 2],
|
||||
},
|
||||
|seed, _| seed,
|
||||
);
|
||||
assert_eq!(cache.meshes.len(), 1);
|
||||
assert_eq!(cache.meshes[&seed].mesh, seed);
|
||||
assert!(cache.meshes.capacity() < 16);
|
||||
}
|
||||
}
|
||||
|
||||
/// Exercise the actual entity shader and retained draw path offscreen. Run
|
||||
/// explicitly, separately from other headless device tests (some GPU drivers
|
||||
/// fail during concurrent device teardown). Machines without an adapter skip it.
|
||||
#[test]
|
||||
#[ignore = "requires GPU; run separately with --ignored --exact"]
|
||||
fn retained_gpu_draw_matches_transient_additive_draw() {
|
||||
use bytemuck::Zeroable;
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
eprintln!("lightning differential GPU: {}", adapter.get_info().name);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Lightning differential shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(crate::gfx::entity_shader().into()),
|
||||
});
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4];
|
||||
let offsets = wgpu::vertex_attr_array![6=>Float32x3];
|
||||
let vertices = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})];
|
||||
let retained_vertices = [
|
||||
vertices[0].clone(),
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 12,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &offsets,
|
||||
}),
|
||||
];
|
||||
let pipelines: Vec<_> = [false, true]
|
||||
.into_iter()
|
||||
.map(|retained| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Lightning differential pipeline"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some(if retained {
|
||||
"vs_lightning"
|
||||
} else {
|
||||
"vs_entity"
|
||||
}),
|
||||
compilation_options: Default::default(),
|
||||
buffers: if retained {
|
||||
&retained_vertices
|
||||
} else {
|
||||
&vertices
|
||||
},
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_celestial"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let globals = crate::gfx::Globals {
|
||||
view_proj: (glam::Mat4::orthographic_rh(-100.0, 100.0, -10.0, 150.0, 0.1, 400.0)
|
||||
* glam::Mat4::look_at_rh(
|
||||
glam::Vec3::new(80.0, 30.0, 160.0),
|
||||
glam::Vec3::new(0.0, 60.0, 0.0),
|
||||
glam::Vec3::Y,
|
||||
))
|
||||
.to_cols_array_2d(),
|
||||
..crate::gfx::Globals::zeroed()
|
||||
};
|
||||
let globals = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::bytes_of(&globals),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let size = wgpu::Extent3d {
|
||||
width: 256,
|
||||
height: 256,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let texture = |format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let white = device
|
||||
.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&[255; 4],
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let sampler = device.create_sampler(&Default::default());
|
||||
let groups: Vec<_> = pipelines
|
||||
.iter()
|
||||
.map(|pipeline| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: globals.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&white),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&white),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut renderer = Renderer::new();
|
||||
let mut uploads = Uploads::new(&device);
|
||||
let mut bolts = [Bolt {
|
||||
seed: 123,
|
||||
at: [29_999_999.5, 64.0, -29_999_999.5],
|
||||
}; 3];
|
||||
bolts[1].seed = -456;
|
||||
bolts[1].at[0] -= 32.0;
|
||||
bolts[2].at[2] += 20.0;
|
||||
let indices = crate::gfx::Renderer::quad_indices(&device, QUADS * 3);
|
||||
for frame in 0..4 {
|
||||
let camera = DVec3::new(29_999_997.125 + frame as f64, 62.75, -29_999_998.875);
|
||||
if frame == 2 {
|
||||
bolts[0].seed = 789;
|
||||
}
|
||||
let input = Input {
|
||||
bolts: &bolts[..if frame == 3 { 1 } else { 3 }],
|
||||
uv: if frame == 3 { [0.75; 2] } else { [0.5; 2] },
|
||||
};
|
||||
let expected: Vec<_> = input
|
||||
.bolts
|
||||
.iter()
|
||||
.flat_map(|bolt| reference(bolt.seed, bolt.at, camera, input.uv))
|
||||
.collect();
|
||||
let transient = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&expected),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
renderer.prepare(&device, &mut uploads, &mut encoder, &input, camera);
|
||||
let mut readbacks = Vec::new();
|
||||
for mode in 0..2 {
|
||||
let color = texture(
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let view = color.create_view(&Default::default());
|
||||
let depth = texture(
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
store: wgpu::StoreOp::Discard,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipelines[mode]);
|
||||
pass.set_bind_group(0, &groups[mode], &[]);
|
||||
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
if mode == 0 {
|
||||
pass.set_vertex_buffer(0, transient.slice(..));
|
||||
pass.draw_indexed(0..expected.len() as u32 / 4 * 6, 0, 0..1);
|
||||
} else {
|
||||
renderer.draw(&mut pass, &input);
|
||||
}
|
||||
}
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256 * 256 * 4,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
color.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(1024),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
size,
|
||||
);
|
||||
readbacks.push(read);
|
||||
}
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
for read in &readbacks {
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
}
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let pixels: Vec<Vec<u8>> = readbacks
|
||||
.iter()
|
||||
.map(|read| read.slice(..).get_mapped_range().unwrap().to_vec())
|
||||
.collect();
|
||||
assert!(pixels[0].iter().any(|&b| b != 0), "empty reference frame");
|
||||
assert_eq!(pixels[0], pixels[1], "frame {frame}: GPU output differs");
|
||||
}
|
||||
}
|
||||
Loaded 100 of 412 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user