mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
236
Commits
No files matched your search
@@ -3,3 +3,4 @@
|
||||
docs/launcher-commands.md text eol=lf
|
||||
docs/horizon-ctl-commands.md text eol=lf
|
||||
website/src/tokens.css text eol=lf
|
||||
docs/api/*-reference.md text eol=lf
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec python3 tools/check_public_tree.py --history
|
||||
exec python3 tools/check_public_tree.py --pre-push
|
||||
@@ -430,7 +430,7 @@ jobs:
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_float_log_parity.py
|
||||
portable-mod:
|
||||
jfr-analysis:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: [self-hosted, linux, horizon-game]
|
||||
steps:
|
||||
@@ -442,101 +442,7 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
- name: Compile original mod and test metrics
|
||||
run: python tools/build_mod.py
|
||||
- name: Verify streaming JFR analysis against the JDK exporter
|
||||
run: python tools/test_jfr.py
|
||||
- name: Verify ZGC pause and allocation event interpretation
|
||||
run: python tools/test_jfr.py --gc zgc
|
||||
- name: Build pinned portable packs
|
||||
run: python tools/build_pack.py
|
||||
- name: Measure isolated worker pacing on this platform
|
||||
run: python -c "from tools.build_mod import benchmark_workers; benchmark_workers()"
|
||||
- name: Measure isolated debounce queue polling
|
||||
run: python -c "from tools.build_mod import benchmark_debounce; benchmark_debounce()"
|
||||
- name: Measure wakeable idle queue latency
|
||||
run: python -c "from tools.build_mod import benchmark_idle; benchmark_idle()"
|
||||
- name: Measure object allocations with compact headers off and on
|
||||
run: python -c "from tools.build_mod import benchmark_headers; benchmark_headers()"
|
||||
- name: Test pack integrity and comparisons
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Start recommended OpenGL macOS clients and verify framebuffer readback
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
python tools/test_client_graphics.py --native-display
|
||||
cp dist/client-graphics-smoke.json dist/client-graphics-macos-title.json
|
||||
python tools/test_client_graphics.py --native-display --world
|
||||
cp dist/client-graphics-smoke.json dist/client-graphics-macos-world.json
|
||||
- name: Compile the private dispatch fixture
|
||||
run: python -c "from tools.build_mod import build_dispatch_fixture; build_dispatch_fixture()"
|
||||
- name: Compile the private integrated-server graphics fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('server-graphics', 'dev.horizonlab.metrics.ServerGraphicsIntegration', fixture_dependencies={})"
|
||||
- name: Compile the private tick-manager state fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('tick-state', 'dev.horizonlab.metrics.TickStateIntegration')"
|
||||
- name: Compile the private terrain tick membership fixture
|
||||
run: python -c "from tools.build_mod import build_tick_dedup_fixture; build_tick_dedup_fixture()"
|
||||
- name: Compile the private culling whitelist fixture
|
||||
run: python -c "from tools.build_mod import build_culling_empty_fixture; build_culling_empty_fixture()"
|
||||
- name: Compile the private culling ray geometry fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('culling-rays', 'dev.horizonlab.dispatch.CullingRayIntegration', ['entityculling'], ['CullingRayAccess'])"
|
||||
- name: Compile the private terrain position lookup fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-position', 'dev.horizonlab.dispatch.PositionIntegration')"
|
||||
- name: Compile the private terrain quad reuse fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('quad-reuse', 'dev.horizonlab.dispatch.QuadReuseIntegration')"
|
||||
- name: Compile the private object-size agent and terrain quad fixture
|
||||
run: python -c "from tools.test_quad_sizes import build_agent; from tools.build_mod import build_integration_fixture; build_agent(); build_integration_fixture('quad-size', 'dev.horizonlab.dispatch.QuadSizeIntegration')"
|
||||
- name: Compile the private terrain read shutdown fixture
|
||||
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-read', 'dev.horizonlab.dispatch.TerrainReadIntegration')"
|
||||
- name: Install headless graphics libraries for applied-hook checks
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb libgl1 libegl1 libvulkan1 mesa-vulkan-drivers libasound2t64 libxkbcommon0
|
||||
- name: Verify integrated-server graphics report against the actual device
|
||||
run: python tools/test_server_graphics.py ${{ runner.os == 'macOS' && '--native-display' || '' }}
|
||||
- name: Exercise the applied dispatcher hooks and upstream rejection path
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_dispatch.py
|
||||
- name: Verify applied tick membership transitions and allocation
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_tick_dedup.py
|
||||
- name: Verify live culling whitelist registration and empty iteration
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_culling_empty.py
|
||||
- name: Compare applied culling ray geometry and provider queries
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_culling_rays.py
|
||||
- name: Verify actual full-data position lookup equivalence
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_terrain_position.py
|
||||
- name: Verify actual terrain quad geometry and allocation
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_quad_reuse.py
|
||||
- name: Measure actual builder and quad shallow sizes in both header modes
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_quad_sizes.py
|
||||
- name: Verify interrupted DH reads and preserved shared futures
|
||||
if: runner.os == 'Linux'
|
||||
run: python tools/test_terrain_read.py
|
||||
- name: Upload original mod and pack manifests only
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: horizon-linux
|
||||
path: |
|
||||
dist/*.mrpack
|
||||
dist/horizon-*.jar
|
||||
dist/worker-pacing-benchmark.txt
|
||||
dist/debounce-queue-benchmark.txt
|
||||
dist/idle-queue-benchmark.txt
|
||||
dist/object-header-benchmark.txt
|
||||
dist/dispatch-integration.json
|
||||
dist/tick-dedup-integration.json
|
||||
dist/culling-empty-integration.json
|
||||
dist/terrain-read-integration.json
|
||||
dist/culling-rays-integration.json
|
||||
dist/terrain-position-integration.json
|
||||
dist/quad-reuse-integration.json
|
||||
dist/quad-size-integration.json
|
||||
dist/server-graphics-smoke.json
|
||||
dist/client-graphics-smoke.json
|
||||
dist/client-graphics-macos-title.json
|
||||
dist/client-graphics-macos-world.json
|
||||
retention-days: 3
|
||||
@@ -0,0 +1,34 @@
|
||||
name: mod-registry
|
||||
# The submission service is its own workspace (crates/horizon-mod-registry), so the checks job doesn't build
|
||||
# it: this one does, when it or horizon-mods (the checks it shares with the launcher and the game) changes.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'crates/horizon-mods/**'
|
||||
- 'crates/horizon-mod-registry/**'
|
||||
- '.github/workflows/mod-registry.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'crates/horizon-mods/**'
|
||||
- 'crates/horizon-mod-registry/**'
|
||||
- '.github/workflows/mod-registry.yml'
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: crates/horizon-mod-registry
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: crates/horizon-mod-registry
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- run: cargo +stable fmt --check
|
||||
- run: cargo +stable clippy --all-targets --locked -- -D warnings
|
||||
- run: cargo +stable test --locked
|
||||
@@ -367,10 +367,15 @@ jobs:
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
# The release and the latest at once: there is one channel (docs/release-flow.md).
|
||||
# The release and the latest at once: there is one channel (docs/release-flow.md). Each archive gets a
|
||||
# label (file#label), which GitHub's page shows instead of the file name, so nobody takes the game's
|
||||
# files for the download (#1647). Launchers, servers and install.sh match the name, which stays.
|
||||
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
|
||||
--notes-file notes/body.md --latest dist/horizon-linux-x86_64.tar.gz \
|
||||
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz \
|
||||
--notes-file notes/body.md --latest \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs: Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Game files the launcher installs: Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs: macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
- name: Dry run, the release that would be published
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
|
||||
@@ -45,9 +45,10 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
|
||||
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `voxel-kernels`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, occupancy kernels, repo tasks |
|
||||
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
|
||||
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
|
||||
|
||||
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
|
||||
|
||||
@@ -39,6 +39,17 @@ probe against Horizon, and compares their outputs. There are about 290 of them.
|
||||
|
||||
Never commit game files, decompiled source or prepared resources (`.local/`): `tools/check_public_tree.py` rejects
|
||||
them (`git config core.hooksPath .githooks` runs it before every commit).
|
||||
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
|
||||
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
|
||||
|
||||
## Only original code
|
||||
|
||||
Horizon's code is written from Minecraft 26.3 itself and from public documentation, never from another mod, loader
|
||||
or renderer. Don't read, copy, translate or paraphrase the source of Iris, OptiFine, Sodium or any other project,
|
||||
and don't send code an AI tool wrote from it. Their user documentation and file formats are fine to read, and their
|
||||
packs and mods are fine to run and observe. Shader packs were removed on 24 October 2026 (#1561) because their code
|
||||
had been written from another project's source; they come back only as a clean-room rewrite from a written
|
||||
description of the pack format.
|
||||
|
||||
## Before asking for review
|
||||
|
||||
|
||||
Generated
+59
-6
@@ -155,7 +155,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -1278,6 +1278,7 @@ dependencies = [
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"symphonia",
|
||||
"ureq",
|
||||
@@ -1314,7 +1315,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"socket2",
|
||||
"socket2 0.5.10",
|
||||
"spake2",
|
||||
]
|
||||
|
||||
@@ -1330,8 +1331,10 @@ name = "horizon-mods"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"wasmtime",
|
||||
"wit-component 0.239.0",
|
||||
@@ -1377,6 +1380,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"horizon-core",
|
||||
"horizon-world",
|
||||
"ipconfig",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
@@ -1430,6 +1434,13 @@ dependencies = [
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-shaders"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-telemetry"
|
||||
version = "0.1.0"
|
||||
@@ -1653,6 +1664,19 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipconfig"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
|
||||
dependencies = [
|
||||
"socket2 0.6.5",
|
||||
"widestring",
|
||||
"windows-registry",
|
||||
"windows-result 0.4.1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -1897,6 +1921,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -3137,6 +3167,16 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spake2"
|
||||
version = "0.4.0"
|
||||
@@ -3577,10 +3617,6 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "voxel-kernels"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -4297,6 +4333,12 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "widestring"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -4429,6 +4471,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -1,35 +1,5 @@
|
||||
# Third-party notices
|
||||
|
||||
## CullLeaves
|
||||
|
||||
The optional leaf and mangrove-root face-culling behavior is adapted from
|
||||
[CullLeaves](https://github.com/TeamMidnightDust/CullLeaves) by MidnightDust.
|
||||
Its MIT license is reproduced below.
|
||||
|
||||
```text
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 MidnightDust
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## wgpu-hal
|
||||
|
||||
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
|
||||
|
||||
@@ -131,8 +131,8 @@ average) and reached the ThinkPad over the tailnet.
|
||||
It ran with Paper's defaults except `connection-throttle: -1` in `bukkit.yml`. Every bot connects from one
|
||||
address, and the default throttle (4 s) refused all but the first. Paperclip patched the pinned server jar
|
||||
from its cache. Same Java and heap.
|
||||
- Fabric + Lithium: the pinned server jar on Fabric loader 0.19.5 (`packs/fabric.lock.json`) with Lithium
|
||||
`mc26.3-0.26.2-fabric` (`packs/mods.lock.json`, checked against its SHA-512). Lithium needs no Fabric API.
|
||||
- Fabric + Lithium: the pinned server jar on Fabric loader 0.19.5 (`tools/benchmark-deps/fabric.lock.json`) with Lithium
|
||||
`mc26.3-0.26.2-fabric` (`tools/benchmark-deps/mods.lock.json`, checked against its SHA-512). Lithium needs no Fabric API.
|
||||
Same Java and heap.
|
||||
|
||||
The Paper and Fabric jars are dev-only downloads into `.local/server-bench/jars` and are never committed or
|
||||
|
||||
@@ -4,10 +4,10 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.86"
|
||||
description = "cargo horizon-mod new/build/run: make Horizon mods in Rust (docs/mods.md)"
|
||||
description = "cargo horizon-mod new/build/run/check: make Horizon mods in Rust (docs/mods.md)"
|
||||
|
||||
[dependencies]
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack"] }
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack", "runtime", "index"] }
|
||||
serde_json = "1.0.151"
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
|
||||
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
|
||||
//! when given a key, and for a mod a trial run in the sandbox: it must start,
|
||||
//! survive ticks, frames, a chat line and each of its commands, and import
|
||||
//! no more than its capabilities.
|
||||
use horizon_mods::{
|
||||
Clock, Mods, Output, Package, Player, Snapshot, Status,
|
||||
index::{self, Facts, Kind},
|
||||
manifest::component_imports,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
/// How long the trial run plays: two seconds of game.
|
||||
const TICKS: u64 = 40;
|
||||
const FRAMES_PER_TICK: u64 = 3;
|
||||
|
||||
pub struct Report {
|
||||
pub kind: Kind,
|
||||
pub facts: Facts,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub signed: bool,
|
||||
/// A mod's component imports.
|
||||
pub imports: Vec<String>,
|
||||
/// A mod's commands, each run once.
|
||||
pub commands: Vec<String>,
|
||||
/// What the mod showed during the trial run.
|
||||
pub output: Vec<String>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
pub fn json(&self) -> Value {
|
||||
let requires: Vec<Value> = self
|
||||
.facts
|
||||
.requires
|
||||
.iter()
|
||||
.map(|r| json!({"id": r.id, "version": r.version, "sha256": r.sha256}))
|
||||
.collect();
|
||||
json!({
|
||||
"kind": self.kind.name(),
|
||||
"id": self.facts.id,
|
||||
"version": self.facts.version,
|
||||
"api": self.facts.api,
|
||||
"side": self.facts.side,
|
||||
"capabilities": self.facts.capabilities,
|
||||
"game": self.facts.game,
|
||||
"requires": requires,
|
||||
"sha256": self.sha256,
|
||||
"size": self.size,
|
||||
"signed": self.signed,
|
||||
"imports": self.imports,
|
||||
"commands": self.commands,
|
||||
"output": self.output,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text(&self) -> String {
|
||||
let f = &self.facts;
|
||||
let mut out = format!(
|
||||
"{} {} ({}",
|
||||
f.id.as_deref().unwrap_or("resource pack"),
|
||||
f.version.as_deref().unwrap_or(""),
|
||||
self.kind.name()
|
||||
);
|
||||
if let Some(side) = f.side {
|
||||
let side = serde_json::to_value(side).unwrap_or_default();
|
||||
out += &format!(", API {}, {}", f.api, side.as_str().unwrap_or("?"));
|
||||
} else if !f.api.is_empty() {
|
||||
out += &format!(", format {}", f.api);
|
||||
}
|
||||
if let Some(game) = &f.game {
|
||||
out += &format!(", for {game}");
|
||||
}
|
||||
let signed = if self.signed {
|
||||
"signature verified"
|
||||
} else {
|
||||
"unsigned"
|
||||
};
|
||||
out += &format!(
|
||||
")\n {} bytes, SHA-256 {}, {signed}\n",
|
||||
self.size, self.sha256
|
||||
);
|
||||
for c in &f.capabilities {
|
||||
out += &format!(" may {}\n", c.describe());
|
||||
}
|
||||
for r in &f.requires {
|
||||
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
|
||||
}
|
||||
if self.kind == Kind::Mod {
|
||||
let commands = if self.commands.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", /{}", self.commands.join(", /"))
|
||||
};
|
||||
out += &format!(
|
||||
" trial run: started, {TICKS} ticks, {} frames, a chat line{commands}: ok\n",
|
||||
TICKS * FRAMES_PER_TICK
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind a file's extension says.
|
||||
pub fn kind_of(path: &Path) -> Option<Kind> {
|
||||
match path.extension()?.to_str()? {
|
||||
"hmod" => Some(Kind::Mod),
|
||||
"hpack" => Some(Kind::Modpack),
|
||||
"zip" => Some(Kind::ResourcePack),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the file at `path` as `kind`; `key` and `signature` (the
|
||||
/// `.minisig` text) check its signature.
|
||||
pub fn check(
|
||||
path: &Path,
|
||||
kind: Kind,
|
||||
key: Option<&str>,
|
||||
signature: Option<&str>,
|
||||
) -> Result<Report, String> {
|
||||
let size = std::fs::metadata(path)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?
|
||||
.len();
|
||||
if size > index::MAX_FILE {
|
||||
return Err(format!("over {} MiB", index::MAX_FILE >> 20));
|
||||
}
|
||||
index::open(&kind)?;
|
||||
let facts = index::inspect(&kind, path)?;
|
||||
let signed = match (key, signature) {
|
||||
(Some(key), Some(sig)) => {
|
||||
index::verify_signature(key, sig, path)?;
|
||||
true
|
||||
}
|
||||
(None, None) => false,
|
||||
_ => return Err("--key and --signature go together".into()),
|
||||
};
|
||||
let mut report = Report {
|
||||
kind,
|
||||
facts,
|
||||
sha256: index::sha256_file(path)?,
|
||||
size,
|
||||
signed,
|
||||
imports: Vec::new(),
|
||||
commands: Vec::new(),
|
||||
output: Vec::new(),
|
||||
};
|
||||
if report.kind == Kind::Mod {
|
||||
trial_run(path, &mut report)?;
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
|
||||
/// storage lands in a folder removed afterwards.
|
||||
fn trial_run(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let package = Package::read(path)?;
|
||||
let wasm = package.wasm.as_deref().ok_or("the .hmod has no mod.wasm")?;
|
||||
report.imports = component_imports(wasm)?;
|
||||
package
|
||||
.manifest
|
||||
.check_imports(report.imports.iter().map(String::as_str))?;
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-check-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let copy = dir.join("mod.hmod");
|
||||
let result = std::fs::copy(path, ©)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|_| play(©, report));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
result
|
||||
}
|
||||
|
||||
fn play(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let mut mods = Mods::load(&[path.to_path_buf()], |_| true, |_| false);
|
||||
let failed = |mods: &Mods| match &mods.entries()[0].status {
|
||||
Status::Failed(reason) => Some(reason.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(reason) = failed(&mods) {
|
||||
return Err(format!("it didn't start: {reason}"));
|
||||
}
|
||||
report.commands = mods.commands().map(str::to_owned).collect();
|
||||
for t in 0..TICKS {
|
||||
mods.set_snapshot(Snapshot {
|
||||
player: Some(Player {
|
||||
x: 0.5 + t as f64 * 0.1,
|
||||
y: 64.0,
|
||||
z: 0.5,
|
||||
yaw: 90.0,
|
||||
pitch: 0.0,
|
||||
health: 20.0,
|
||||
food: 20,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
}),
|
||||
clock: Some(Clock {
|
||||
game_time: t,
|
||||
day_time: 1000 + t,
|
||||
}),
|
||||
});
|
||||
mods.tick(t);
|
||||
for _ in 0..FRAMES_PER_TICK {
|
||||
mods.frame(1.0 / 60.0);
|
||||
}
|
||||
}
|
||||
mods.chat("<Steve> hello");
|
||||
for name in report.commands.clone() {
|
||||
mods.command(&name);
|
||||
}
|
||||
for out in mods.drain() {
|
||||
report.output.push(match out {
|
||||
Output::Chat { text, .. } => format!("chat: {text}"),
|
||||
Output::Feedback { text, error } => {
|
||||
format!("{}: {text}", if error { "error" } else { "feedback" })
|
||||
}
|
||||
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
|
||||
});
|
||||
}
|
||||
match failed(&mods) {
|
||||
Some(reason) => Err(format!("it stopped: {reason}")),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture(name: &str) -> std::path::PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-mods/tests/index")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_each_kind_its_own_way() {
|
||||
let pack = fixture("cozy-1.0.0.hpack");
|
||||
let report = check(&pack, kind_of(&pack).unwrap(), None, None).unwrap();
|
||||
assert_eq!(report.facts.requires[0].id, "coords_hud");
|
||||
assert!(
|
||||
report
|
||||
.text()
|
||||
.contains("cozy 1.0.0 (modpack, format 1, for 26.3)")
|
||||
);
|
||||
// The fixture mod's code is a stand-in: its manifest reads, its run fails.
|
||||
let hmod = fixture("coords_hud-0.1.0.hmod");
|
||||
let key = std::fs::read_to_string(fixture("test.pub")).unwrap();
|
||||
let sig = std::fs::read_to_string(fixture("coords_hud-0.1.0.hmod.minisig")).unwrap();
|
||||
let key = key.lines().last().unwrap();
|
||||
let e = check(&hmod, Kind::Mod, Some(key), Some(&sig))
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(e.contains("didn't start"), "{e}");
|
||||
let e = check(
|
||||
&hmod,
|
||||
Kind::Mod,
|
||||
Some(key),
|
||||
Some("untrusted comment: x\nRUQ=\n"),
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(e.contains("signature"), "{e}");
|
||||
let e = check(&pack, Kind::Shader, None, None).err().unwrap();
|
||||
assert!(e.contains("shader packs open"), "{e}");
|
||||
assert!(check(&pack, Kind::Mod, None, None).is_err());
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,16 @@
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
//! the mod; run `build` again and the game reloads it.
|
||||
//! - `check <file> [--json]`: what the registry checks of a submission
|
||||
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
|
||||
//! - `registry build|review|check|list`: the registry repository's CI.
|
||||
use horizon_mods::{
|
||||
Manifest, Package,
|
||||
manifest::{MANIFEST, component_imports, componentize},
|
||||
};
|
||||
mod check;
|
||||
mod registry;
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitCode, Stdio},
|
||||
@@ -28,7 +34,9 @@ fn main() -> ExitCode {
|
||||
println!("Built {}", p.display());
|
||||
}),
|
||||
Some("run") => run(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>]".into()),
|
||||
Some("check") => check(&args[1..]),
|
||||
Some("registry") => registry::main(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -150,6 +158,37 @@ fn build(release: bool) -> Result<PathBuf, String> {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// `check <file>`: the registry's checks of a submission, printed; fails
|
||||
/// when one does.
|
||||
fn check(args: &[String]) -> Result<(), String> {
|
||||
let file = args
|
||||
.first()
|
||||
.filter(|a| !a.starts_with("--"))
|
||||
.ok_or("cargo horizon-mod check <file>")?;
|
||||
let path = Path::new(file);
|
||||
let kind = match flag(args, "--kind") {
|
||||
Some(k) => horizon_mods::index::Kind::from(k.to_string()),
|
||||
None => check::kind_of(path)
|
||||
.ok_or("pass --kind mod|modpack|resourcepack: the extension doesn't say")?,
|
||||
};
|
||||
let signature = flag(args, "--signature")
|
||||
.map(|p| std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}")))
|
||||
.transpose()?;
|
||||
let report = check::check(path, kind, flag(args, "--key"), signature.as_deref());
|
||||
if args.iter().any(|a| a == "--json") {
|
||||
let json = match &report {
|
||||
Ok(r) => serde_json::json!({"ok": true, "report": r.json()}),
|
||||
Err(e) => serde_json::json!({"ok": false, "error": e}),
|
||||
};
|
||||
println!("{json}");
|
||||
}
|
||||
let report = report?;
|
||||
if !args.iter().any(|a| a == "--json") {
|
||||
print!("{}", report.text());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let path = build(true)?;
|
||||
let game = flag(args, "--game")
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! `cargo horizon-mod registry build|review|check|list`: the registry repository's CI
|
||||
//! (`ludovic111/horizon-mods`, docs/api/mod-index.md). Network-free: the
|
||||
//! workflow downloads the files and publishes what this writes.
|
||||
use horizon_mods::index::{
|
||||
FILE_HOST,
|
||||
registry::{self, Sources},
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
/// Where the registry's site is published (its icons).
|
||||
const SITE: &str = "https://ludovic111.github.io/horizon-mods/";
|
||||
|
||||
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
|
||||
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
|
||||
cargo horizon-mod registry check <base checkout> <head checkout> --files <dir>
|
||||
cargo horizon-mod registry list <checkout>";
|
||||
|
||||
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||||
super::flag(args, name)
|
||||
}
|
||||
|
||||
fn positional(args: &[String]) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut skip = false;
|
||||
for a in args {
|
||||
if skip {
|
||||
skip = false;
|
||||
} else if a == "--json" {
|
||||
} else if a.starts_with("--") {
|
||||
skip = true;
|
||||
} else {
|
||||
out.push(a.as_str());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn lines(errors: Vec<String>) -> String {
|
||||
format!("{} problem(s):\n {}", errors.len(), errors.join("\n "))
|
||||
}
|
||||
|
||||
pub fn main(args: &[String]) -> Result<(), String> {
|
||||
let rest = args.get(1..).unwrap_or_default();
|
||||
let pos = positional(rest);
|
||||
match args.first().map(String::as_str) {
|
||||
Some("build") => {
|
||||
let [checkout] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let (files, out) = (flag(rest, "--files"), flag(rest, "--out"));
|
||||
let (Some(files), Some(out)) = (files, out) else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let generated = flag(rest, "--generated")
|
||||
.map(str::to_owned)
|
||||
.or_else(|| std::env::var("REGISTRY_GENERATED").ok())
|
||||
.unwrap_or_default();
|
||||
let index = registry::build(&Sources {
|
||||
registry: Path::new(checkout),
|
||||
files: Path::new(files),
|
||||
release: flag(rest, "--release").unwrap_or(FILE_HOST),
|
||||
site: flag(rest, "--site").unwrap_or(SITE),
|
||||
generated,
|
||||
})
|
||||
.map_err(lines)?;
|
||||
let text = serde_json::to_string_pretty(&index).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, text + "\n").map_err(|e| format!("{out}: {e}"))?;
|
||||
println!(
|
||||
"Wrote {out}: {} project(s), {} taken down",
|
||||
index.projects.len(),
|
||||
index.revoked.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Some("review") => {
|
||||
let [base, head] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let author = flag(rest, "--author").ok_or(USAGE)?;
|
||||
let (base, _) = registry::load(Path::new(base)).map_err(lines)?;
|
||||
let (head, _) = registry::load(Path::new(head)).map_err(lines)?;
|
||||
let review = registry::review(&base, &head, author);
|
||||
if rest.iter().any(|a| a == "--json") {
|
||||
let json = serde_json::json!({"errors": review.errors, "notes": review.notes});
|
||||
println!("{json}");
|
||||
} else {
|
||||
for n in &review.notes {
|
||||
println!("note: {n}");
|
||||
}
|
||||
for e in &review.errors {
|
||||
println!("error: {e}");
|
||||
}
|
||||
}
|
||||
if review.errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"{} change(s) the registry refuses",
|
||||
review.errors.len()
|
||||
))
|
||||
}
|
||||
}
|
||||
// `check` (and its trial run) on each file `head` lists and `base`
|
||||
// doesn't: a pull request's new files.
|
||||
Some("check") => {
|
||||
let [base, head] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let files = Path::new(flag(rest, "--files").ok_or(USAGE)?);
|
||||
let old: Vec<String> = match registry::load(Path::new(base)) {
|
||||
Ok((entries, _)) => entries
|
||||
.iter()
|
||||
.flat_map(|e| e.versions.iter().map(|v| v.sha256.clone()))
|
||||
.collect(),
|
||||
// The first pull request has no entries to compare with.
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
let (entries, _) = registry::load(Path::new(head)).map_err(lines)?;
|
||||
let mut failed = 0;
|
||||
for e in &entries {
|
||||
for v in e.versions.iter().filter(|v| !old.contains(&v.sha256)) {
|
||||
println!("{} {} ({}):", e.id, v.version, v.file);
|
||||
let report = crate::check::check(
|
||||
&files.join(&v.sha256),
|
||||
e.kind.clone(),
|
||||
e.key.as_deref(),
|
||||
v.signature.as_deref(),
|
||||
);
|
||||
match report {
|
||||
Ok(r) => print!("{}", r.text()),
|
||||
Err(err) => {
|
||||
failed += 1;
|
||||
println!(" error: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if failed == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{failed} file(s) failed their check"))
|
||||
}
|
||||
}
|
||||
// The files CI must download for `build`: `<sha256> <id> <version> <file>`.
|
||||
Some("list") => {
|
||||
let [checkout] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let (entries, _) = registry::load(Path::new(checkout)).map_err(lines)?;
|
||||
for e in &entries {
|
||||
for v in &e.versions {
|
||||
println!("{} {} {} {}", v.sha256, e.id, v.version, v.file);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.into()),
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,15 @@ use crate::json::{Result, error};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Choice {
|
||||
Single(Variant),
|
||||
enum Choice<T> {
|
||||
Single(T),
|
||||
Weighted {
|
||||
variants: Vec<Variant>,
|
||||
variants: Vec<T>,
|
||||
ends: Vec<i32>,
|
||||
total: i32,
|
||||
},
|
||||
}
|
||||
impl Choice {
|
||||
impl Choice<Variant> {
|
||||
fn new(value: &VariantSet) -> Result<Self> {
|
||||
match value {
|
||||
VariantSet::Single(value) => Ok(Self::Single(value.clone())),
|
||||
@@ -41,7 +41,23 @@ impl Choice {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn select(&self, random: &mut impl RandomSource) -> &Variant {
|
||||
}
|
||||
impl<T> Choice<T> {
|
||||
fn map<U>(&self, f: &mut impl FnMut(&T) -> U) -> Choice<U> {
|
||||
match self {
|
||||
Self::Single(value) => Choice::Single(f(value)),
|
||||
Self::Weighted {
|
||||
variants,
|
||||
ends,
|
||||
total,
|
||||
} => Choice::Weighted {
|
||||
variants: variants.iter().map(&mut *f).collect(),
|
||||
ends: ends.clone(),
|
||||
total: *total,
|
||||
},
|
||||
}
|
||||
}
|
||||
fn select(&self, random: &mut impl RandomSource) -> &T {
|
||||
match self {
|
||||
Self::Single(value) => value,
|
||||
Self::Weighted {
|
||||
@@ -60,10 +76,11 @@ impl Choice {
|
||||
}
|
||||
/// Compile once per definition and share across its bound block states. Prefix
|
||||
/// sums avoid rebuilding weight totals or scanning all entries during selection.
|
||||
/// `map` swaps each variant for what it stands for (its baked model).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CompiledChoices {
|
||||
variants: Vec<Choice>,
|
||||
multipart: Vec<Choice>,
|
||||
pub struct CompiledChoices<T = Variant> {
|
||||
variants: Vec<Choice<T>>,
|
||||
multipart: Vec<Choice<T>>,
|
||||
}
|
||||
impl CompiledChoices {
|
||||
pub fn new(definition: &BlockStateDefinition) -> Result<Self> {
|
||||
@@ -82,10 +99,19 @@ impl CompiledChoices {
|
||||
.collect::<Result<_>>()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
impl<T> CompiledChoices<T> {
|
||||
/// The same choices, with `f` of each variant in its place.
|
||||
pub fn map<U>(&self, mut f: impl FnMut(&T) -> U) -> CompiledChoices<U> {
|
||||
CompiledChoices {
|
||||
variants: self.variants.iter().map(|c| c.map(&mut f)).collect(),
|
||||
multipart: self.multipart.iter().map(|c| c.map(&mut f)).collect(),
|
||||
}
|
||||
}
|
||||
/// The variant `particleMaterial` comes from: the first entry of a weighted
|
||||
/// list (`WeightedVariants`) and, for a multipart root, of its first
|
||||
/// selector whether or not that selector applies (`MultiPartModel`).
|
||||
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&Variant> {
|
||||
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&T> {
|
||||
let choice = match selected {
|
||||
SelectedModels::Variant(index) => self.variants.get(*index)?,
|
||||
SelectedModels::Multipart(_) => self.multipart.first()?,
|
||||
@@ -95,14 +121,23 @@ impl CompiledChoices {
|
||||
Choice::Weighted { variants, .. } => variants.first()?,
|
||||
})
|
||||
}
|
||||
/// Append selected variants without allocating intermediate part lists. A
|
||||
/// multipart root consumes nextLong even for an empty selected subset, then
|
||||
/// resets the same random source to that seed before each selected part.
|
||||
/// Append selected variants without allocating intermediate part lists.
|
||||
pub fn collect<'a>(
|
||||
&'a self,
|
||||
selected: &SelectedModels,
|
||||
random: &mut impl RandomSource,
|
||||
output: &mut Vec<&'a Variant>,
|
||||
output: &mut Vec<&'a T>,
|
||||
) -> Result<()> {
|
||||
self.for_each(selected, random, |v| output.push(v))
|
||||
}
|
||||
/// Each selected variant, in order. A multipart root consumes nextLong
|
||||
/// even for an empty selected subset, then resets the same random source
|
||||
/// to that seed before each selected part.
|
||||
pub fn for_each<'a>(
|
||||
&'a self,
|
||||
selected: &SelectedModels,
|
||||
random: &mut impl RandomSource,
|
||||
mut f: impl FnMut(&'a T),
|
||||
) -> Result<()> {
|
||||
match selected {
|
||||
SelectedModels::Variant(index) => {
|
||||
@@ -110,7 +145,7 @@ impl CompiledChoices {
|
||||
.variants
|
||||
.get(*index)
|
||||
.ok_or_else(|| error("invalid variant selection"))?;
|
||||
output.push(choice.select(random));
|
||||
f(choice.select(random));
|
||||
}
|
||||
SelectedModels::Multipart(indices) => {
|
||||
if indices.iter().any(|index| *index >= self.multipart.len()) {
|
||||
@@ -119,7 +154,7 @@ impl CompiledChoices {
|
||||
let seed = random.next_long();
|
||||
for index in indices {
|
||||
random.set_seed(seed);
|
||||
output.push(self.multipart[*index].select(random));
|
||||
f(self.multipart[*index].select(random));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
|
||||
//! come from the program that rendered (the game knows its adapter, the launcher its window).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use horizon_telemetry::system::command_output as output;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -173,12 +172,6 @@ pub fn cpu_seconds(pid: u32) -> Option<f64> {
|
||||
Some(ticks / 100.0)
|
||||
}
|
||||
|
||||
fn output(program: &str, args: &[&str]) -> Option<String> {
|
||||
let out = Command::new(program).args(args).output().ok()?;
|
||||
let text = String::from_utf8(out.stdout).ok()?.trim().to_owned();
|
||||
(out.status.success() && !text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_version() -> Option<String> {
|
||||
output("sw_vers", &["-productVersion"])
|
||||
|
||||
@@ -245,13 +245,19 @@ pub struct Report {
|
||||
impl Report {
|
||||
/// An empty report for this machine, created now.
|
||||
pub fn new(kind: Kind, app: App) -> Report {
|
||||
Self::with_hardware(kind, app, Hardware::detect())
|
||||
}
|
||||
|
||||
/// Starts a report using supplied hardware, without running system probes.
|
||||
/// Interactive apps can collect the remaining machine details after their first frame.
|
||||
pub fn with_hardware(kind: Kind, app: App, hardware: Hardware) -> Report {
|
||||
Report {
|
||||
format: FORMAT.to_owned(),
|
||||
version: VERSION,
|
||||
kind,
|
||||
created: utc_now(),
|
||||
app,
|
||||
hardware: Hardware::detect(),
|
||||
hardware,
|
||||
conditions: Conditions::default(),
|
||||
metrics: BTreeMap::new(),
|
||||
frames: Vec::new(),
|
||||
|
||||
@@ -27,6 +27,8 @@ bytemuck = { version = "1.24", features = ["derive"] }
|
||||
glam = "0.30"
|
||||
png = "0.18"
|
||||
sha2 = "0.10.9"
|
||||
# Server resource packs are checked and cached by their SHA-1, as vanilla does.
|
||||
sha1 = "0.10"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! What a column costs to load, on real terrain: generates and lights the
|
||||
//! columns around a point with the vanilla generator, as a world being explored
|
||||
//! does, then meshes every section of the inner columns on this one thread, so a
|
||||
//! change to generation or meshing shows as time per column and per section.
|
||||
//!
|
||||
//! cargo run --release -p horizon-client --example chunk_load_bench -- \
|
||||
//! .local/horizon-client/26.3 [--seed 1234] [--center CX CZ] [--radius 8]
|
||||
//! [--threads N] [--rounds 3]
|
||||
//!
|
||||
//! The default centre is the village of `tools/perf_bench.py`. The generation
|
||||
//! figure is wall time with N workers (the game's own pool); the mesh figure is
|
||||
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
|
||||
//! work on the machine changes it less. The checksum of the meshes shows that an
|
||||
//! A/B pair of builds made the same ones.
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::HEIGHT,
|
||||
light::LightTables,
|
||||
mesher::{MeshTables, Mesher, Region, borrow},
|
||||
models,
|
||||
spans::thread_cpu,
|
||||
streaming::{Streaming, WorkerContext},
|
||||
vanillagen::VanillaTerrain,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::biome::{BiomeZoom, obfuscate_seed};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
|
||||
fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
|
||||
args.iter()
|
||||
.position(|a| a == name)
|
||||
.and_then(|i| args.get(i + 1)?.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Error> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let root = PathBuf::from(args.get(1).ok_or("usage: chunk_load_bench <resources>")?);
|
||||
let seed: i64 = flag(&args, "--seed", 1234);
|
||||
let radius: i32 = flag(&args, "--radius", 8);
|
||||
let rounds: usize = flag(&args, "--rounds", 3);
|
||||
let threads: usize = flag(
|
||||
&args,
|
||||
"--threads",
|
||||
thread::available_parallelism().map_or(2, |n| n.get()),
|
||||
);
|
||||
let center = args
|
||||
.iter()
|
||||
.position(|a| a == "--center")
|
||||
.map(|i| -> Result<(i32, i32), Error> {
|
||||
Ok((
|
||||
args.get(i + 1).ok_or("--center CX CZ")?.parse()?,
|
||||
args.get(i + 2).ok_or("--center CX CZ")?.parse()?,
|
||||
))
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or((-60, 10));
|
||||
|
||||
let assets = Arc::new(Assets::load(&root)?);
|
||||
let render = Arc::new(assets.reload(&root)?);
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let visibility = models::load_visibility(&root, assets.registry)?;
|
||||
let biomes = Arc::new(Biomes::load(&root)?);
|
||||
let context = Arc::new(WorkerContext {
|
||||
tables: Arc::new(MeshTables::new(&assets, &render, &facts, visibility)?),
|
||||
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
|
||||
biomes: biomes.clone(),
|
||||
assets: assets.clone(),
|
||||
render,
|
||||
});
|
||||
let terrain = VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed)?;
|
||||
|
||||
// Generation and light, on the game's worker pool. Meshing is left out
|
||||
// here and measured on its own below.
|
||||
let mut streaming = Streaming::new(Arc::new(terrain), context.clone(), threads);
|
||||
streaming.mesh = false;
|
||||
streaming.center = center;
|
||||
streaming.radius = radius;
|
||||
let mut world = World::new(BlockStateId(0), 0);
|
||||
let started = Instant::now();
|
||||
let mut quiet = 0;
|
||||
while quiet < 100 {
|
||||
let before = world.chunks.len();
|
||||
let _ = streaming.update(&mut world);
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
quiet = if streaming.pending() == 0 && before == world.chunks.len() {
|
||||
quiet + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
}
|
||||
let generated = started.elapsed() - Duration::from_millis(2 * 100);
|
||||
let columns = world.chunks.len();
|
||||
println!(
|
||||
"generated and lit {columns} columns in {:.0} ms with {threads} workers ({:.2} ms per column)",
|
||||
generated.as_secs_f64() * 1e3,
|
||||
generated.as_secs_f64() * 1e3 / columns.max(1) as f64
|
||||
);
|
||||
|
||||
// Meshing every section of the columns whose neighbours are all loaded.
|
||||
let inner = radius - 1;
|
||||
let regions: Vec<(Region, i32)> = (center.0 - inner..=center.0 + inner)
|
||||
.flat_map(|x| (center.1 - inner..=center.1 + inner).map(move |z| (x, z)))
|
||||
.filter_map(|(x, z)| {
|
||||
let region: Region = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| {
|
||||
world
|
||||
.chunks
|
||||
.get(&(x + dx as i32 - 1, z + dz as i32 - 1))
|
||||
.cloned()
|
||||
})
|
||||
});
|
||||
region
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(Option::is_some)
|
||||
.then_some(region)
|
||||
})
|
||||
.flat_map(|region| (0..HEIGHT / 16).map(move |sy| (region.clone(), sy)))
|
||||
.collect();
|
||||
let mut best = Duration::MAX;
|
||||
let (mut vertices, mut checksum) = (0, 0);
|
||||
for _ in 0..rounds.max(1) {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
zoom: BiomeZoom::new(obfuscate_seed(seed)),
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
biomes: &context.biomes,
|
||||
visibility: context.tables.visibility.clone(),
|
||||
};
|
||||
vertices = 0;
|
||||
let mut hash = DefaultHasher::new();
|
||||
let mut spent = Duration::ZERO;
|
||||
for (region, sy) in ®ions {
|
||||
let started = thread_cpu().ok_or("no thread CPU clock here")?;
|
||||
let mesh = mesher.mesh(&borrow(region), *sy);
|
||||
spent += thread_cpu().ok_or("no thread CPU clock here")? - started;
|
||||
vertices += mesh.solid.len() + mesh.translucent.len();
|
||||
bytemuck::cast_slice::<_, u8>(&mesh.solid).hash(&mut hash);
|
||||
bytemuck::cast_slice::<_, u8>(&mesh.translucent).hash(&mut hash);
|
||||
}
|
||||
checksum = hash.finish();
|
||||
best = best.min(spent);
|
||||
}
|
||||
println!(
|
||||
"meshed {} sections ({vertices} vertices, checksum {checksum:016x}) in {:.1} ms of CPU ({:.1} us per section)",
|
||||
regions.len(),
|
||||
best.as_secs_f64() * 1e3,
|
||||
best.as_secs_f64() * 1e6 / regions.len().max(1) as f64
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -85,6 +85,7 @@ mod sprint_probe;
|
||||
mod subtitle_fixture;
|
||||
mod tab_list_fixture;
|
||||
mod team_rules_fixture;
|
||||
mod text_display_fixture;
|
||||
mod toggle_keys_probe;
|
||||
mod tooltip_display_fixture;
|
||||
mod trading_fixture;
|
||||
@@ -170,6 +171,7 @@ probes! {
|
||||
team_rules_fixture,
|
||||
toggle_keys_probe,
|
||||
tooltip_display_fixture,
|
||||
text_display_fixture,
|
||||
trading_fixture,
|
||||
translucency_fixture,
|
||||
weather_fixture,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! usage: pack_screen_fixture RESOURCES CASES OUTPUT
|
||||
use horizon_client::{
|
||||
hud::HudAtlas,
|
||||
hud::{HudAtlas, PACK_ICON_SIZE},
|
||||
lang,
|
||||
pack_screen::{Compat, PackInfo, PackScreen, Source},
|
||||
packs::{self, Position},
|
||||
@@ -13,7 +13,7 @@ use horizon_client::{
|
||||
text::Text,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{fmt::Write as _, fs, path::PathBuf};
|
||||
use std::{fmt::Write as _, fs, path::PathBuf, sync::Arc};
|
||||
|
||||
/// Styled runs: [text, rgb or null, bold, italic, underlined, strikethrough, obfuscated].
|
||||
pub fn runs(t: &Text) -> Value {
|
||||
@@ -105,7 +105,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
Some("unknown") => Compat::Unknown,
|
||||
_ => Compat::Compatible,
|
||||
},
|
||||
icon: p["icon"].as_bool().unwrap_or(false),
|
||||
icon: p["icon"].as_bool().unwrap_or(false).then(|| {
|
||||
Arc::from(vec![0; (PACK_ICON_SIZE * PACK_ICON_SIZE * 4) as usize])
|
||||
}),
|
||||
required: p["required"].as_bool().unwrap_or(false),
|
||||
position: if p["position"] == "top" {
|
||||
Position::Top
|
||||
|
||||
@@ -83,7 +83,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let mut extra = String::new();
|
||||
match kind {
|
||||
"give" => count = book.add_recipes(&index, &list()).0,
|
||||
"take" => count = book.remove_recipes(&index, &list()),
|
||||
"take" => count = book.remove_recipes(&index, &list()).len(),
|
||||
"seen" => book.remove_highlight(op["id"].as_str().unwrap_or_default()),
|
||||
"set" => {
|
||||
let i = match op["type"].as_str().unwrap_or_default() {
|
||||
|
||||
@@ -136,7 +136,6 @@ fn scene(
|
||||
blend_radius,
|
||||
smooth_lighting,
|
||||
cutout_leaves,
|
||||
cull_leaves: false,
|
||||
};
|
||||
let section_y = int(&row["section_y"])?;
|
||||
let started = Instant::now();
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
use horizon_client::text_display::TextDisplayData;
|
||||
use horizon_protocol::{ids, packets::play::Clientbound};
|
||||
use std::{error::Error, io::Write};
|
||||
|
||||
pub fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let mut out = std::fs::File::create(&args[2])?;
|
||||
for line in std::fs::read_to_string(&args[1])?.lines() {
|
||||
let input: serde_json::Value = serde_json::from_str(line)?;
|
||||
let hex = input["wire"].as_str().unwrap();
|
||||
let bytes: Vec<u8> = (0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let Clientbound::SetEntityData(packet) =
|
||||
Clientbound::decode(ids::play::clientbound::SET_ENTITY_DATA, &bytes)?
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let mut display = TextDisplayData::default();
|
||||
display.apply(&packet.values);
|
||||
if let Some(angles) = input["angles"].as_array() {
|
||||
let a: Vec<_> = angles.iter().map(|v| v.as_f64().unwrap() as f32).collect();
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
serde_json::to_string(&display.matrix([a[0], a[1]], [a[2], a[3]]).to_cols_array())?
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
let mut state = serde_json::to_value(&display)?;
|
||||
state["text"] = display.text.plain().into();
|
||||
state["alignment"] = format!("{:?}", display.align()).to_uppercase().into();
|
||||
writeln!(out, "{}", state)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -161,6 +161,9 @@ pub struct Audio {
|
||||
/// How many times each positional sound played (the control socket's `status`),
|
||||
/// with or without an audio device.
|
||||
pub heard: std::collections::BTreeMap<String, u32>,
|
||||
/// How many times each selection was stopped ("record *", "* minecraft:…", `*` for
|
||||
/// any), with or without an audio device, as `heard`.
|
||||
pub stopped: std::collections::BTreeMap<String, u32>,
|
||||
}
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
use symphonia::core::{
|
||||
@@ -595,6 +598,7 @@ impl Audio {
|
||||
source_override: None,
|
||||
capture: None,
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
@@ -825,6 +829,14 @@ impl Audio {
|
||||
/// and/or a source.
|
||||
pub fn stop_sounds(&mut self, event: Option<&str>, source: Option<usize>) {
|
||||
let event = event.map(crate::sound_events::normalize);
|
||||
let selection = format!(
|
||||
"{} {}",
|
||||
source
|
||||
.and_then(|s| crate::settings::SOUND_SOURCES.get(s))
|
||||
.map_or("*", |s| s),
|
||||
event.as_deref().unwrap_or("*")
|
||||
);
|
||||
*self.stopped.entry(selection).or_default() += 1;
|
||||
self.engine
|
||||
.stop_matching(event.as_deref(), source, &mut self.backend);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
//! `BossHealthOverlay` and `LerpingBossEvent`: the boss bars a client is
|
||||
//! showing, in the order their add packets arrived.
|
||||
use crate::{
|
||||
hud_overlays::BossBar,
|
||||
scoreboard::{comp_text, tag_to_json},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_protocol::packets::play::{BossAction, BossEvent};
|
||||
|
||||
/// `LerpingBossEvent.LERP_MILLISECONDS`.
|
||||
const LERP_MS: u128 = 100;
|
||||
|
||||
/// `Util.getMillis`.
|
||||
pub fn now_millis() -> u128 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// One `LerpingBossEvent`.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Event {
|
||||
id: u128,
|
||||
name: Text,
|
||||
/// The progress the lerp started from (`progress`).
|
||||
progress: f32,
|
||||
/// `targetPercent`.
|
||||
target: f32,
|
||||
/// `setTime`.
|
||||
set_at: u128,
|
||||
color: u8,
|
||||
overlay: u8,
|
||||
darken: bool,
|
||||
music: bool,
|
||||
fog: bool,
|
||||
}
|
||||
impl Event {
|
||||
/// `LerpingBossEvent.getProgress`.
|
||||
fn displayed(&self, now: u128) -> f32 {
|
||||
let t = ((now.saturating_sub(self.set_at)) as f32 / LERP_MS as f32).clamp(0.0, 1.0);
|
||||
self.progress + (self.target - self.progress) * t
|
||||
}
|
||||
/// `LerpingBossEvent.setProgress`: the bar starts the next lerp from
|
||||
/// whatever it is showing now.
|
||||
fn set_progress(&mut self, progress: f32, now: u128) {
|
||||
self.progress = self.displayed(now);
|
||||
self.target = progress;
|
||||
self.set_at = now;
|
||||
}
|
||||
}
|
||||
|
||||
/// `BossHealthOverlay`: the events, plus `GameRenderer`'s world darkening.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BossOverlay {
|
||||
events: Vec<Event>,
|
||||
/// `bossOverlayWorldDarkening` and the previous tick's value.
|
||||
darken: f32,
|
||||
darken_o: f32,
|
||||
}
|
||||
impl BossOverlay {
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
/// `ClientboundBossEventPacket.dispatch` into `BossHealthOverlay`'s
|
||||
/// handler. An id already shown keeps its place when added again.
|
||||
pub fn apply(&mut self, packet: &BossEvent, now: u128) {
|
||||
// An ordinal past the enum shows as its last value.
|
||||
let color = |c: i32| c.clamp(0, 6) as u8;
|
||||
let overlay = |o: i32| o.clamp(0, 4) as u8;
|
||||
let id = packet.id;
|
||||
if let BossAction::Add {
|
||||
name,
|
||||
progress,
|
||||
color: c,
|
||||
overlay: o,
|
||||
darken,
|
||||
music,
|
||||
fog,
|
||||
} = &packet.action
|
||||
{
|
||||
let event = Event {
|
||||
id,
|
||||
name: comp_text(&tag_to_json(name)),
|
||||
progress: *progress,
|
||||
target: *progress,
|
||||
set_at: now,
|
||||
color: color(*c),
|
||||
overlay: overlay(*o),
|
||||
darken: *darken,
|
||||
music: *music,
|
||||
fog: *fog,
|
||||
};
|
||||
match self.events.iter_mut().find(|e| e.id == id) {
|
||||
Some(slot) => *slot = event,
|
||||
None => self.events.push(event),
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let BossAction::Remove = packet.action {
|
||||
self.events.retain(|e| e.id != id);
|
||||
return;
|
||||
}
|
||||
let Some(e) = self.events.iter_mut().find(|e| e.id == id) else {
|
||||
return;
|
||||
};
|
||||
match &packet.action {
|
||||
BossAction::UpdateProgress { progress } => e.set_progress(*progress, now),
|
||||
BossAction::UpdateName { name } => e.name = comp_text(&tag_to_json(name)),
|
||||
BossAction::UpdateStyle {
|
||||
color: c,
|
||||
overlay: o,
|
||||
} => {
|
||||
e.color = color(*c);
|
||||
e.overlay = overlay(*o);
|
||||
}
|
||||
BossAction::UpdateProperties { darken, music, fog } => {
|
||||
(e.darken, e.music, e.fog) = (*darken, *music, *fog);
|
||||
}
|
||||
BossAction::Add { .. } | BossAction::Remove => {}
|
||||
}
|
||||
}
|
||||
/// `GameRenderer.tick` while the level runs.
|
||||
pub fn tick_darken(&mut self, running: bool) {
|
||||
if !running {
|
||||
return;
|
||||
}
|
||||
self.darken_o = self.darken;
|
||||
if self.should_darken() {
|
||||
self.darken = (self.darken + 0.05).min(1.0);
|
||||
} else if self.darken > 0.0 {
|
||||
self.darken = (self.darken - 0.0125).max(0.0);
|
||||
}
|
||||
}
|
||||
/// `GameRenderer.bossOverlayWorldDarkening`.
|
||||
pub fn darken_amount(&self, partial: f32) -> f32 {
|
||||
let t = partial.clamp(0.0, 1.0);
|
||||
self.darken_o + (self.darken - self.darken_o) * t
|
||||
}
|
||||
pub fn should_darken(&self) -> bool {
|
||||
self.events.iter().any(|e| e.darken)
|
||||
}
|
||||
pub fn should_play_music(&self) -> bool {
|
||||
self.events.iter().any(|e| e.music)
|
||||
}
|
||||
pub fn should_create_world_fog(&self) -> bool {
|
||||
self.events.iter().any(|e| e.fog)
|
||||
}
|
||||
/// The bars `extractRenderState` walks, with the lerped progress.
|
||||
pub fn bars(&self, now: u128) -> Vec<BossBar> {
|
||||
self.events
|
||||
.iter()
|
||||
.map(|e| BossBar {
|
||||
name: e.name.clone(),
|
||||
progress: e.displayed(now),
|
||||
color: e.color as usize,
|
||||
overlay: e.overlay as usize,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
fn add(id: u128, name: &str, progress: f32, darken: bool, fog: bool) -> BossEvent {
|
||||
BossEvent {
|
||||
id,
|
||||
action: BossAction::Add {
|
||||
name: Tag::String(name.into()),
|
||||
progress,
|
||||
color: 5,
|
||||
overlay: 0,
|
||||
darken,
|
||||
music: false,
|
||||
fog,
|
||||
},
|
||||
}
|
||||
}
|
||||
fn progress(id: u128, progress: f32) -> BossEvent {
|
||||
BossEvent {
|
||||
id,
|
||||
action: BossAction::UpdateProgress { progress },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_lerps_over_a_tenth_of_a_second() {
|
||||
let mut overlay = BossOverlay::default();
|
||||
overlay.apply(&add(1, "W", 0.0, true, false), 0);
|
||||
overlay.apply(&progress(1, 1.0), 0);
|
||||
assert!((overlay.bars(50)[0].progress - 0.5).abs() < 1.0e-5);
|
||||
assert!((overlay.bars(100)[0].progress - 1.0).abs() < 1.0e-5);
|
||||
// A new target starts from the value on screen.
|
||||
overlay.apply(&progress(1, 0.0), 50);
|
||||
assert!((overlay.bars(150)[0].progress - 0.0).abs() < 1.0e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn darken_ramps_and_fades() {
|
||||
let mut overlay = BossOverlay::default();
|
||||
overlay.apply(&add(1, "W", 1.0, true, false), 0);
|
||||
overlay.tick_darken(true);
|
||||
assert!((overlay.darken_amount(1.0) - 0.05).abs() < 1.0e-5);
|
||||
overlay.apply(
|
||||
&BossEvent {
|
||||
id: 1,
|
||||
action: BossAction::Remove,
|
||||
},
|
||||
0,
|
||||
);
|
||||
overlay.tick_darken(true);
|
||||
assert!((overlay.darken - 0.0375).abs() < 1.0e-5);
|
||||
assert!(!overlay.should_play_music());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bars_keep_arrival_order() {
|
||||
let mut overlay = BossOverlay::default();
|
||||
overlay.apply(&add(2, "b", 1.0, false, true), 0);
|
||||
overlay.apply(&add(1, "a", 1.0, false, false), 0);
|
||||
overlay.apply(&add(2, "c", 0.5, false, true), 0);
|
||||
// An update for a bar this client never got is ignored.
|
||||
overlay.apply(&progress(9, 0.5), 0);
|
||||
let names: Vec<_> = overlay
|
||||
.bars(0)
|
||||
.into_iter()
|
||||
.map(|b| b.name.plain())
|
||||
.collect();
|
||||
assert_eq!(names, ["c", "a"]);
|
||||
assert!(overlay.should_create_world_fog());
|
||||
overlay.apply(
|
||||
&BossEvent {
|
||||
id: 2,
|
||||
action: BossAction::Remove,
|
||||
},
|
||||
0,
|
||||
);
|
||||
assert!(!overlay.should_create_world_fog());
|
||||
}
|
||||
}
|
||||
@@ -14,40 +14,64 @@ pub const STYLES: [&str; 5] = [
|
||||
"notched_12",
|
||||
"notched_20",
|
||||
];
|
||||
/// `BossBarColor.getFormatting`.
|
||||
/// `BossBarColor.getFormatting`. Purple is dark blue in 26.3.
|
||||
pub fn color_rgb(color: &str) -> u32 {
|
||||
let name = match color {
|
||||
"pink" => "red",
|
||||
"red" => "dark_red",
|
||||
"purple" => "dark_purple",
|
||||
"purple" => "dark_blue",
|
||||
other => other,
|
||||
};
|
||||
text::named_color(name).unwrap_or(text::WHITE)
|
||||
}
|
||||
|
||||
/// A version-4 shaped id from a resource id. Saved boss events do not keep
|
||||
/// their UUID (`CustomBossEvents` mints a new one on load).
|
||||
pub fn stable_uuid(id: &str) -> u128 {
|
||||
let mut h = 0xcbf29ce484222325u64;
|
||||
for b in id.as_bytes() {
|
||||
h ^= u64::from(*b);
|
||||
h = h.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
let most = (h as i64) & -61441 | 16384;
|
||||
let least = (h.rotate_left(17) as i64) & 4_611_686_018_427_387_903 | i64::MIN;
|
||||
((most as u64 as u128) << 64) | (least as u64 as u128)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BossBar {
|
||||
pub id: String,
|
||||
/// The `ServerBossEvent` UUID. Not saved.
|
||||
pub uuid: u128,
|
||||
pub name: Comp,
|
||||
pub color: String,
|
||||
pub style: String,
|
||||
pub value: i32,
|
||||
pub max: i32,
|
||||
/// `ServerBossEvent.visible`. A new bar is visible; nobody sees it until
|
||||
/// a player is added.
|
||||
pub visible: bool,
|
||||
/// Whether the (single) local player is in the bar's player set.
|
||||
pub player: bool,
|
||||
pub darken: bool,
|
||||
pub music: bool,
|
||||
pub fog: bool,
|
||||
/// `CustomBossEvent.players`, the UUIDs told about the bar.
|
||||
pub players: Vec<[i32; 4]>,
|
||||
}
|
||||
impl BossBar {
|
||||
pub fn new(id: &str, name: Comp) -> Self {
|
||||
Self {
|
||||
id: id.to_owned(),
|
||||
uuid: stable_uuid(id),
|
||||
name,
|
||||
color: "white".into(),
|
||||
style: "progress".into(),
|
||||
value: 0,
|
||||
max: 100,
|
||||
visible: true,
|
||||
player: false,
|
||||
darken: false,
|
||||
music: false,
|
||||
fog: false,
|
||||
players: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// `CustomBossEvent.getDisplayName`.
|
||||
@@ -91,31 +115,34 @@ impl BossBars {
|
||||
pub fn ids(&self) -> Vec<String> {
|
||||
self.bars.iter().map(|b| b.id.clone()).collect()
|
||||
}
|
||||
/// The saved compound; the player's UUID stands for "the local player".
|
||||
pub fn pack(&self, player: Option<[i32; 4]>) -> Tag {
|
||||
/// The saved compound (`CustomBossEvent.Packed`).
|
||||
pub fn pack(&self) -> Tag {
|
||||
let mut c = Compound::new();
|
||||
for b in &self.bars {
|
||||
let mut e = vec![
|
||||
("Name", json_to_tag(&b.name)),
|
||||
("Visible", Tag::Byte(i8::from(b.visible))),
|
||||
("Value", Tag::Int(b.value)),
|
||||
("Max", Tag::Int(b.max)),
|
||||
("Color", Tag::String(b.color.as_str().into())),
|
||||
("Overlay", Tag::String(b.style.as_str().into())),
|
||||
("DarkenScreen", Tag::Byte(0)),
|
||||
("PlayBossMusic", Tag::Byte(0)),
|
||||
("CreateWorldFog", Tag::Byte(0)),
|
||||
];
|
||||
let players = match (b.player, player) {
|
||||
(true, Some(u)) => vec![Tag::IntArray(u.to_vec())],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
e.push(("Players", Tag::List(players)));
|
||||
c.insert(b.id.as_str().into(), Tag::compound(e));
|
||||
let players = b
|
||||
.players
|
||||
.iter()
|
||||
.map(|u| Tag::IntArray(u.to_vec()))
|
||||
.collect();
|
||||
c.insert(
|
||||
b.id.as_str().into(),
|
||||
Tag::compound([
|
||||
("Name", json_to_tag(&b.name)),
|
||||
("Visible", Tag::Byte(i8::from(b.visible))),
|
||||
("Value", Tag::Int(b.value)),
|
||||
("Max", Tag::Int(b.max)),
|
||||
("Color", Tag::String(b.color.as_str().into())),
|
||||
("Overlay", Tag::String(b.style.as_str().into())),
|
||||
("DarkenScreen", Tag::Byte(i8::from(b.darken))),
|
||||
("PlayBossMusic", Tag::Byte(i8::from(b.music))),
|
||||
("CreateWorldFog", Tag::Byte(i8::from(b.fog))),
|
||||
("Players", Tag::List(players)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
Tag::Compound(c)
|
||||
}
|
||||
pub fn unpack(data: &Tag, player: Option<[i32; 4]>) -> Self {
|
||||
pub fn unpack(data: &Tag) -> Self {
|
||||
let mut bars = Vec::new();
|
||||
if let Tag::Compound(c) = data {
|
||||
for (id, e) in c {
|
||||
@@ -130,21 +157,30 @@ impl BossBars {
|
||||
Some(Tag::Byte(v)) => i32::from(*v),
|
||||
_ => d,
|
||||
};
|
||||
let in_players = match (get("Players"), player) {
|
||||
(Some(Tag::List(l)), Some(u)) => l
|
||||
let players = match get("Players") {
|
||||
Some(Tag::List(l)) => l
|
||||
.iter()
|
||||
.any(|t| matches!(t, Tag::IntArray(a) if a.as_slice() == u)),
|
||||
_ => false,
|
||||
.filter_map(|t| match t {
|
||||
Tag::IntArray(a) if a.len() == 4 => Some([a[0], a[1], a[2], a[3]]),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let id = id.to_string_lossy();
|
||||
bars.push(BossBar {
|
||||
id: id.to_string_lossy(),
|
||||
uuid: stable_uuid(&id),
|
||||
id,
|
||||
name: get("Name").map_or_else(|| literal(""), tag_to_json),
|
||||
color: text("Color", "white"),
|
||||
style: text("Overlay", "progress"),
|
||||
value: num("Value", 0),
|
||||
max: num("Max", 100),
|
||||
visible: num("Visible", 0) != 0,
|
||||
player: in_players,
|
||||
darken: num("DarkenScreen", 0) != 0,
|
||||
music: num("PlayBossMusic", 0) != 0,
|
||||
fog: num("CreateWorldFog", 0) != 0,
|
||||
players,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -167,17 +203,15 @@ mod tests {
|
||||
bar.value = 25;
|
||||
bar.max = 50;
|
||||
bar.visible = true;
|
||||
bar.player = true;
|
||||
let uuid = Some([1, 2, 3, 4]);
|
||||
let back = BossBars::unpack(&b.pack(uuid), uuid);
|
||||
bar.darken = true;
|
||||
bar.players = vec![[1, 2, 3, 4]];
|
||||
let back = BossBars::unpack(&b.pack());
|
||||
assert_eq!(back, b);
|
||||
// Another player's UUID does not include the local player.
|
||||
assert!(!BossBars::unpack(&b.pack(uuid), Some([9, 9, 9, 9])).bars[0].player);
|
||||
let bar = b.get("test:x").unwrap();
|
||||
assert_eq!(bar.progress(), 0.5);
|
||||
assert_eq!(bar.display_name().plain(), "chat.square_brackets");
|
||||
assert_eq!(color_rgb("pink"), text::RED);
|
||||
assert_eq!(color_rgb("purple"), 0xAA00AA);
|
||||
assert_eq!(color_rgb("purple"), text::named_color("dark_blue").unwrap());
|
||||
b.remove("test:x");
|
||||
assert!(b.bars.is_empty());
|
||||
}
|
||||
|
||||
@@ -133,6 +133,30 @@ impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCo
|
||||
border.collision_shape().map(Some).map_err(io::Error::other)
|
||||
}
|
||||
}
|
||||
impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
|
||||
/// `SceneCollisions::resolve` that also stops at the entity boxes.
|
||||
pub fn resolve(
|
||||
&mut self,
|
||||
bounds: Aabb,
|
||||
movement: Vec3,
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
) -> Result<Vec3> {
|
||||
let context = self.blocks.context(bounds);
|
||||
Ok(resolve_world_movement(
|
||||
self,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
on_ground,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)?)
|
||||
}
|
||||
}
|
||||
impl SceneCollisions<'_, '_> {
|
||||
/// Position contexts permit geometric movement probes without inventing a
|
||||
/// player, inventory, effects or fluid-standing behavior; a lava walker
|
||||
|
||||
@@ -55,7 +55,7 @@ impl CommandStorage {
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str().into(), v.clone()))
|
||||
.collect();
|
||||
let _ = save::write_dat(
|
||||
if let Err(e) = save::write_dat(
|
||||
&file(root, namespace),
|
||||
Tag::compound([
|
||||
(
|
||||
@@ -64,7 +64,9 @@ impl CommandStorage {
|
||||
),
|
||||
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
|
||||
]),
|
||||
);
|
||||
) {
|
||||
log::warn!("command storage {namespace} was not written: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `get`: an empty compound when unset.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! `ConfirmScreen`: a title, a message wrapped to `width - 50` (15 lines at
|
||||
//! most) and two 150-wide buttons 4 apart and 16 below it, the whole column
|
||||
//! centred; Escape answers no.
|
||||
use crate::{
|
||||
font::Font,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
|
||||
pub struct Confirm {
|
||||
/// Each line and where it is drawn.
|
||||
pub lines: Vec<(Text, f32, f32)>,
|
||||
/// The yes and no buttons' corners.
|
||||
pub buttons: [(f32, f32); 2],
|
||||
}
|
||||
impl Confirm {
|
||||
pub fn new(font: &Font, (w, h): (f32, f32), title: &Text, message: &Text) -> Self {
|
||||
let message: Vec<Text> = font
|
||||
.split_lines(message, w - 50.0)
|
||||
.into_iter()
|
||||
.map(|(line, _)| line)
|
||||
.take(15)
|
||||
.collect();
|
||||
let tw = font.width_text(title);
|
||||
let mw = message
|
||||
.iter()
|
||||
.map(|l| font.width_text(l))
|
||||
.fold(0.0, f32::max);
|
||||
let mh = 9.0 * message.len() as f32;
|
||||
let col = tw.max(mw).max(304.0);
|
||||
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
|
||||
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
|
||||
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
|
||||
let mid = cell(mw) + (mw / 2.0).trunc();
|
||||
let mut lines = vec![(title.clone(), cell(tw), ly)];
|
||||
for (i, line) in message.into_iter().enumerate() {
|
||||
let x = mid - (font.width_text(&line) / 2.0).trunc();
|
||||
lines.push((line, x, ly + 17.0 + 9.0 * i as f32));
|
||||
}
|
||||
let (bx, by) = (cell(304.0), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
|
||||
Self {
|
||||
lines,
|
||||
buttons: [(bx, by), (bx + 154.0, by)],
|
||||
}
|
||||
}
|
||||
/// The answer a click at `cursor` gives: true for yes, false for no.
|
||||
pub fn answer_at(&self, cursor: (f32, f32)) -> Option<bool> {
|
||||
let [yes, no] = self.buttons;
|
||||
let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0);
|
||||
if on(yes) {
|
||||
Some(true)
|
||||
} else if on(no) {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// The lines and the buttons (yes first), over whatever is behind.
|
||||
pub fn draw(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
labels: [&Text; 2],
|
||||
) {
|
||||
for (line, x, y) in &self.lines {
|
||||
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
|
||||
}
|
||||
for ((bx, by), label) in self.buttons.into_iter().zip(labels) {
|
||||
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
|
||||
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, true, hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,6 +198,8 @@ pub struct FogInputs<'a> {
|
||||
pub blindness: Option<(i32, bool)>,
|
||||
/// The darkness effect's blend factor, while the effect is on.
|
||||
pub darkness: Option<f32>,
|
||||
/// `GameRenderer.bossOverlayWorldDarkening`: a wither's bar dims the fog.
|
||||
pub darken_world: f32,
|
||||
/// `BossHealthOverlay.shouldCreateWorldFog`: a boss bar with
|
||||
/// `createWorldFog` is shown (the Ender Dragon's).
|
||||
pub boss_world_fog: bool,
|
||||
@@ -263,6 +265,13 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
let brightness = (1.0 - darkness) * (1.0 - darkness);
|
||||
color = color.map(|c| c * brightness);
|
||||
}
|
||||
// FogRenderer.computeFogColor: a boss bar's world darkening.
|
||||
if input.darken_world > 0.0 {
|
||||
let t = input.darken_world;
|
||||
color[0] += (color[0] * 0.7 - color[0]) * t;
|
||||
color[1] += (color[1] * 0.6 - color[1]) * t;
|
||||
color[2] += (color[2] * 0.6 - color[2]) * t;
|
||||
}
|
||||
let brighten = if eye_in_water { water_vision } else { 0.0 };
|
||||
if color.iter().all(|&c| c != 0.0) {
|
||||
let scale = 1.0 / color[0].max(color[1]).max(color[2]);
|
||||
@@ -394,6 +403,7 @@ mod tests {
|
||||
skybox: true,
|
||||
blindness: None,
|
||||
darkness: None,
|
||||
darken_world: 0.0,
|
||||
boss_world_fog: false,
|
||||
}
|
||||
}
|
||||
@@ -422,6 +432,25 @@ mod tests {
|
||||
assert_eq!(water.environmental_start, -8.0);
|
||||
}
|
||||
#[test]
|
||||
fn boss_darkening_dims_fog_color() {
|
||||
// FogRenderer.computeFogColor: lerp toward 70/60/60% of each channel.
|
||||
let sky = Sky {
|
||||
fog_color: [1.0, 1.0, 1.0],
|
||||
sky_color: [1.0, 1.0, 1.0],
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let mut input = inputs(&sky);
|
||||
input.skybox = false;
|
||||
input.darken_world = 1.0;
|
||||
let fog = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert!((fog.color[0] - 0.7).abs() < 1.0e-5);
|
||||
assert!((fog.color[1] - 0.6).abs() < 1.0e-5);
|
||||
assert!((fog.color[2] - 0.6).abs() < 1.0e-5);
|
||||
}
|
||||
#[test]
|
||||
fn void_darkness_starts_32_above_the_floor() {
|
||||
let sky = Sky {
|
||||
fog_end: 1024.0,
|
||||
|
||||
@@ -13,6 +13,7 @@ mod block_entity_anims;
|
||||
mod block_entity_ticks;
|
||||
mod block_items;
|
||||
mod blockctl;
|
||||
mod boat_passengers;
|
||||
mod boats;
|
||||
mod books;
|
||||
mod bossbars;
|
||||
@@ -43,6 +44,7 @@ mod enchant_effects;
|
||||
mod end;
|
||||
mod entity_culling;
|
||||
mod events;
|
||||
mod execution_role;
|
||||
mod explosion;
|
||||
mod extras;
|
||||
mod falling_damage;
|
||||
@@ -137,6 +139,7 @@ mod scoreboard;
|
||||
mod screens;
|
||||
mod sculk;
|
||||
mod sculk_fixture;
|
||||
mod server_packs;
|
||||
mod server_travel;
|
||||
mod shield;
|
||||
mod sign_edit;
|
||||
@@ -152,6 +155,7 @@ mod storage;
|
||||
mod structures;
|
||||
mod subtitle_hud;
|
||||
mod sulfur_cubes;
|
||||
mod text_displays;
|
||||
mod titles;
|
||||
mod tnt;
|
||||
mod totem;
|
||||
@@ -288,8 +292,8 @@ struct Options {
|
||||
/// as vanilla's does: the default. `--in-frame` (or `--no-server-thread`,
|
||||
/// or `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead.
|
||||
server_thread: bool,
|
||||
/// Set on the integrated server's own options: it draws nothing, so it skips meshing.
|
||||
integrated: bool,
|
||||
/// What this Game executes; independent of its current server connection.
|
||||
execution_role: execution_role::ExecutionRole,
|
||||
}
|
||||
impl Options {
|
||||
/// The name to join servers with: the one given, else the offline name kept
|
||||
@@ -346,7 +350,7 @@ impl Options {
|
||||
// itself, without the event loop: it stays in the frame.
|
||||
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0")
|
||||
&& std::env::var_os("HORIZON_COMMAND_SCRIPT").is_none(),
|
||||
integrated: false,
|
||||
execution_role: execution_role::ExecutionRole::Client,
|
||||
};
|
||||
let mut session_file = std::env::var("HORIZON_SESSION_FILE").ok();
|
||||
let mut args = std::env::args().skip(1);
|
||||
@@ -360,6 +364,7 @@ impl Options {
|
||||
return Ok(None);
|
||||
}
|
||||
"--dedicated" => {
|
||||
o.execution_role = execution_role::ExecutionRole::DedicatedServer;
|
||||
o.dedicated = Some(args.by_ref().collect());
|
||||
break;
|
||||
}
|
||||
@@ -1126,6 +1131,8 @@ struct Game<'a> {
|
||||
pending_entry: Option<PendingWorldEntry>,
|
||||
world_loader: Option<world_loading::WorldLoader>,
|
||||
pending_pack_reload: bool,
|
||||
/// A server's resource packs (game/server_packs.rs).
|
||||
server_packs: server_packs::ServerPackState,
|
||||
world_renderer_ready: bool,
|
||||
collision: &'a BlockCollisionRegistry<'a>,
|
||||
motion: &'a MotionResources,
|
||||
@@ -1418,6 +1425,8 @@ struct Game<'a> {
|
||||
dragon_model: Option<horizon_client::mobs::Part>,
|
||||
/// Map textures waiting for the next HUD batch to upload them.
|
||||
map_patches: Vec<horizon_client::font::Patch>,
|
||||
text_displays: std::collections::HashMap<i32, text_displays::RemoteTextDisplay>,
|
||||
text_display_vertices: usize,
|
||||
/// Player skin downloads, and which skin each atlas skin slot holds
|
||||
/// (least recently shown first).
|
||||
skin_downloads: RefCell<horizon_client::skins::Downloader>,
|
||||
@@ -1562,6 +1571,8 @@ struct Game<'a> {
|
||||
score: scoreboard::ScoreState,
|
||||
fns: functions::FnState,
|
||||
bosses: bossbars::BossState,
|
||||
/// `Hud.bossOverlay`: the bars this window is showing.
|
||||
boss_overlay: horizon_client::boss_overlay::BossOverlay,
|
||||
exec: commands::execute::ExecState,
|
||||
rand_sequences: horizon_client::random_sequences::RandomSequences,
|
||||
border: horizon_client::worldborder::WorldBorder,
|
||||
@@ -4042,6 +4053,20 @@ impl Game<'_> {
|
||||
self.capture_loaded_world();
|
||||
Ok(())
|
||||
}
|
||||
/// Landing rules (farmland trampling, turtle eggs) wrote or removed the block
|
||||
/// raw: finish `Level.setBlock`'s neighbour updates, so a crop on trampled
|
||||
/// farmland breaks. The local player's and every server player's landings.
|
||||
fn landing_block_updates(&mut self, event: &horizon_client::landing::FallEvent) {
|
||||
use horizon_client::landing::FallEvent;
|
||||
let (position, flags) = match *event {
|
||||
FallEvent::Write {
|
||||
position, flags, ..
|
||||
} => (position, flags as i32),
|
||||
FallEvent::Destroy { position, .. } => (position, level_flags::UPDATE_ALL),
|
||||
_ => return,
|
||||
};
|
||||
self.with_level(|b, level, ticks| b.notify_written(level, ticks, position, flags));
|
||||
}
|
||||
fn tick(&mut self) -> Result<()> {
|
||||
self.tick_debug_keys()?;
|
||||
self.tick_debug_charts();
|
||||
@@ -4060,6 +4085,8 @@ impl Game<'_> {
|
||||
self.track_experience_display();
|
||||
// `/tick freeze` stops the world but not the player.
|
||||
let running = self.clock.world_runs();
|
||||
// GameRenderer.tick: a wither's bar darkens the world, then fades.
|
||||
self.boss_overlay.tick_darken(running);
|
||||
if running {
|
||||
self.world.game_time += 1;
|
||||
if self.ui.rules.bool("advance_weather") {
|
||||
@@ -4256,23 +4283,7 @@ impl Game<'_> {
|
||||
};
|
||||
self.audio.play(name, Some(feet), volume, pitch);
|
||||
}
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Write {
|
||||
position,
|
||||
flags,
|
||||
..
|
||||
}) => {
|
||||
// Landing rules (farmland trampling, turtle eggs) wrote the block raw.
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.notify_written(level, ticks, position, flags as i32)
|
||||
});
|
||||
}
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Destroy {
|
||||
position, ..
|
||||
}) => {
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.notify_written(level, ticks, position, level_flags::UPDATE_ALL)
|
||||
});
|
||||
}
|
||||
MotionEvent::Fall(event) => self.landing_block_updates(&event),
|
||||
MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity),
|
||||
MotionEvent::EnterBlock { state } => self.trigger_enter_block(state),
|
||||
MotionEvent::InsidePortal { pos } => self.player_inside_nether_portal(pos),
|
||||
@@ -4410,7 +4421,7 @@ impl Game<'_> {
|
||||
self.resolve_arrivals();
|
||||
});
|
||||
}
|
||||
horizon_client::spans::time("tick_lan", || self.tick_lan());
|
||||
horizon_client::spans::time("tick_lan", || self.tick_lan(false));
|
||||
self.tick_friends();
|
||||
horizon_client::spans::time("tick_hand", || self.tick_hand());
|
||||
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
|
||||
@@ -4435,6 +4446,8 @@ impl Game<'_> {
|
||||
});
|
||||
horizon_client::spans::time("tick_particles", || self.tick_particles());
|
||||
}
|
||||
// ServerBossEvent broadcasts, after this level's entities have ticked.
|
||||
self.publish_boss_bars();
|
||||
// Minecraft.handleKeybinds: held use repeats only while no item is in
|
||||
// use (a raised shield or a drawn bow places and uses nothing).
|
||||
if self.right
|
||||
@@ -4771,7 +4784,14 @@ impl Game<'_> {
|
||||
hud_layout::paint(batch, &self.gui.atlas, &draws);
|
||||
}
|
||||
fn hud(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
let mut batch = self.hud_layers(size);
|
||||
// The pack prompt replaces the connecting screen (setScreen), but
|
||||
// in game it opens over the world.
|
||||
let mut batch = if self.server_packs.prompt.is_some() && self.player.is_none() {
|
||||
HudBatch::new(size)
|
||||
} else {
|
||||
self.hud_layers(size)
|
||||
};
|
||||
self.draw_pack_prompt(&mut batch);
|
||||
// Gui.extractRenderState: the debug overlay comes after the screen
|
||||
// and the toasts, so it is over everything, menus included.
|
||||
self.draw_debug_overlay(&mut batch, size);
|
||||
@@ -4808,11 +4828,15 @@ impl Game<'_> {
|
||||
}
|
||||
let atlas_ptr: *const HudAtlas = &self.gui.atlas;
|
||||
let _ = atlas_ptr;
|
||||
// The ToastManager draws over every screen: a server's packs
|
||||
// download while the connection screen shows.
|
||||
if self.remote_overlay(&mut batch) {
|
||||
self.draw_pack_toasts(&mut batch);
|
||||
return batch;
|
||||
}
|
||||
if let Some(reason) = self.loading_reason() {
|
||||
self.draw_level_loading(&mut batch, reason);
|
||||
self.draw_pack_toasts(&mut batch);
|
||||
return batch;
|
||||
}
|
||||
// Hud.extractRenderState: F1 (keyToggleGui) hides camera overlays, the
|
||||
@@ -5024,6 +5048,7 @@ impl Game<'_> {
|
||||
Screen::Playing | Screen::Options | Screen::Win => {}
|
||||
}
|
||||
self.draw_toasts(&mut batch);
|
||||
self.draw_pack_toasts(&mut batch);
|
||||
self.remote_hud(&mut batch);
|
||||
self.draw_sign_editor(&mut batch);
|
||||
self.draw_book(&mut batch);
|
||||
@@ -6761,6 +6786,7 @@ impl Game<'_> {
|
||||
let mut see_through = std::mem::take(&mut self.see_through_vertices);
|
||||
see_through.clear();
|
||||
self.name_tag_geometry(camera, alpha, &mut out, &mut see_through);
|
||||
self.text_display_geometry(camera, alpha, &mut out, &mut see_through);
|
||||
self.see_through_vertices = see_through;
|
||||
// Entity shadows (EntityRenderer.extractShadow).
|
||||
if let Some(sprite) = self.gui.atlas.sprites.get("misc/shadow").copied() {
|
||||
@@ -7491,7 +7517,7 @@ impl Game<'_> {
|
||||
(sky.star_brightness > 0.0).then(|| (frame(sky.star_angle), sky.star_brightness));
|
||||
(out, stars)
|
||||
}
|
||||
/// Load (or create) a world folder under saves/ and start streaming it.
|
||||
/// Enter a local world from the client, selecting its simulation mode.
|
||||
fn open_world(
|
||||
&mut self,
|
||||
folder: &str,
|
||||
@@ -7510,20 +7536,16 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
self.join_world_rest()?;
|
||||
let save_root = std::env::current_dir()?.join("saves").join(folder);
|
||||
if integrated::wanted(&self.options, &save_root) {
|
||||
if self
|
||||
.options
|
||||
.execution_role
|
||||
.starts_integrated_server(self.options.server_thread)
|
||||
{
|
||||
return self.open_integrated(folder, name, seed, creative, create);
|
||||
}
|
||||
self.perf.world_opening();
|
||||
let request = world_loading::WorldRequest {
|
||||
save_root,
|
||||
folder: folder.to_owned(),
|
||||
name: name.map(str::to_owned),
|
||||
seed,
|
||||
creative,
|
||||
create: create.cloned(),
|
||||
};
|
||||
let request = world_loading::WorldRequest::new(folder, name, seed, creative, create)?;
|
||||
if self.window.is_some() {
|
||||
self.perf.world_opening();
|
||||
if self.title.is_none() {
|
||||
self.title = Some(Title::new(
|
||||
std::env::current_dir()?.join("saves"),
|
||||
@@ -7539,6 +7561,14 @@ impl Game<'_> {
|
||||
)?);
|
||||
return Ok(());
|
||||
}
|
||||
self.load_world(request)
|
||||
}
|
||||
|
||||
/// Load authoritative world state synchronously, without client entry or
|
||||
/// integrated-server selection. Server startup must use this entry point.
|
||||
fn load_world(&mut self, request: world_loading::WorldRequest) -> Result<()> {
|
||||
self.join_world_rest()?;
|
||||
self.perf.world_opening();
|
||||
let prepared = request.prepare(&self.res, &self.pack, &self.options, &self.settings)?;
|
||||
self.finish_open_world(prepared)
|
||||
}
|
||||
@@ -7952,6 +7982,8 @@ impl Game<'_> {
|
||||
self.session_lock = None;
|
||||
horizon_client::crash::clear_detail("World");
|
||||
self.debug_charts.reset();
|
||||
self.boss_overlay.reset();
|
||||
self.bosses = Default::default();
|
||||
self.streaming.paused = true;
|
||||
self.world = World::new(self.res.facts().air, 0);
|
||||
self.player = None;
|
||||
@@ -9080,21 +9112,11 @@ impl Game<'_> {
|
||||
// Options.biomeBlendRadius, ambientOcclusion and cutoutLeaves:
|
||||
// LevelExtractor.allChanged when they change.
|
||||
let (s, st) = (&self.settings, &self.streaming);
|
||||
let remesh = (
|
||||
s.biome_blend,
|
||||
s.smooth_lighting,
|
||||
s.cutout_leaves,
|
||||
s.horizon.cull_leaves,
|
||||
) != (
|
||||
st.biome_blend,
|
||||
st.smooth_lighting,
|
||||
st.cutout_leaves,
|
||||
st.cull_leaves,
|
||||
);
|
||||
let remesh = (s.biome_blend, s.smooth_lighting, s.cutout_leaves)
|
||||
!= (st.biome_blend, st.smooth_lighting, st.cutout_leaves);
|
||||
self.streaming.biome_blend = self.settings.biome_blend;
|
||||
self.streaming.smooth_lighting = self.settings.smooth_lighting;
|
||||
self.streaming.cutout_leaves = self.settings.cutout_leaves;
|
||||
self.streaming.cull_leaves = self.settings.horizon.cull_leaves;
|
||||
if remesh {
|
||||
self.all_sections_changed();
|
||||
}
|
||||
@@ -10095,7 +10117,7 @@ impl Game<'_> {
|
||||
if !self.settings.horizon.fog
|
||||
&& !underwater
|
||||
&& self.darkness_fog().is_none()
|
||||
&& !self.dragon_bar_shown()
|
||||
&& !self.boss_overlay.should_create_world_fog()
|
||||
&& self.potions.effects.get("blindness").is_none()
|
||||
&& fog.environmental_end >= (self.streaming.radius * 16) as f32
|
||||
{
|
||||
@@ -10166,6 +10188,9 @@ impl Game<'_> {
|
||||
darkness: self.darkness_lighting(alpha as f32),
|
||||
night_vision: self.night_vision_intensity(),
|
||||
block_flicker: self.fog.block_light_flicker,
|
||||
boss_darken: self
|
||||
.boss_overlay
|
||||
.darken_amount(self.clock.fraction() as f32),
|
||||
menu_blur: self.menu_blur(),
|
||||
};
|
||||
let result = match self.renderer.as_mut() {
|
||||
@@ -10556,7 +10581,7 @@ impl Game<'_> {
|
||||
}
|
||||
// The loading screen's chunk map fills ring by ring, as vanilla's.
|
||||
self.streaming.loading =
|
||||
self.options.dedicated.is_none() && self.loading_reason().is_some();
|
||||
self.options.execution_role.is_client() && self.loading_reason().is_some();
|
||||
let (uploads, unloaded) =
|
||||
horizon_client::spans::time("streaming", || self.streaming.update(&mut self.world));
|
||||
if let Some(lod) = &mut self.lod {
|
||||
@@ -10995,6 +11020,13 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.advancements_scroll(scaled(raw_x) as f32, d);
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. }
|
||||
if self.server_packs.prompt.is_some() =>
|
||||
{
|
||||
if state == ElementState::Pressed && button == MouseButton::Left {
|
||||
self.pack_prompt_click()?;
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
|
||||
if self.options_waiting_for_key() && state == ElementState::Pressed {
|
||||
@@ -11194,6 +11226,14 @@ impl ApplicationHandler for Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// ConfirmScreen.keyPressed: Escape answers no.
|
||||
WindowEvent::KeyboardInput { event, .. } if self.server_packs.prompt.is_some() => {
|
||||
if event.state == ElementState::Pressed
|
||||
&& event.physical_key == PhysicalKey::Code(KeyCode::Escape)
|
||||
{
|
||||
self.answer_pack_prompt(false)?;
|
||||
}
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. }
|
||||
if self.screen == Screen::Chat && self.title.is_none() =>
|
||||
{
|
||||
@@ -11572,6 +11612,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
self.tick_server_packs();
|
||||
self.poll_control(event_loop);
|
||||
if self.pending_save.is_some() {
|
||||
return;
|
||||
@@ -11666,6 +11707,7 @@ impl<'a> Game<'a> {
|
||||
pending_entry: None,
|
||||
world_loader: None,
|
||||
pending_pack_reload: false,
|
||||
server_packs: Default::default(),
|
||||
world_renderer_ready: false,
|
||||
collision: res.collision(),
|
||||
motion: res.motion(),
|
||||
@@ -11866,6 +11908,8 @@ impl<'a> Game<'a> {
|
||||
breath_clouds: Vec::new(),
|
||||
dragon_model: prepared.dragon_model,
|
||||
map_patches: Vec::new(),
|
||||
text_displays: Default::default(),
|
||||
text_display_vertices: 0,
|
||||
skin_downloads: RefCell::new(horizon_client::skins::Downloader::new(
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
@@ -11953,6 +11997,7 @@ impl<'a> Game<'a> {
|
||||
score: Default::default(),
|
||||
fns: Default::default(),
|
||||
bosses: Default::default(),
|
||||
boss_overlay: Default::default(),
|
||||
exec: Default::default(),
|
||||
rand_sequences: Default::default(),
|
||||
border: Default::default(),
|
||||
@@ -12226,7 +12271,7 @@ fn synchronous_startup(options: &Options) -> bool {
|
||||
|| options.world_frames.is_some()
|
||||
|| options.benchmark.is_some()
|
||||
|| options.control.is_some()
|
||||
|| options.dedicated.is_some()
|
||||
|| !options.execution_role.is_client()
|
||||
|| std::env::var_os("HORIZON_DISPENSE_FIXTURE").is_some()
|
||||
|| std::env::var_os("HORIZON_SCULK_FIXTURE").is_some()
|
||||
|| std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some()
|
||||
@@ -12680,6 +12725,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
darkness: (0.0, 0.0),
|
||||
night_vision: 0.0,
|
||||
block_flicker: 0.0,
|
||||
boss_darken: 0.0,
|
||||
menu_blur: None,
|
||||
}
|
||||
}
|
||||
@@ -12767,7 +12813,7 @@ fn start_game<'a>(
|
||||
game.sculk_fixture(&spec)?;
|
||||
return Ok(None);
|
||||
}
|
||||
if game.options.dedicated.is_some() {
|
||||
if game.options.execution_role == execution_role::ExecutionRole::DedicatedServer {
|
||||
game.run_dedicated()?;
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -13044,7 +13090,7 @@ impl Loading {
|
||||
);
|
||||
let renderer_ms = renderer_at.elapsed().as_millis();
|
||||
// Audio is created on the main thread, after the core is ready.
|
||||
let audio = if options.dedicated.is_some() {
|
||||
let audio = if !options.execution_role.is_client() {
|
||||
Audio::silent(&options.resources)
|
||||
} else {
|
||||
Audio::new(&options.resources)
|
||||
@@ -13219,13 +13265,15 @@ pub fn run() -> Result<()> {
|
||||
let Some(mut options) = Options::read()? else {
|
||||
return Ok(());
|
||||
};
|
||||
if options.dedicated.is_some() && !dedicated::preflight(&mut options)? {
|
||||
if options.execution_role == execution_role::ExecutionRole::DedicatedServer
|
||||
&& !dedicated::preflight(&mut options)?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
// Before any worker starts: on Linux they go behind the server thread
|
||||
// and the render thread.
|
||||
horizon_threads::put_helpers_behind();
|
||||
if options.dedicated.is_none() {
|
||||
if options.execution_role.is_client() {
|
||||
// The render thread, like the server thread, ahead of the workers on
|
||||
// macOS and Windows. While terrain loads, the worker pools (two with
|
||||
// single player's server thread) and other programs outnumber the
|
||||
@@ -13235,7 +13283,7 @@ pub fn run() -> Result<()> {
|
||||
}
|
||||
// Someone is playing: the anonymous ping behind the launcher's "N playing now". Not for a
|
||||
// server, a benchmark or a run that quits by itself.
|
||||
if options.dedicated.is_none()
|
||||
if options.execution_role.is_client()
|
||||
&& options.benchmark.is_none()
|
||||
&& !options.offscreen
|
||||
&& options.frames.is_none()
|
||||
@@ -13254,7 +13302,7 @@ pub fn run() -> Result<()> {
|
||||
return run_loading(options, early_settings, vanilla_root, None, started);
|
||||
}
|
||||
let mut pack_error = None;
|
||||
options.resources = packs::startup_root(&vanilla_root, &early_settings.resource_packs)
|
||||
options.resources = packs::startup_root(&vanilla_root, &early_settings.resource_packs, &[])
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("Horizon: resource packs not applied: {e}");
|
||||
pack_error = Some(e.to_string());
|
||||
@@ -13276,7 +13324,7 @@ pub fn run() -> Result<()> {
|
||||
)?;
|
||||
let gui = loaded.gui.ok_or("missing menu bundle")?;
|
||||
options.resources = loaded.root.clone();
|
||||
let audio = if options.dedicated.is_some() {
|
||||
let audio = if !options.execution_role.is_client() {
|
||||
Audio::silent(&options.resources)
|
||||
} else {
|
||||
Audio::new(&options.resources)
|
||||
|
||||
@@ -881,30 +881,40 @@ pub(super) fn adv_name(a: &adv::Advancement) -> Text {
|
||||
let Some(d) = &a.display else {
|
||||
return Text::literal(a.id.clone());
|
||||
};
|
||||
let color = match d.frame {
|
||||
Frame::Challenge => text::DARK_PURPLE,
|
||||
_ => text::GREEN,
|
||||
};
|
||||
// Advancement.decorateName: the title in the frame's colour (styles
|
||||
// merged into it), a new line and the description.
|
||||
let coloured = text_of(&d.title).with_color(color);
|
||||
let mut component = coloured.component();
|
||||
if let Some(o) = component.as_object_mut() {
|
||||
o.insert(
|
||||
"extra".into(),
|
||||
serde_json::json!(["\n", text_of(&d.description).component()]),
|
||||
);
|
||||
}
|
||||
let tooltip = coloured
|
||||
.append("\n")
|
||||
.append(text_of(&d.description))
|
||||
.with_component(component);
|
||||
let color = frame_color(d.frame);
|
||||
let title = text_of(&d.title).with_style(&Style {
|
||||
hover: Some(Box::new(tooltip)),
|
||||
hover: Some(Box::new(decorate_name(d))),
|
||||
..Style::default()
|
||||
});
|
||||
Text::translate("chat.square_brackets", &[title]).with_color(color)
|
||||
}
|
||||
fn frame_color(frame: Frame) -> u32 {
|
||||
match frame {
|
||||
Frame::Challenge => text::DARK_PURPLE,
|
||||
_ => text::GREEN,
|
||||
}
|
||||
}
|
||||
/// `Advancement.decorateName`: a copy of the title with the frame's colour
|
||||
/// under its own style (mergeStyles), then a new line and the description
|
||||
/// after the title's own siblings, so both inherit the title's root style.
|
||||
/// Built on the title's JSON (a list as its first element with the rest as
|
||||
/// siblings): a parsed Text moves a styled root's style down a level once it
|
||||
/// has siblings.
|
||||
fn decorate_name(d: &adv::Display) -> Text {
|
||||
let mut component = match horizon_client::scoreboard::canonical_component(&d.title) {
|
||||
Value::String(s) => serde_json::json!({ "text": s }),
|
||||
c => c,
|
||||
};
|
||||
if let Some(o) = component.as_object_mut() {
|
||||
let frame = Text::empty().with_color(frame_color(d.frame)).component();
|
||||
o.entry("color").or_insert_with(|| frame["color"].clone());
|
||||
let extra = o.entry("extra").or_insert_with(|| serde_json::json!([]));
|
||||
if let Some(extra) = extra.as_array_mut() {
|
||||
extra.extend(["\n".into(), d.description.clone()]);
|
||||
}
|
||||
}
|
||||
text_of(&component).with_component(component)
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `AdvancementCommands.Action.GRANT` on one advancement (or one of
|
||||
/// its criteria): false when nothing changed.
|
||||
@@ -1051,17 +1061,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A text component from advancement JSON (`translate` or `text`).
|
||||
/// An advancement's title or description: a whole component (ComponentSerialization.CODEC),
|
||||
/// with its siblings and styles, as a plugin's `{"text":"","extra":[...]}` needs.
|
||||
pub(super) fn text_of(v: &Value) -> Text {
|
||||
if let Some(key) = v["translate"].as_str() {
|
||||
Text::translate(key, &[])
|
||||
} else if let Some(s) = v["text"].as_str() {
|
||||
Text::literal(s)
|
||||
} else if let Some(s) = v.as_str() {
|
||||
Text::literal(s)
|
||||
} else {
|
||||
Text::empty()
|
||||
}
|
||||
horizon_client::scoreboard::comp_text(v)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1095,6 +1098,105 @@ mod tests {
|
||||
(seen, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn titles_keep_their_siblings_and_styles() {
|
||||
let title = text_of(&serde_json::json!({
|
||||
"text": "",
|
||||
"extra": [{"text": "Quest ", "color": "gold"}, "Board"],
|
||||
}));
|
||||
assert_eq!(title.plain(), "Quest Board");
|
||||
assert_eq!(title.spans[0].style.color, Some(text::GOLD));
|
||||
assert_eq!(title.spans[1].style.color, None);
|
||||
let vanilla = text_of(&serde_json::json!({"translate": "advancements.story.root.title"}));
|
||||
assert_eq!(
|
||||
vanilla,
|
||||
Text::translate("advancements.story.root.title", &[])
|
||||
);
|
||||
assert_eq!(
|
||||
vanilla.translation_key(),
|
||||
Some("advancements.story.root.title")
|
||||
);
|
||||
}
|
||||
|
||||
fn announced(title: Value, description: Value) -> adv::Advancement {
|
||||
adv::Advancement {
|
||||
id: "quests:board".into(),
|
||||
parent: None,
|
||||
display: Some(adv::Display {
|
||||
icon: "minecraft:book".into(),
|
||||
title,
|
||||
description,
|
||||
frame: Frame::Task,
|
||||
background: None,
|
||||
show_toast: true,
|
||||
announce: true,
|
||||
hidden: false,
|
||||
}),
|
||||
criteria: Vec::new(),
|
||||
requirements: Vec::new(),
|
||||
rewards: adv::Rewards::default(),
|
||||
}
|
||||
}
|
||||
/// The chat hover: the JSON a client receives, and the spans drawn here.
|
||||
fn hover(a: &adv::Advancement) -> (Value, Text) {
|
||||
let local = decorate_name(a.display.as_ref().unwrap());
|
||||
let sent = adv_name(a).component()["with"][0]["hover_event"]["value"].clone();
|
||||
(sent, local)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_hover_keeps_the_titles_siblings_before_the_description() {
|
||||
let a = announced(
|
||||
serde_json::json!({"text": "", "extra": [{"text": "Quest ", "color": "gold"}, "Board"]}),
|
||||
serde_json::json!("Open the board"),
|
||||
);
|
||||
let (sent, local) = hover(&a);
|
||||
let sent = horizon_client::scoreboard::comp_text(&sent);
|
||||
assert_eq!(sent.plain(), "Quest Board\nOpen the board");
|
||||
let colors = |t: &Text| t.spans.iter().map(|s| s.style.color).collect::<Vec<_>>();
|
||||
let (gold, green) = (Some(text::GOLD), Some(text::GREEN));
|
||||
assert_eq!(colors(&sent), [gold, green, green, green]);
|
||||
// The spans drawn here come from the same JSON a client receives.
|
||||
assert_eq!(colors(&local), colors(&sent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_hover_description_takes_a_styled_titles_own_style() {
|
||||
// mergeStyles keeps the title's gold; the description inherits it.
|
||||
let object = announced(
|
||||
serde_json::json!({"text": "Quest", "color": "gold", "bold": true, "extra": [" Board"]}),
|
||||
serde_json::json!("Open the board"),
|
||||
);
|
||||
let list = announced(
|
||||
serde_json::json!([{"text": "Quest", "color": "gold", "bold": true}, " Board"]),
|
||||
serde_json::json!("Open the board"),
|
||||
);
|
||||
for a in [object, list] {
|
||||
let (sent, local) = hover(&a);
|
||||
let sent = horizon_client::scoreboard::comp_text(&sent);
|
||||
assert_eq!(sent.plain(), "Quest Board\nOpen the board");
|
||||
let last = &sent.spans.last().unwrap().style;
|
||||
assert_eq!((last.color, last.bold), (Some(text::GOLD), Some(true)));
|
||||
assert_eq!(local.spans.last().unwrap().style, *last);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_hover_of_a_vanilla_advancement() {
|
||||
let a = announced(
|
||||
serde_json::json!({"translate": "advancements.story.root.title"}),
|
||||
serde_json::json!({"translate": "advancements.story.root.description"}),
|
||||
);
|
||||
assert_eq!(
|
||||
hover(&a).0,
|
||||
serde_json::json!({
|
||||
"translate": "advancements.story.root.title",
|
||||
"color": "green",
|
||||
"extra": ["\n", {"translate": "advancements.story.root.description"}],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toasts_slide_in_squared_and_out_after_five_seconds() {
|
||||
let mut toast = recipes(1);
|
||||
|
||||
@@ -9,6 +9,10 @@ use horizon_client::{
|
||||
block_entity_anim::Ticker,
|
||||
chunk::{MIN_Y, SECTIONS},
|
||||
items::ItemStack,
|
||||
mobs::{
|
||||
Species,
|
||||
render::{PROFESSIONS, VILLAGER_TYPES},
|
||||
},
|
||||
widgets,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
@@ -468,6 +472,8 @@ impl Game<'_> {
|
||||
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
|
||||
"health": p.health.health(),
|
||||
"absorption": p.health.absorption(),
|
||||
// The hurt animation's ticks left (the camera's hurt tilt).
|
||||
"hurt_time": p.health.hurt_animation_time,
|
||||
"air": p.air,
|
||||
"food": p.health.food.food,
|
||||
"saturation": p.health.food.saturation,
|
||||
@@ -592,10 +598,20 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
for m in self.mobs.iter().filter(|m| m.alive()) {
|
||||
let mut v = json!({ "id": m.id, "kind": "mob", "health": m.health, "baby": m.is_baby(), "hurt_time": m.hurt_time });
|
||||
// VillagerData as this game knows it: on a client, what the
|
||||
// server's synched data says.
|
||||
if matches!(m.species(), Species::Villager | Species::ZombieVillager) {
|
||||
v["villager"] = json!({
|
||||
"type": VILLAGER_TYPES[m.extra.variant as usize % VILLAGER_TYPES.len()],
|
||||
"profession": PROFESSIONS[m.extra.variant2 as usize % PROFESSIONS.len()],
|
||||
"level": m.extra.variant3,
|
||||
});
|
||||
}
|
||||
push(
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
m.kind.id.as_ref(),
|
||||
json!({ "id": m.id, "kind": "mob", "health": m.health, "baby": m.is_baby(), "hurt_time": m.hurt_time }),
|
||||
v,
|
||||
);
|
||||
}
|
||||
for item in &self.dropped {
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Boats and the entities around them: what a boat runs into
|
||||
//! (`AbstractBoat.canVehicleCollide`), the pushes between a boat and what
|
||||
//! touches it (`AbstractBoat.push`, `LivingEntity.pushEntities`), the mobs
|
||||
//! it picks up (the end of `AbstractBoat.tick`), where they sit
|
||||
//! (`positionRider`, `clampRotation`) and how they get out.
|
||||
//! Saving them under the boat's `Passengers` lives in `persist.rs`.
|
||||
use super::{Game, boats::Boat, lan_mobs::guest_bounds};
|
||||
use horizon_client::{
|
||||
camera::wrap_degrees,
|
||||
mobs::{Mob, Species, Who, drops::is_animal, equine, riders},
|
||||
scoreboard::pushable_by,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
/// `Entity.boardingCooldown` once a mob leaves its boat.
|
||||
const BOARDING_COOLDOWN: i32 = 60;
|
||||
/// `Player.DEFAULT_VEHICLE_ATTACHMENT`: how far below the seat a player's
|
||||
/// feet are.
|
||||
const PLAYER_RIDING_OFFSET: f64 = 0.6;
|
||||
|
||||
/// `Entity.isVehicle`: a player or a mob rides it.
|
||||
fn carries(m: &Mob) -> bool {
|
||||
m.rider.is_some() || m.extra.rider.is_some()
|
||||
}
|
||||
/// `isPushable`, as `pushableBy` and `canVehicleCollide` ask it:
|
||||
/// `Mob::pushable` plus the shulkers it leaves out (they take no push, but
|
||||
/// count), less a ridden horse (`AbstractHorse.isPushable`).
|
||||
fn pushable(m: &Mob) -> bool {
|
||||
!m.removed
|
||||
&& (m.pushable() || m.species() == Species::Shulker && m.alive())
|
||||
&& !(equine::is_equine(m.species()) && carries(m))
|
||||
}
|
||||
/// Whether `Entity.push` moves the mob: it is pushable and carries no one.
|
||||
fn moved_by_push(m: &Mob) -> bool {
|
||||
!m.removed && m.pushable() && !carries(m)
|
||||
}
|
||||
/// `Entity.push(Entity)`: the push `other` gets from `this` (which takes the
|
||||
/// opposite one), or None when they stand on nearly the same spot.
|
||||
pub(super) fn push_impulse(this: Vec3, other: Vec3) -> Option<Vec3> {
|
||||
let (mut xa, mut za) = (other.x - this.x, other.z - this.z);
|
||||
let mut dd = xa.abs().max(za.abs());
|
||||
if dd < f64::from(0.01_f32) {
|
||||
return None;
|
||||
}
|
||||
dd = dd.sqrt();
|
||||
xa /= dd;
|
||||
za /= dd;
|
||||
let pow = (1.0 / dd).min(1.0);
|
||||
let k = f64::from(0.05_f32);
|
||||
Some(Vec3::new(xa * pow * k, 0.0, za * pow * k))
|
||||
}
|
||||
/// `AbstractBoat.push`: another boat is pushed while it is lower than this
|
||||
/// boat's top, anything else while its feet are no higher than the boat's.
|
||||
pub(super) fn boat_pushes(boat: Aabb, other: Aabb, other_is_boat: bool) -> bool {
|
||||
if other_is_boat {
|
||||
other.min_position().y < boat.max_position().y
|
||||
} else {
|
||||
other.min_position().y <= boat.min_position().y
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The players in boat `id`: the window's player (or the server player
|
||||
/// acting as it) and the server's other players.
|
||||
pub(super) fn boat_players(&self, id: u32) -> usize {
|
||||
let guests = self.lan.as_ref().map_or(0, |h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| Some(g.conn) != self.lan_acting && g.vehicle == Some(id))
|
||||
.count()
|
||||
});
|
||||
usize::from(self.riding == Some(id)) + guests
|
||||
}
|
||||
/// The mobs in boat `id`, in their seat order after the players
|
||||
/// (`Entity.addPassenger` puts a player first).
|
||||
pub(super) fn boat_mobs(&self, id: u32) -> Vec<usize> {
|
||||
(0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
let m = &self.mobs[j];
|
||||
m.extra.mount == Some(id) && !m.removed && m.alive()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `getPassengers().size()` of boat `id`.
|
||||
pub(super) fn boat_passengers(&self, id: u32) -> usize {
|
||||
self.boat_players(id) + self.boat_mobs(id).len()
|
||||
}
|
||||
/// `AbstractBoat.canAddPassenger`: a seat is free and the boat is not
|
||||
/// under water.
|
||||
pub(super) fn boat_has_room(&self, i: usize) -> bool {
|
||||
let b = &self.boats[i];
|
||||
self.boat_passengers(b.id) < b.max_passengers() && !b.under_water()
|
||||
}
|
||||
/// Where the player in boat `i` sits: in the middle alone, in front of a
|
||||
/// mob.
|
||||
pub(super) fn player_boat_seat(&self, i: usize) -> Vec3 {
|
||||
let b = &self.boats[i];
|
||||
let count = self.boat_players(b.id).max(1) + self.boat_mobs(b.id).len();
|
||||
b.seat(0, count, false, PLAYER_RIDING_OFFSET, false)
|
||||
}
|
||||
/// `getRootVehicle` of mob `j`: the mob or boat at the bottom of what it
|
||||
/// rides, None when it rides nothing.
|
||||
fn mob_root_vehicle(&self, mut j: usize) -> Option<u32> {
|
||||
let mut root = None;
|
||||
for _ in 0..8 {
|
||||
let Some(v) = self.mobs[j].extra.mount else {
|
||||
break;
|
||||
};
|
||||
root = Some(v);
|
||||
match self.mobs.iter().position(|m| m.id == v) {
|
||||
Some(k) => j = k,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
root
|
||||
}
|
||||
/// The players outside boat `id` that push and block it (living, not
|
||||
/// spectators), with the window player's team.
|
||||
fn players_outside(&self, id: u32) -> Vec<(Aabb, bool)> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(p) = self.player.as_ref()
|
||||
&& self.riding != Some(id)
|
||||
&& !self.spectator()
|
||||
&& !p.health.dead_or_dying()
|
||||
{
|
||||
out.push((p.bounds(), true));
|
||||
}
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
for g in host.hub.guests.iter().filter(|g| {
|
||||
Some(g.conn) != self.lan_acting
|
||||
&& host.hub.here(g)
|
||||
&& g.loaded
|
||||
&& !g.dead
|
||||
&& !g.spectator()
|
||||
&& g.vehicle != Some(id)
|
||||
}) {
|
||||
out.push((guest_bounds(g), false));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `getEntityCollisions` of boat `id` within `area`, with
|
||||
/// `AbstractBoat.canVehicleCollide`: the other boats, minecarts,
|
||||
/// shulkers and pushable mobs and players, but not its own passengers.
|
||||
pub(super) fn boat_obstacles(&self, id: u32, area: Aabb) -> Vec<Aabb> {
|
||||
let mut out: Vec<Aabb> = (0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
let m = &self.mobs[j];
|
||||
pushable(m)
|
||||
&& m.bounds().intersects(area)
|
||||
&& (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id))
|
||||
})
|
||||
.map(|j| self.mobs[j].bounds())
|
||||
.collect();
|
||||
out.extend(
|
||||
self.boats
|
||||
.iter()
|
||||
.filter(|b| b.id != id)
|
||||
.map(Boat::bounds)
|
||||
.chain(self.carts.iter().map(|c| c.bounds()))
|
||||
.chain(self.players_outside(id).into_iter().map(|(bb, _)| bb))
|
||||
.filter(|bb| bb.intersects(area)),
|
||||
);
|
||||
out
|
||||
}
|
||||
/// What boat `b` may run into this tick.
|
||||
pub(super) fn boat_move_obstacles(&self, b: &Boat) -> Vec<Aabb> {
|
||||
let reach = b.velocity.length() + 1.0;
|
||||
self.boat_obstacles(b.id, b.bounds().inflate(Vec3::new(reach, reach, reach)))
|
||||
}
|
||||
/// `noEntityCollision(boat, box)` for a new boat (`BoatItem.use`).
|
||||
pub(super) fn boat_entity_collision(&self, bb: Aabb) -> bool {
|
||||
// EntityGetter.getEntityCollisions: the box grown by 1e-7.
|
||||
let area = bb.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
|
||||
!self.boat_obstacles(u32::MAX, area).is_empty()
|
||||
}
|
||||
|
||||
/// The end of `AbstractBoat.tick` on the server for boat `i`: each
|
||||
/// entity it touches gets in (a mob, while no player steers) or is
|
||||
/// pushed apart from it, and `LivingEntity.pushEntities` of each mob and
|
||||
/// player touching it pushes the two apart once more. Only the window's
|
||||
/// own player is moved here: a server's players are pushed by their
|
||||
/// clients, which tick the boats near them (`remote_pushes`).
|
||||
pub(super) fn boat_contacts(&mut self, i: usize) {
|
||||
let (id, bb, max) = {
|
||||
let b = &self.boats[i];
|
||||
(b.id, b.bounds(), b.max_passengers())
|
||||
};
|
||||
let area = bb.inflate(Vec3::new(
|
||||
f64::from(0.2_f32),
|
||||
f64::from(-0.01_f32),
|
||||
f64::from(0.2_f32),
|
||||
));
|
||||
let mut count = self.boat_passengers(id);
|
||||
// getControllingPassenger is a player: one rides it (players sit first).
|
||||
let pick_up = self.boat_players(id) == 0;
|
||||
// canAddPassenger, which startRiding asks after the checks below.
|
||||
let room = !self.boats[i].under_water();
|
||||
// (mob, touched by the boat's sweep, its own pushEntities reaches the boat)
|
||||
let teams = self.mob_teams();
|
||||
let mobs: Vec<(usize, bool, bool)> = (0..self.mobs.len())
|
||||
.filter_map(|j| {
|
||||
let m = &self.mobs[j];
|
||||
if !pushable(m) || m.extra.mount.is_some() && self.mob_root_vehicle(j) == Some(id) {
|
||||
return None;
|
||||
}
|
||||
let mb = m.bounds();
|
||||
let team = teams.of(Who::Mob(m.id));
|
||||
let touched = mb.intersects(area) && pushable_by(None, team, true);
|
||||
// Bats push nothing (Bat.pushEntities).
|
||||
let pushes = m.species() != Species::Bat
|
||||
&& mb.intersects(bb)
|
||||
&& pushable_by(team, None, true);
|
||||
(touched || pushes).then_some((j, touched, pushes))
|
||||
})
|
||||
.collect();
|
||||
let player_team = teams.of(Who::Player);
|
||||
let players: Vec<(Aabb, bool, bool, bool)> = self
|
||||
.players_outside(id)
|
||||
.into_iter()
|
||||
.map(|(pb, own)| {
|
||||
let team = if own { player_team } else { None };
|
||||
(
|
||||
pb,
|
||||
own,
|
||||
pb.intersects(area) && pushable_by(None, team, true),
|
||||
pb.intersects(bb) && pushable_by(team, None, true),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
drop(teams);
|
||||
for (j, touched, pushes) in mobs {
|
||||
if touched {
|
||||
let m = &self.mobs[j];
|
||||
let boards = pick_up
|
||||
&& count < max
|
||||
&& m.extra.mount.is_none()
|
||||
&& m.width() < super::boats::WIDTH as f32
|
||||
&& !self
|
||||
.res
|
||||
.mob_data()
|
||||
.entity_has("minecraft:cannot_be_pushed_onto_boats", &m.kind.id);
|
||||
if boards {
|
||||
// startRiding fails quietly (no push) while the mob's
|
||||
// boarding cooldown runs (Entity.canRide) or the boat
|
||||
// is under water.
|
||||
if room && m.extra.boarding_cooldown <= 0 {
|
||||
let m = &mut self.mobs[j];
|
||||
m.extra.mount = Some(id);
|
||||
m.extra.passenger = true;
|
||||
m.ai.nav.stop();
|
||||
let mob = m.id;
|
||||
count += 1;
|
||||
// Mob.startRiding drops its lead.
|
||||
self.break_leash(mob, true, false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
self.boat_push_mob(i, j, count);
|
||||
}
|
||||
if pushes {
|
||||
self.boat_push_mob(i, j, count);
|
||||
}
|
||||
}
|
||||
for (pb, own, touched, pushes) in players {
|
||||
let Some(v) = push_impulse(self.boats[i].position, pb.center())
|
||||
.filter(|_| boat_pushes(bb, pb, false))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for _ in 0..usize::from(touched) + usize::from(pushes) {
|
||||
if count == 0 {
|
||||
let b = &mut self.boats[i];
|
||||
b.velocity = b.velocity.subtract(v);
|
||||
}
|
||||
if own && let Some(p) = self.player.as_mut() {
|
||||
p.velocity = p.velocity.add_vector(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
for k in 0..self.boats.len() {
|
||||
let ob = self.boats[k].bounds();
|
||||
if k == i || !ob.intersects(area) || !boat_pushes(bb, ob, true) {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = push_impulse(self.boats[i].position, self.boats[k].position) else {
|
||||
continue;
|
||||
};
|
||||
if count == 0 {
|
||||
self.boats[i].velocity = self.boats[i].velocity.subtract(v);
|
||||
}
|
||||
if self.boat_passengers(self.boats[k].id) == 0 {
|
||||
self.boats[k].velocity = self.boats[k].velocity.add_vector(v);
|
||||
}
|
||||
}
|
||||
let guest_carts = self.guest_vehicles();
|
||||
for c in 0..self.carts.len() {
|
||||
let cb = self.carts[c].bounds();
|
||||
if !cb.intersects(area) || !boat_pushes(bb, cb, false) {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = push_impulse(self.boats[i].position, self.carts[c].position) else {
|
||||
continue;
|
||||
};
|
||||
if count == 0 {
|
||||
self.boats[i].velocity = self.boats[i].velocity.subtract(v);
|
||||
}
|
||||
let cid = self.carts[c].id;
|
||||
let ridden = self.riding == Some(cid)
|
||||
|| guest_carts.contains(&cid)
|
||||
|| self.mobs.iter().any(|m| m.extra.mount == Some(cid));
|
||||
if !ridden {
|
||||
self.carts[c].velocity = self.carts[c].velocity.add_vector(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AbstractBoat.push(mob)` from boat `i`, which has `count` passengers.
|
||||
fn boat_push_mob(&mut self, i: usize, j: usize, count: usize) {
|
||||
let (b, m) = (&self.boats[i], &self.mobs[j]);
|
||||
if !boat_pushes(b.bounds(), m.bounds(), false) {
|
||||
return;
|
||||
}
|
||||
let Some(v) = push_impulse(b.position, m.position) else {
|
||||
return;
|
||||
};
|
||||
if count == 0 {
|
||||
let b = &mut self.boats[i];
|
||||
b.velocity = b.velocity.subtract(v);
|
||||
}
|
||||
let m = &mut self.mobs[j];
|
||||
if moved_by_push(m) {
|
||||
m.velocity = m.velocity.add_vector(v);
|
||||
m.needs_sync = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// `positionRider` and `clampRotation` for the mobs in boats: each sits
|
||||
/// in its seat facing the way the boat does, an animal in a full boat
|
||||
/// sideways, its head kept within its reach of its body.
|
||||
pub(super) fn seat_boat_mobs(&mut self) {
|
||||
for i in 0..self.boats.len() {
|
||||
let id = self.boats[i].id;
|
||||
let mobs = self.boat_mobs(id);
|
||||
if mobs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let players = self.boat_players(id);
|
||||
let count = players + mobs.len();
|
||||
let b = &self.boats[i];
|
||||
let full = count == b.max_passengers();
|
||||
let delta = b.delta_rotation;
|
||||
let seats: Vec<_> = mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(n, &j)| {
|
||||
let m = &self.mobs[j];
|
||||
let animal = is_animal(m.species());
|
||||
// Another player, on a server's client.
|
||||
let offset = if &*m.kind.id == "minecraft:player" {
|
||||
PLAYER_RIDING_OFFSET
|
||||
} else {
|
||||
riders::vehicle_attachment(m)
|
||||
};
|
||||
let index = players + n;
|
||||
// calculatePassengerBodyYRot.
|
||||
let turn = match (animal && full, m.id.is_multiple_of(2)) {
|
||||
(false, _) => 0.0,
|
||||
(true, true) => 90.0,
|
||||
(true, false) => 270.0,
|
||||
};
|
||||
let turns = !self
|
||||
.res
|
||||
.mob_data()
|
||||
.entity_has("minecraft:can_turn_in_boats", &m.kind.id);
|
||||
(
|
||||
j,
|
||||
b.seat(index, count, animal, offset, false),
|
||||
b.seat(index, count, animal, offset, true),
|
||||
turns.then_some((b.yaw + turn, b.previous_yaw + turn)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for (j, at, previous, facing) in seats {
|
||||
let m = &mut self.mobs[j];
|
||||
m.position = at;
|
||||
m.previous = previous;
|
||||
m.velocity = Vec3::ZERO;
|
||||
m.fall_distance = 0.0;
|
||||
m.on_ground = false;
|
||||
m.extra.passenger = true;
|
||||
m.ai.nav.stop();
|
||||
let Some((body, previous_body)) = facing else {
|
||||
continue;
|
||||
};
|
||||
m.head_yaw += delta;
|
||||
m.body_yaw = body;
|
||||
m.previous_body_yaw = previous_body;
|
||||
m.yaw = body;
|
||||
m.previous_yaw = previous_body;
|
||||
// Mob.clampHeadRotationToBody.
|
||||
let limit = m.kind.max_head_y_rot;
|
||||
let d = wrap_degrees(body - m.head_yaw);
|
||||
m.head_yaw += d - d.clamp(-limit, limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ejectPassengers` for the mobs in boat `i`: each gets out where
|
||||
/// `getDismountLocationForPassenger` puts it (beside the boat when it
|
||||
/// broke: `LivingEntity.dismountVehicle`) and can't board for a while.
|
||||
pub(super) fn eject_boat_mobs(&mut self, i: usize, broken: bool) {
|
||||
let b = &self.boats[i];
|
||||
let (id, y) = (b.id, b.position.y);
|
||||
for j in self.boat_mobs(id) {
|
||||
let m = &self.mobs[j];
|
||||
let at = if broken {
|
||||
Vec3::new(m.position.x, m.position.y.max(y), m.position.z)
|
||||
} else {
|
||||
self.boat_dismount_target(i, f64::from(m.width()), m.yaw)
|
||||
};
|
||||
let m = &mut self.mobs[j];
|
||||
m.extra.mount = None;
|
||||
m.extra.passenger = false;
|
||||
m.extra.boarding_cooldown = BOARDING_COOLDOWN;
|
||||
m.position = at;
|
||||
m.previous = at;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn push_is_capped_and_scaled() {
|
||||
// One block apart on x: the full 0.05 along x.
|
||||
let v = push_impulse(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0)).unwrap();
|
||||
assert!((v.x - f64::from(0.05_f32)).abs() < 1e-12 && v.z == 0.0);
|
||||
// Almost on the same spot: no push.
|
||||
assert!(push_impulse(Vec3::ZERO, Vec3::new(0.005, 0.0, 0.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boats_push_by_height() {
|
||||
let boat = Aabb::new(Vec3::new(0.0, 64.0, 0.0), Vec3::new(1.0, 64.5625, 1.0));
|
||||
let at = |y: f64| Aabb::new(Vec3::new(0.0, y, 0.0), Vec3::new(1.0, y + 1.0, 1.0));
|
||||
assert!(boat_pushes(boat, at(64.0), false));
|
||||
assert!(!boat_pushes(boat, at(64.1), false));
|
||||
assert!(boat_pushes(boat, at(64.5), true));
|
||||
assert!(!boat_pushes(boat, at(64.5625), true));
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,10 @@
|
||||
use super::{Game, Keys, entity_culling::at, view_vector};
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
collisions::SceneCollisions,
|
||||
collisions::{EntityCollisions, SceneCollisions},
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Part, anim::Poses},
|
||||
mobs::{Part, anim::Poses, riders},
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
@@ -54,10 +54,8 @@ pub(super) struct Boat {
|
||||
}
|
||||
/// `ChestBoat.CONTAINER_SIZE`.
|
||||
pub(super) const CHEST_SLOTS: usize = 27;
|
||||
const WIDTH: f64 = 1.375;
|
||||
pub(super) const WIDTH: f64 = 1.375;
|
||||
const HEIGHT: f64 = 0.5625;
|
||||
/// Player.DEFAULT_VEHICLE_ATTACHMENT minus the boat's passenger attachment.
|
||||
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 {
|
||||
@@ -98,6 +96,54 @@ impl Boat {
|
||||
Vec3::new(p.x + w, p.y + HEIGHT, p.z + w),
|
||||
)
|
||||
}
|
||||
/// `isUnderWater`; it stands in for `canAddPassenger`'s
|
||||
/// `isEyeInFluid(WATER)` too.
|
||||
pub fn under_water(&self) -> bool {
|
||||
matches!(self.status, Status::UnderWater | Status::UnderFlowingWater)
|
||||
}
|
||||
/// `getMaxPassengers`: two, one beside a chest.
|
||||
pub fn max_passengers(&self) -> usize {
|
||||
if self.chest { 1 } else { 2 }
|
||||
}
|
||||
/// `rideHeight`: a third of a boat's height, 8/9 of a raft's.
|
||||
fn ride_height(&self) -> f64 {
|
||||
let height = HEIGHT as f32;
|
||||
f64::from(if &*self.wood == "bamboo" {
|
||||
height * 0.888_888_9
|
||||
} else {
|
||||
height / 3.0
|
||||
})
|
||||
}
|
||||
/// Where the passenger at `index` of `count` stands, at the boat's current
|
||||
/// (or, with `previous`, last) place: `getPassengerAttachmentPoint`
|
||||
/// turned with the boat, less the passenger's `rider_offset`.
|
||||
pub fn seat(
|
||||
&self,
|
||||
index: usize,
|
||||
count: usize,
|
||||
animal: bool,
|
||||
rider_offset: f64,
|
||||
previous: bool,
|
||||
) -> Vec3 {
|
||||
let mut z = if self.chest { 0.15f32 } else { 0.0 };
|
||||
if count > 1 {
|
||||
z = if index == 0 { 0.2 } else { -0.6 };
|
||||
if animal {
|
||||
z += 0.2;
|
||||
}
|
||||
}
|
||||
let (at, yaw) = if previous {
|
||||
(self.previous, self.previous_yaw)
|
||||
} else {
|
||||
(self.position, self.yaw)
|
||||
};
|
||||
riders::seat(
|
||||
[0.0, self.ride_height(), f64::from(z)],
|
||||
rider_offset,
|
||||
at,
|
||||
yaw,
|
||||
)
|
||||
}
|
||||
/// `getRowingTime`.
|
||||
pub fn rowing_time(&self, side: usize, a: f32) -> f32 {
|
||||
if self.paddling[side] {
|
||||
@@ -320,6 +366,7 @@ impl Game<'_> {
|
||||
let mut vspeed = -0.04;
|
||||
let mut buoyancy = 0.0;
|
||||
let inv_friction: f32;
|
||||
let obstacles = self.boat_move_obstacles(&b);
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
@@ -401,6 +448,11 @@ impl Game<'_> {
|
||||
b.paddling = [right && !left || up, left && !right || up];
|
||||
}
|
||||
// Entity.move: collisions zero the blocked components.
|
||||
let mut scene = EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &obstacles,
|
||||
border: None,
|
||||
};
|
||||
if let Ok(moved) = scene.resolve(b.bounds(), b.velocity, 0.0, b.on_ground) {
|
||||
b.position = Vec3::new(
|
||||
b.position.x + moved.x,
|
||||
@@ -477,6 +529,19 @@ impl Game<'_> {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
// The rest of AbstractBoat.tick on the server, for every boat: the
|
||||
// passengers of one under water too long get out, and it picks up or
|
||||
// pushes what it touches.
|
||||
for i in 0..self.boats.len() {
|
||||
let p = self.boats[i].position;
|
||||
if !ticking(p.x.floor() as i32, p.z.floor() as i32) {
|
||||
continue;
|
||||
}
|
||||
if self.boats[i].out_of_control >= 60.0 {
|
||||
self.eject_boat_mobs(i, false);
|
||||
}
|
||||
self.boat_contacts(i);
|
||||
}
|
||||
}
|
||||
/// `LavaFluid.entityInside` (`lavaHurt`) from the two
|
||||
/// `applyEffectsFromBlocks` calls in `AbstractBoat.tick`: twice 40
|
||||
@@ -540,13 +605,14 @@ impl Game<'_> {
|
||||
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;
|
||||
self.boats[i] = b;
|
||||
let seat = self.player_boat_seat(i);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = Vec3::new(b.position.x, b.position.y + SEAT, b.position.z);
|
||||
p.position = seat;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
self.boats[i] = b;
|
||||
if self.boat_lava_tick(i) {
|
||||
self.riding = None;
|
||||
}
|
||||
@@ -556,9 +622,18 @@ impl Game<'_> {
|
||||
/// floor there, else on top of the boat.
|
||||
pub(super) fn dismount(&mut self, i: usize) {
|
||||
self.riding = None;
|
||||
let target = self.boat_dismount_target(i, 0.6, self.yaw);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = target;
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
/// `getDismountLocationForPassenger` for a passenger `width` wide
|
||||
/// facing `yaw`.
|
||||
pub(super) fn boat_dismount_target(&self, i: usize, width: f64, yaw: f32) -> Vec3 {
|
||||
let b = &self.boats[i];
|
||||
let r = self.yaw.to_radians();
|
||||
let distance = (WIDTH * std::f64::consts::SQRT_2 + 0.6 + 1.0e-5) / 2.0;
|
||||
let r = yaw.to_radians();
|
||||
let distance = (WIDTH * std::f64::consts::SQRT_2 + width + 1.0e-5) / 2.0;
|
||||
let (s, c) = (-(r.sin()) as f64, r.cos() as f64);
|
||||
let m = s.abs().max(c.abs());
|
||||
let (x, z) = (
|
||||
@@ -569,17 +644,13 @@ impl Game<'_> {
|
||||
let (bx, by, bz) = (x.floor() as i32, top.floor() as i32, z.floor() as i32);
|
||||
let below = self.res.facts().get(self.world.block(bx, by - 1, bz));
|
||||
let here = self.res.facts().get(self.world.block(bx, by, bz));
|
||||
let target = if below.fluid != Fluid::Water
|
||||
if below.fluid != Fluid::Water
|
||||
&& !here.full_collision
|
||||
&& (below.full_collision || below.legacy_solid)
|
||||
{
|
||||
Vec3::new(x, f64::from(by), z)
|
||||
} else {
|
||||
Vec3::new(b.position.x, top, b.position.z)
|
||||
};
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = target;
|
||||
p.velocity = Vec3::ZERO;
|
||||
}
|
||||
}
|
||||
/// `BoatItem.use`: aim at a block or the water surface.
|
||||
@@ -656,7 +727,7 @@ impl Game<'_> {
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if !scene.unobstructed(inner).unwrap_or(false) {
|
||||
if !scene.unobstructed(inner).unwrap_or(false) || self.boat_entity_collision(bb) {
|
||||
return false;
|
||||
}
|
||||
self.next_entity += 1;
|
||||
@@ -711,7 +782,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
// ChestBoat.interact: sneaking (or a full seat) opens the chest.
|
||||
if self.boats[i].chest && (sneaking || self.riding.is_some()) {
|
||||
if self.boats[i].chest && (sneaking || self.riding.is_some() || !self.boat_has_room(i)) {
|
||||
let b = &self.boats[i];
|
||||
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, b.id as i32);
|
||||
self.world.block_entities.insert(
|
||||
@@ -728,6 +799,7 @@ impl Game<'_> {
|
||||
|| self.boats[i].out_of_control >= 60.0
|
||||
|| self.riding.is_some()
|
||||
|| self.ridden_by_guest(self.boats[i].id)
|
||||
|| !self.boat_has_room(i)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -758,6 +830,7 @@ impl Game<'_> {
|
||||
b.damage += damage * 10.0;
|
||||
let destroyed = creative || b.damage > 40.0;
|
||||
if destroyed {
|
||||
self.eject_boat_mobs(i, true);
|
||||
let b = self.boats.remove(i);
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
if !creative && self.ui.rules.bool("entity_drops") {
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
//! `/bossbar` (BossBarCommands) and drawing the custom boss bars.
|
||||
use super::{
|
||||
Game,
|
||||
commands::{R, err},
|
||||
Dim, Game,
|
||||
commands::{R, Target, err},
|
||||
};
|
||||
use horizon_client::{
|
||||
boss_overlay,
|
||||
bossbar::{self, BossBars},
|
||||
command::{Arg, Context},
|
||||
hud_overlays, save,
|
||||
scoreboard::{self as sb, Comp, comp_text},
|
||||
mobs::Species,
|
||||
save,
|
||||
scoreboard::{self as sb, Comp, json_to_tag},
|
||||
scoreboard_cmd::format_list,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_protocol::packets::play::{self, BossAction, Clientbound as C};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct BossState {
|
||||
pub bars: BossBars,
|
||||
loaded: bool,
|
||||
/// What each viewer was last sent, so a change becomes one packet.
|
||||
sent: HashMap<u128, HashMap<u128, Offer>>,
|
||||
}
|
||||
|
||||
fn num(n: impl std::fmt::Display) -> Text {
|
||||
@@ -36,7 +43,7 @@ impl Game<'_> {
|
||||
if let Some(root) = save::read_dat(&self.bossbar_path())
|
||||
&& let Some(data) = root.get("data")
|
||||
{
|
||||
self.bosses.bars = BossBars::unpack(data, self.level.player_uuid);
|
||||
self.bosses.bars = BossBars::unpack(data);
|
||||
}
|
||||
self.ui.registries.bossbars = self.bosses.bars.ids();
|
||||
}
|
||||
@@ -47,13 +54,15 @@ impl Game<'_> {
|
||||
if self.bosses.bars.bars.is_empty() && !self.bossbar_path().exists() {
|
||||
return;
|
||||
}
|
||||
let _ = save::write_dat(
|
||||
if let Err(e) = save::write_dat(
|
||||
&self.bossbar_path(),
|
||||
Tag::compound([
|
||||
("data", self.bosses.bars.pack(self.level.player_uuid)),
|
||||
("data", self.bosses.bars.pack()),
|
||||
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
|
||||
]),
|
||||
);
|
||||
) {
|
||||
log::warn!("boss bars were not written: {e}");
|
||||
}
|
||||
}
|
||||
pub(in crate::game) fn bossbar_exists(&self, id: &str) -> bool {
|
||||
self.bosses.bars.get(id).is_some()
|
||||
@@ -68,6 +77,7 @@ impl Game<'_> {
|
||||
b.value = b.value.min(b.max);
|
||||
}
|
||||
}
|
||||
self.publish_boss_bars();
|
||||
}
|
||||
fn bar_id(ctx: &Context) -> String {
|
||||
match ctx.get("id") {
|
||||
@@ -86,6 +96,7 @@ impl Game<'_> {
|
||||
self.load_bossbars();
|
||||
let result = self.bossbar_command(id, ctx);
|
||||
self.ui.registries.bossbars = self.bosses.bars.ids();
|
||||
self.publish_boss_bars();
|
||||
result
|
||||
}
|
||||
fn bossbar_command(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
@@ -97,6 +108,10 @@ impl Game<'_> {
|
||||
}
|
||||
let name = self.component_json(ctx, "name")?;
|
||||
self.bosses.bars.add(&bar, name);
|
||||
if let Some(b) = self.bosses.bars.get_mut(&bar) {
|
||||
// CustomBossEvents.create: Mth.createInsecureUUID(level.random).
|
||||
b.uuid = insecure_uuid(&mut self.world.random);
|
||||
}
|
||||
let shown = self
|
||||
.bosses
|
||||
.bars
|
||||
@@ -173,19 +188,20 @@ impl Game<'_> {
|
||||
i32::from(b.visible)
|
||||
}
|
||||
_ => {
|
||||
if b.player {
|
||||
let name = self.target_name(super::commands::Target::Player);
|
||||
self.feedback(Text::translate(
|
||||
"commands.bossbar.get.players.some",
|
||||
&[s, num(1), format_list(vec![name])],
|
||||
));
|
||||
} else {
|
||||
let names = self.bossbar_player_names(&b.players);
|
||||
let n = names.len();
|
||||
if n == 0 {
|
||||
self.feedback(Text::translate(
|
||||
"commands.bossbar.get.players.none",
|
||||
&[s],
|
||||
));
|
||||
} else {
|
||||
self.feedback(Text::translate(
|
||||
"commands.bossbar.get.players.some",
|
||||
&[s, num(n), format_list(names)],
|
||||
));
|
||||
}
|
||||
i32::from(b.player)
|
||||
n as i32
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -295,31 +311,46 @@ impl Game<'_> {
|
||||
Ok(0)
|
||||
}
|
||||
_ => {
|
||||
// players [<targets>]
|
||||
let include = if ctx.get("targets").is_some() {
|
||||
!self.entities(ctx, "targets", true)?.is_empty()
|
||||
// players [<targets>]: replace the set. No argument clears it.
|
||||
let targets = if ctx.get("targets").is_some() {
|
||||
self.entities(ctx, "targets", true)?
|
||||
} else {
|
||||
false
|
||||
Vec::new()
|
||||
};
|
||||
let mut uuids = Vec::new();
|
||||
let mut names = Vec::new();
|
||||
for t in &targets {
|
||||
let Some(uuid) = self.player_target_uuid(*t) else {
|
||||
continue;
|
||||
};
|
||||
if !uuids.contains(&uuid) {
|
||||
uuids.push(uuid);
|
||||
names.push(self.target_name(*t));
|
||||
}
|
||||
}
|
||||
let b = self.bosses.bars.get_mut(bar).expect("checked");
|
||||
if b.player == include {
|
||||
let mut had = b.players.clone();
|
||||
had.sort();
|
||||
let mut next = uuids.clone();
|
||||
next.sort();
|
||||
if had == next {
|
||||
return Err(err("commands.bossbar.set.players.unchanged", &[]));
|
||||
}
|
||||
b.player = include;
|
||||
b.players = uuids;
|
||||
let n = b.players.len();
|
||||
let s = shown(self);
|
||||
if include {
|
||||
let name = self.target_name(super::commands::Target::Player);
|
||||
self.feedback(Text::translate(
|
||||
"commands.bossbar.set.players.success.some",
|
||||
&[s, num(1), format_list(vec![name])],
|
||||
));
|
||||
} else {
|
||||
if n == 0 {
|
||||
self.feedback(Text::translate(
|
||||
"commands.bossbar.set.players.success.none",
|
||||
&[s],
|
||||
));
|
||||
} else {
|
||||
self.feedback(Text::translate(
|
||||
"commands.bossbar.set.players.success.some",
|
||||
&[s, num(targets.len()), format_list(names)],
|
||||
));
|
||||
}
|
||||
Ok(i32::from(include))
|
||||
Ok(n as i32)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,29 +364,316 @@ impl Game<'_> {
|
||||
Ok(self.resolve_component(value))
|
||||
}
|
||||
|
||||
// ---- drawing ------------------------------------------------------------------
|
||||
|
||||
/// The visible custom bars (`/bossbar`), after the built-in ones.
|
||||
pub(super) fn custom_bossbars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
|
||||
for b in self
|
||||
.bosses
|
||||
.bars
|
||||
.bars
|
||||
.iter()
|
||||
.filter(|b| b.visible && b.player)
|
||||
{
|
||||
bars.push(hud_overlays::BossBar {
|
||||
name: comp_text(&b.name),
|
||||
progress: b.progress(),
|
||||
color: bossbar::COLORS
|
||||
/// Names of the online players in a boss bar's set, in join order.
|
||||
fn bossbar_player_names(&self, players: &[[i32; 4]]) -> Vec<Text> {
|
||||
self.known_boss_players()
|
||||
.into_iter()
|
||||
.filter(|(uuid, _)| players.contains(uuid))
|
||||
.map(|(_, name)| name)
|
||||
.collect()
|
||||
}
|
||||
/// The players who can be told about a bar: the one in place, then guests.
|
||||
fn known_boss_players(&self) -> Vec<([i32; 4], Text)> {
|
||||
let mut out = Vec::new();
|
||||
if self.player.is_some() {
|
||||
out.push((self.acting_uuid(), self.target_name(Target::Player)));
|
||||
}
|
||||
if let Some(host) = &self.lan {
|
||||
for g in host.hub.guests.iter().filter(|g| g.placed) {
|
||||
let ints = horizon_client::lan::playerdata::uuid_ints(g.uuid);
|
||||
if out.iter().any(|(uuid, _)| *uuid == ints) {
|
||||
continue;
|
||||
}
|
||||
out.push((ints, Text::literal(g.name.clone())));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn player_target_uuid(&self, target: Target) -> Option<[i32; 4]> {
|
||||
match target {
|
||||
Target::Player => Some(self.acting_uuid()),
|
||||
Target::Guest(conn) => self.lan.as_ref().and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.position(|c| *c == b.color)
|
||||
.unwrap_or(0),
|
||||
overlay: bossbar::STYLES
|
||||
.iter()
|
||||
.position(|s| *s == b.style)
|
||||
.unwrap_or(0),
|
||||
});
|
||||
.find(|g| g.conn == conn)
|
||||
.map(|g| horizon_client::lan::playerdata::uuid_ints(g.uuid))
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ServerBossEvent delivery ------------------------------------------------
|
||||
|
||||
/// Tell every viewer the boss events they should be seeing. The window
|
||||
/// that simulates the world applies them to its own overlay; everyone
|
||||
/// else gets `ClientboundBossEventPacket`s.
|
||||
pub(super) fn publish_boss_bars(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
self.load_bossbars();
|
||||
let now = boss_overlay::now_millis();
|
||||
let viewers = self.boss_viewers();
|
||||
let live: HashSet<u128> = viewers.iter().map(|v| v.uuid).collect();
|
||||
self.bosses.sent.retain(|id, _| live.contains(id));
|
||||
let mut packets = Vec::new();
|
||||
for viewer in &viewers {
|
||||
let desired = self.boss_events_for(viewer);
|
||||
for event in self.diff_boss_events(viewer, desired) {
|
||||
if viewer.local {
|
||||
self.boss_overlay.apply(&event, now);
|
||||
} else if let Some(conn) = viewer.conn {
|
||||
packets.push((conn, C::BossEvent(event)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if packets.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return;
|
||||
};
|
||||
for (conn, packet) in &packets {
|
||||
host.hub.send_to(*conn, packet);
|
||||
}
|
||||
}
|
||||
fn boss_viewers(&self) -> Vec<Viewer> {
|
||||
let mut out = Vec::new();
|
||||
// While `as_guest` has swapped a guest in, the host's own position
|
||||
// is not in place. Their overlay updates once the tick puts it back.
|
||||
let local =
|
||||
self.serving.is_none() && self.options.dedicated.is_none() && self.lan_acting.is_none();
|
||||
if local && let Some(p) = &self.player {
|
||||
let ints = self.level.player_uuid.unwrap_or_else(|| self.acting_uuid());
|
||||
out.push(Viewer {
|
||||
uuid: horizon_client::lan::playerdata::ints_uuid(ints),
|
||||
ints,
|
||||
at: [p.position.x, p.position.y, p.position.z],
|
||||
alive: !p.health.dead_or_dying(),
|
||||
here: true,
|
||||
local: true,
|
||||
conn: None,
|
||||
view_chunks: self.settings.render_distance,
|
||||
});
|
||||
}
|
||||
if let Some(host) = &self.lan {
|
||||
for g in host.hub.guests.iter().filter(|g| g.placed) {
|
||||
if out.iter().any(|v| v.uuid == g.uuid) {
|
||||
continue;
|
||||
}
|
||||
out.push(Viewer {
|
||||
uuid: g.uuid,
|
||||
ints: horizon_client::lan::playerdata::uuid_ints(g.uuid),
|
||||
at: g.position,
|
||||
alive: !g.dead,
|
||||
here: g.dimension == self.dim.id(),
|
||||
local: false,
|
||||
conn: Some(g.conn),
|
||||
view_chunks: g.view_distance,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Custom bars (`CustomBossEvents.onPlayerConnect` sends them as a player
|
||||
/// joins), then the dragon, withers and the raid.
|
||||
fn boss_events_for(&self, viewer: &Viewer) -> Vec<Offer> {
|
||||
let mut out = Vec::new();
|
||||
for b in &self.bosses.bars.bars {
|
||||
if !b.visible || !b.players.contains(&viewer.ints) {
|
||||
continue;
|
||||
}
|
||||
out.push(Offer {
|
||||
id: b.uuid,
|
||||
name: json_to_tag(&b.name),
|
||||
progress: b.progress(),
|
||||
color: color_index(&b.color),
|
||||
overlay: style_index(&b.style),
|
||||
darken: b.darken,
|
||||
music: b.music,
|
||||
fog: b.fog,
|
||||
scope: Scope::Global,
|
||||
});
|
||||
}
|
||||
if viewer.here
|
||||
&& let Some(progress) = self.dragon_boss_progress(viewer.at, viewer.alive)
|
||||
{
|
||||
out.push(Offer {
|
||||
id: bossbar::stable_uuid("minecraft:ender_dragon"),
|
||||
name: translate_tag("entity.minecraft.ender_dragon"),
|
||||
progress,
|
||||
color: 0,
|
||||
overlay: 0,
|
||||
darken: false,
|
||||
music: true,
|
||||
fog: true,
|
||||
scope: Scope::Level(self.dim),
|
||||
});
|
||||
}
|
||||
if viewer.here {
|
||||
let range = f64::from(10.min(viewer.view_chunks.max(0)) * 16);
|
||||
for m in self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.species() == Species::Wither && m.alive() && !m.removed)
|
||||
{
|
||||
let (dx, dz) = (m.position.x - viewer.at[0], m.position.z - viewer.at[2]);
|
||||
if dx * dx + dz * dz > range * range {
|
||||
continue;
|
||||
}
|
||||
let progress = if m.extra.counter > 0 {
|
||||
1.0 - m.extra.counter as f32 / horizon_client::mobs::wither::INVULNERABLE as f32
|
||||
} else {
|
||||
(m.health / m.attrs.max_health).clamp(0.0, 1.0)
|
||||
};
|
||||
let name = if let Some(text) = &m.custom_name_text {
|
||||
json_to_tag(&text.component())
|
||||
} else if let Some(name) = &m.custom_name {
|
||||
Tag::String(name.as_str().into())
|
||||
} else {
|
||||
translate_tag("entity.minecraft.wither")
|
||||
};
|
||||
out.push(Offer {
|
||||
id: super::lan::entity_uuid(m.id),
|
||||
name,
|
||||
progress,
|
||||
color: 5,
|
||||
overlay: 0,
|
||||
darken: true,
|
||||
music: false,
|
||||
fog: false,
|
||||
scope: Scope::Level(self.dim),
|
||||
});
|
||||
}
|
||||
}
|
||||
if viewer.here
|
||||
&& let Some((id, name, progress)) = self.raid_boss_at(viewer.at, viewer.alive)
|
||||
{
|
||||
out.push(Offer {
|
||||
id: bossbar::stable_uuid(&format!("raid:{id}")),
|
||||
name,
|
||||
progress,
|
||||
color: 2,
|
||||
overlay: 2,
|
||||
darken: false,
|
||||
music: false,
|
||||
fog: false,
|
||||
scope: Scope::Level(self.dim),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The packets that take `viewer` from what it was last sent to `desired`
|
||||
/// (`ServerBossEvent`'s broadcasts).
|
||||
fn diff_boss_events(&mut self, viewer: &Viewer, desired: Vec<Offer>) -> Vec<play::BossEvent> {
|
||||
let full = viewer.local;
|
||||
let dim = self.dim;
|
||||
let sent = self.bosses.sent.entry(viewer.uuid).or_default();
|
||||
let mut out = Vec::new();
|
||||
let event = |id, action| play::BossEvent { id, action };
|
||||
sent.retain(|id, prev| {
|
||||
let owned = full || prev.scope == Scope::Global || prev.scope == Scope::Level(dim);
|
||||
let keep = !owned || desired.iter().any(|e| e.id == *id);
|
||||
if !keep {
|
||||
out.push(event(*id, BossAction::Remove));
|
||||
}
|
||||
keep
|
||||
});
|
||||
for offer in desired {
|
||||
let Some(prev) = sent.get(&offer.id) else {
|
||||
out.push(event(
|
||||
offer.id,
|
||||
BossAction::Add {
|
||||
name: offer.name.clone(),
|
||||
progress: offer.progress,
|
||||
color: offer.color,
|
||||
overlay: offer.overlay,
|
||||
darken: offer.darken,
|
||||
music: offer.music,
|
||||
fog: offer.fog,
|
||||
},
|
||||
));
|
||||
sent.insert(offer.id, offer);
|
||||
continue;
|
||||
};
|
||||
let id = offer.id;
|
||||
if prev.progress != offer.progress {
|
||||
let progress = offer.progress;
|
||||
out.push(event(id, BossAction::UpdateProgress { progress }));
|
||||
}
|
||||
if prev.name != offer.name {
|
||||
let name = offer.name.clone();
|
||||
out.push(event(id, BossAction::UpdateName { name }));
|
||||
}
|
||||
if (prev.color, prev.overlay) != (offer.color, offer.overlay) {
|
||||
let (color, overlay) = (offer.color, offer.overlay);
|
||||
out.push(event(id, BossAction::UpdateStyle { color, overlay }));
|
||||
}
|
||||
if (prev.darken, prev.music, prev.fog) != (offer.darken, offer.music, offer.fog) {
|
||||
let (darken, music, fog) = (offer.darken, offer.music, offer.fog);
|
||||
out.push(event(
|
||||
id,
|
||||
BossAction::UpdateProperties { darken, music, fog },
|
||||
));
|
||||
}
|
||||
sent.insert(id, offer);
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `ClientPacketListener.handleBossUpdate`.
|
||||
pub(super) fn apply_boss_event(&mut self, packet: &play::BossEvent) {
|
||||
self.boss_overlay.apply(packet, boss_overlay::now_millis());
|
||||
}
|
||||
}
|
||||
|
||||
/// `Mth.createInsecureUUID`.
|
||||
fn insecure_uuid(random: &mut impl RandomSource) -> u128 {
|
||||
let most = random.next_long() & -61441 | 16384;
|
||||
let least = random.next_long() & 4_611_686_018_427_387_903 | i64::MIN;
|
||||
((most as u64 as u128) << 64) | (least as u64 as u128)
|
||||
}
|
||||
fn translate_tag(key: &str) -> Tag {
|
||||
Tag::compound([("translate", Tag::String(key.into()))])
|
||||
}
|
||||
fn color_index(color: &str) -> i32 {
|
||||
bossbar::COLORS
|
||||
.iter()
|
||||
.position(|c| *c == color)
|
||||
.unwrap_or(6) as i32
|
||||
}
|
||||
fn style_index(style: &str) -> i32 {
|
||||
bossbar::STYLES
|
||||
.iter()
|
||||
.position(|s| *s == style)
|
||||
.unwrap_or(0) as i32
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Scope {
|
||||
Global,
|
||||
Level(Dim),
|
||||
}
|
||||
struct Viewer {
|
||||
uuid: u128,
|
||||
ints: [i32; 4],
|
||||
at: [f64; 3],
|
||||
alive: bool,
|
||||
here: bool,
|
||||
local: bool,
|
||||
conn: Option<u32>,
|
||||
view_chunks: i32,
|
||||
}
|
||||
/// A boss event as one viewer should see it, and as it was last sent.
|
||||
struct Offer {
|
||||
id: u128,
|
||||
name: Tag,
|
||||
progress: f32,
|
||||
/// `BossBarColor` and `BossBarOverlay` ordinals.
|
||||
color: i32,
|
||||
overlay: i32,
|
||||
darken: bool,
|
||||
music: bool,
|
||||
fog: bool,
|
||||
scope: Scope,
|
||||
}
|
||||
@@ -570,8 +570,14 @@ impl Game<'_> {
|
||||
/// ChatScreen.handleChatInput: history, then a command or a chat line.
|
||||
pub(super) fn handle_chat_input(&mut self, message: &str) {
|
||||
self.ui.chat.add_recent(message);
|
||||
// On a server (single player's own included) the server runs it.
|
||||
// On a server (single player's own included) the server runs it,
|
||||
// unless a client mod owns the command.
|
||||
if self.remote.is_some() {
|
||||
if let Some(command) = message.strip_prefix('/')
|
||||
&& self.mod_command(command)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.send_chat(message);
|
||||
return;
|
||||
}
|
||||
@@ -816,8 +822,12 @@ impl Game<'_> {
|
||||
("data", data),
|
||||
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
|
||||
]);
|
||||
let _ = horizon_client::save::write_dat(&path, root);
|
||||
let _ = write_difficulty(&self.world_root, self.ui.difficulty);
|
||||
if let Err(e) = horizon_client::save::write_dat(&path, root) {
|
||||
log::warn!("the world clocks were not written: {e}");
|
||||
}
|
||||
if let Err(e) = write_difficulty(&self.world_root, self.ui.difficulty) {
|
||||
log::warn!("the difficulty was not written: {e}");
|
||||
}
|
||||
}
|
||||
/// Death message in chat (CombatTracker.getDeathMessage, as far as the
|
||||
/// cause is known to the client).
|
||||
|
||||
@@ -1082,6 +1082,8 @@ impl Game<'_> {
|
||||
self.for_player(t, |g| g.teleport_to(Target::Player, pos, rotation));
|
||||
}
|
||||
Target::Player => {
|
||||
// ServerPlayer.teleport: removeVehicle before placing the player.
|
||||
self.stop_riding();
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = v;
|
||||
p.velocity = Vec3::new(0.0, 0.0, 0.0);
|
||||
@@ -1783,8 +1785,11 @@ impl Game<'_> {
|
||||
self.level_set(pos, state)
|
||||
.map_err(|e| CommandError::new(Text::literal(e.to_string())))?;
|
||||
}
|
||||
// LevelChunk.setBlockState creates it when the block, not the state, is still
|
||||
// there after onPlace: a powered hopper turns itself `enabled=false`.
|
||||
let landed = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if old != new
|
||||
&& self.world.block(pos[0], pos[1], pos[2]) == state
|
||||
&& self.res.facts().get(landed).name == new
|
||||
&& let Some(entity) = horizon_client::containers::BlockEntity::for_block(&new)
|
||||
{
|
||||
self.world.block_entities.insert(key, entity);
|
||||
|
||||
@@ -65,9 +65,10 @@ impl Game<'_> {
|
||||
tag
|
||||
}
|
||||
Target::Mob(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let m = self.mobs.iter().find(|m| m.id == id)?;
|
||||
entity_nbt::mob_to_nbt(m, uuid, self.persist.raw.get(&id))
|
||||
// `saveWithoutId` writes the entity as saved, riders under
|
||||
// `Passengers`: `/data get` reads the same tree persistence does (#1519).
|
||||
let i = self.mobs.iter().position(|m| m.id == id)?;
|
||||
self.mob_tree_nbt(i)
|
||||
}
|
||||
Target::Item(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
|
||||
@@ -59,7 +59,15 @@ impl Game<'_> {
|
||||
self.recipe_unlocked(&unlocked);
|
||||
count as i32
|
||||
} else {
|
||||
self.level.recipe_book.remove_recipes(index, &recipes) as i32
|
||||
let removed = self.level.recipe_book.remove_recipes(index, &recipes);
|
||||
let count = removed.len() as i32;
|
||||
// A network player's client drops them from its book.
|
||||
if self.lan_acting.is_some() && !removed.is_empty() {
|
||||
self.guest_packets.push(C::RecipeBookRemove(
|
||||
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
|
||||
));
|
||||
}
|
||||
count
|
||||
};
|
||||
tracker.track(&h, n);
|
||||
}
|
||||
@@ -345,7 +353,26 @@ impl Game<'_> {
|
||||
Some(Arg::Resource(s)) => Some(s.clone()),
|
||||
_ => None,
|
||||
};
|
||||
self.audio.stop_sounds(sound.as_deref(), source);
|
||||
// StopSoundCommand: each target's connection gets the packet; the
|
||||
// window's own player stops its sounds.
|
||||
let packet = C::StopSound(play::StopSound {
|
||||
source: source.map(|s| s as i32),
|
||||
sound: sound.clone(),
|
||||
});
|
||||
for &t in &targets {
|
||||
match t {
|
||||
Target::Player if self.lan_acting.is_some() => {
|
||||
self.guest_packets.push(packet.clone());
|
||||
}
|
||||
Target::Player => self.audio.stop_sounds(sound.as_deref(), source),
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let key = match (source, &sound) {
|
||||
(Some(_), Some(_)) => "commands.stopsound.success.source.sound",
|
||||
(Some(_), None) => "commands.stopsound.success.source.any",
|
||||
|
||||
@@ -226,6 +226,16 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(Some(json!({ "ok": true, "cull": r.section_cull() })))
|
||||
}
|
||||
"section-sides" => {
|
||||
let r = self.renderer.as_mut().ok_or("no renderer")?;
|
||||
match words.get(1).copied() {
|
||||
None => {}
|
||||
Some("on") => r.set_section_sides(true),
|
||||
Some("off") => r.set_section_sides(false),
|
||||
Some(other) => return Err(format!("unknown section sides mode {other}").into()),
|
||||
}
|
||||
Ok(Some(json!({ "ok": true, "sides": r.section_sides() })))
|
||||
}
|
||||
"sculk" => Ok(Some(self.sculk_command(&words)?)),
|
||||
"status" => Ok(Some(self.control_status())),
|
||||
"pad" => Ok(Some(self.pad_command(&words)?)),
|
||||
@@ -981,6 +991,7 @@ impl Game<'_> {
|
||||
status["destroying"] = json!(destroying);
|
||||
status["physical_keys"] = json!(physical_keys);
|
||||
status["heard"] = json!(self.audio.heard);
|
||||
status["stopped_sounds"] = json!(self.audio.stopped);
|
||||
// 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],
|
||||
|
||||
@@ -978,7 +978,9 @@ impl Game<'_> {
|
||||
m.ai.target = Some(target);
|
||||
}
|
||||
}
|
||||
MobEvent::Projectile(p) => {
|
||||
MobEvent::Projectile(mut p) => {
|
||||
self.next_entity += 1;
|
||||
p.id = self.next_entity;
|
||||
let (owner, at) = (p.owner, [p.position.x, p.position.y, p.position.z]);
|
||||
self.creatures.projectiles.push(p);
|
||||
self.projectile_event("minecraft:projectile_shoot", owner, at);
|
||||
|
||||
@@ -82,8 +82,10 @@ impl Game<'_> {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Screens with a text box take every key press first
|
||||
// (EditBox.keyPressed); releases still reach KeyboardHandler.
|
||||
// Vanilla types into a focused EditBox through charTyped, which no
|
||||
// debug key can swallow. Here the character comes with the key press,
|
||||
// so a screen with a focused text box takes every press first;
|
||||
// releases still reach KeyboardHandler.
|
||||
let typing = matches!(
|
||||
self.screen,
|
||||
Screen::Chat | Screen::Options | Screen::WorldOptions | Screen::CommandBlock
|
||||
@@ -91,7 +93,11 @@ impl Game<'_> {
|
||||
|| self.book.is_some()
|
||||
|| (self.screen == Screen::Creative && self.creative_screen.tab().search)
|
||||
|| self.recipe_search_focused()
|
||||
|| self.station_typing();
|
||||
|| self.station_typing()
|
||||
|| self
|
||||
.title
|
||||
.as_mut()
|
||||
.is_some_and(|t| t.focused_field().is_some());
|
||||
// Container screens use Escape, the inventory key and the hotbar and
|
||||
// drop keys first; any other screen closes on Escape.
|
||||
let screen_key = match self.screen {
|
||||
@@ -179,7 +185,9 @@ impl Game<'_> {
|
||||
// Minecraft.emergencySaveAndCrash: the world is saved, then the
|
||||
// game stops; main writes the crash report.
|
||||
let crash = horizon_client::crash::DebugCrash::new();
|
||||
let _ = self.save_game();
|
||||
if let Err(e) = self.save_game() {
|
||||
log::error!("the emergency save before the crash failed: {e}");
|
||||
}
|
||||
return Err(crash.into());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! its simulation and the play-state hub that LAN hosting uses, configured
|
||||
//! from server.properties, with the operator, whitelist and ban lists and a
|
||||
//! console on standard input.
|
||||
use super::{Game, Options, lan::LanSettings};
|
||||
use super::{Game, Options, lan::LanSettings, world_loading::WorldRequest};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
lan::{LoginGate, NetConfig, NetLog, OnlineMode, hub::HubConfig},
|
||||
@@ -218,21 +218,22 @@ impl Lists {
|
||||
use_whitelist: false,
|
||||
enforce_whitelist: false,
|
||||
};
|
||||
// DedicatedPlayerList's constructor order and its LOGGER.warn lines.
|
||||
let warn = |what: &str, r: std::result::Result<(), String>| {
|
||||
if let Err(e) = r {
|
||||
log("WARN", &format!("Failed to load {what}: {e}"));
|
||||
log("WARN", &format!("Failed to {what}: {e}"));
|
||||
}
|
||||
};
|
||||
// DedicatedPlayerList's constructor order.
|
||||
warn("user banlist", l.bans.load());
|
||||
let _ = l.bans.save();
|
||||
warn("ip banlist", l.ip_bans.load());
|
||||
let _ = l.ip_bans.save();
|
||||
warn("operators list", l.ops.load());
|
||||
warn("white-list", l.white.load());
|
||||
let _ = l.ops.save();
|
||||
let saved = |r: std::io::Result<()>| r.map_err(|e| e.to_string());
|
||||
warn("load user banlist", l.bans.load());
|
||||
warn("save user banlist", saved(l.bans.save()));
|
||||
warn("load ip banlist", l.ip_bans.load());
|
||||
warn("save ip banlist", saved(l.ip_bans.save()));
|
||||
warn("load operators list", l.ops.load());
|
||||
warn("load white-list", l.white.load());
|
||||
warn("save operators list", saved(l.ops.save()));
|
||||
if !l.white.file.exists() {
|
||||
let _ = l.white.save();
|
||||
warn("save white-list", saved(l.white.save()));
|
||||
}
|
||||
l
|
||||
}
|
||||
@@ -546,13 +547,13 @@ impl Game<'_> {
|
||||
let lists = Arc::new(Mutex::new(lists));
|
||||
log("INFO", &format!("Preparing level \"{level_name}\""));
|
||||
let folder = world_dir.to_string_lossy().into_owned();
|
||||
self.open_world(
|
||||
self.load_world(WorldRequest::new(
|
||||
&folder,
|
||||
Some(&level_name),
|
||||
Some(props.seed),
|
||||
game_mode == 1,
|
||||
None,
|
||||
)?;
|
||||
)?)?;
|
||||
if new_world {
|
||||
self.level.hardcore = props.hardcore;
|
||||
}
|
||||
@@ -608,6 +609,7 @@ impl Game<'_> {
|
||||
lang: Arc::new(horizon_client::net::load_language(&self.options.resources)),
|
||||
log_ips: props.log_ips,
|
||||
}),
|
||||
resource_pack: props.resource_pack.clone(),
|
||||
};
|
||||
let config = HubConfig {
|
||||
default_game_mode: game_mode,
|
||||
|
||||
@@ -12,7 +12,6 @@ use horizon_client::{
|
||||
hud_layout, hud_overlays,
|
||||
mesher::Vertex,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
@@ -2157,23 +2156,12 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `BossHealthOverlay`: the dragon's pink bar, then the withers', the
|
||||
/// raids' and the custom bars, in that order.
|
||||
/// `BossHealthOverlay.extractRenderState`: whatever `publish_boss_bars`
|
||||
/// (or a server's packets) put on this client's overlay.
|
||||
pub(super) fn draw_boss_bars(&self, batch: &mut HudBatch) {
|
||||
let mut bars = Vec::new();
|
||||
if let Some(d) = self.dragon.as_ref().filter(|_| self.dragon_bar_shown()) {
|
||||
bars.push(hud_overlays::BossBar {
|
||||
name: Text::literal(
|
||||
self.translate("entity.minecraft.ender_dragon", "Ender Dragon"),
|
||||
),
|
||||
progress: (d.health / MAX_HEALTH).clamp(0.0, 1.0),
|
||||
color: 0,
|
||||
overlay: 0,
|
||||
});
|
||||
}
|
||||
self.wither_bars(&mut bars);
|
||||
self.raid_bars(&mut bars);
|
||||
self.custom_bossbars(&mut bars);
|
||||
let bars = self
|
||||
.boss_overlay
|
||||
.bars(horizon_client::boss_overlay::now_millis());
|
||||
let draws = hud_overlays::boss_overlay(
|
||||
batch.width as i32,
|
||||
batch.height as i32,
|
||||
@@ -2182,14 +2170,26 @@ impl Game<'_> {
|
||||
);
|
||||
hud_layout::paint(batch, &self.gui.atlas, &draws);
|
||||
}
|
||||
/// The dragon fight's boss bar shows (`EnderDragonFight.dragonEvent`,
|
||||
/// with `setCreateWorldFog`).
|
||||
pub(super) fn dragon_bar_shown(&self) -> bool {
|
||||
self.dim == Dim::End && self.dragon.is_some()
|
||||
/// The dragon fight's bar for a player at `at`
|
||||
/// (`EnderDragonFight.dragonEvent`, visible while the fight is not won
|
||||
/// and the player is alive within 192 blocks of (0, 128, 0)).
|
||||
pub(super) fn dragon_boss_progress(&self, at: [f64; 3], alive: bool) -> Option<f32> {
|
||||
if self.dim != Dim::End || !alive || !self.fight.loaded || self.fight.killed {
|
||||
return None;
|
||||
}
|
||||
let near = at[0].powi(2) + (at[1] - 128.0).powi(2) + at[2].powi(2) <= 192.0 * 192.0;
|
||||
if !near {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
self.dragon
|
||||
.as_ref()
|
||||
.map_or(1.0, |d| (d.health / MAX_HEALTH).clamp(0.0, 1.0)),
|
||||
)
|
||||
}
|
||||
/// `music.dragon` while the boss bar plays music.
|
||||
/// `BossHealthOverlay.shouldPlayMusic`.
|
||||
pub(super) fn boss_music(&self) -> bool {
|
||||
self.dim == Dim::End && self.dragon.is_some() || self.wither_near()
|
||||
self.boss_overlay.should_play_music()
|
||||
}
|
||||
}
|
||||
pub(super) struct DragonFireball {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! A Game's execution role is fixed at construction. Connecting or disconnecting
|
||||
//! changes `remote`, not this role. A client may simulate a world in-frame.
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum ExecutionRole {
|
||||
Client,
|
||||
IntegratedServer,
|
||||
DedicatedServer,
|
||||
}
|
||||
|
||||
impl ExecutionRole {
|
||||
/// Only a client owns presentation work (audio, loading UI, chunk meshes).
|
||||
pub fn is_client(self) -> bool {
|
||||
self == Self::Client
|
||||
}
|
||||
|
||||
/// The optional in-frame simulation remains a client execution mode.
|
||||
pub fn starts_integrated_server(self, server_thread: bool) -> bool {
|
||||
self.is_client() && server_thread
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ExecutionRole::*;
|
||||
|
||||
#[test]
|
||||
fn world_loading_keeps_in_frame_clients_and_server_roles_separate() {
|
||||
for (role, client, threaded) in [
|
||||
(Client, true, true),
|
||||
(IntegratedServer, false, false),
|
||||
(DedicatedServer, false, false),
|
||||
] {
|
||||
assert_eq!(role.is_client(), client);
|
||||
assert_eq!(role.starts_integrated_server(true), threaded);
|
||||
assert!(!role.starts_integrated_server(false));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! box each, and Done sending the changed ones (ServerboundSetGameRulePacket).
|
||||
//! Leaving with changes asks first (ConfirmScreen).
|
||||
use horizon_client::{
|
||||
confirm_screen::Confirm,
|
||||
font::Font,
|
||||
gamerules::{self, GameRules, RULES, Rule, Value},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
@@ -66,9 +67,6 @@ fn parse(r: &Rule, s: &str) -> Option<i32> {
|
||||
.flatten()
|
||||
.filter(|v| (r.min..=r.max).contains(v))
|
||||
}
|
||||
fn widest(font: &Font, lines: &[Text]) -> f32 {
|
||||
lines.iter().map(|l| font.width_text(l)).fold(0.0, f32::max)
|
||||
}
|
||||
fn lines(font: &Font, t: &Text, width: f32) -> Vec<Text> {
|
||||
font.split_lines(t, width)
|
||||
.into_iter()
|
||||
@@ -95,38 +93,11 @@ fn yellow() -> u32 {
|
||||
named_color("yellow").unwrap_or(0xFFFF55)
|
||||
}
|
||||
|
||||
/// ConfirmScreen: the title, the message (at most `width - 50` wide), and
|
||||
/// Yes / No 16 below, 4 apart, all centred.
|
||||
struct Confirm {
|
||||
lines: Vec<(Text, f32, f32)>,
|
||||
buttons: [(f32, f32); 2],
|
||||
}
|
||||
impl Confirm {
|
||||
fn new(font: &Font, (w, h): (f32, f32)) -> Self {
|
||||
let title = Text::translate("editGamerule.inGame.discardChanges.title", &[]);
|
||||
let message = Text::translate("editGamerule.inGame.discardChanges.message", &[]);
|
||||
let message: Vec<Text> = lines(font, &message, w - 50.0)
|
||||
.into_iter()
|
||||
.take(15)
|
||||
.collect();
|
||||
let (tw, mw) = (font.width_text(&title), widest(font, &message));
|
||||
let mh = 9.0 * message.len() as f32;
|
||||
let col = tw.max(mw).max(304.0);
|
||||
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
|
||||
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
|
||||
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
|
||||
let mid = cell(mw) + (mw / 2.0).trunc();
|
||||
let mut out = vec![(title, cell(tw), ly)];
|
||||
for (i, line) in message.into_iter().enumerate() {
|
||||
let x = mid - (font.width_text(&line) / 2.0).trunc();
|
||||
out.push((line, x, ly + 17.0 + 9.0 * i as f32));
|
||||
}
|
||||
let (bx, by) = (cell(304.0), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
|
||||
Self {
|
||||
lines: out,
|
||||
buttons: [(bx, by), (bx + 154.0, by)],
|
||||
}
|
||||
}
|
||||
/// ConfirmScreen "Game Rule Changes".
|
||||
fn confirm(font: &Font, size: (f32, f32)) -> Confirm {
|
||||
let title = Text::translate("editGamerule.inGame.discardChanges.title", &[]);
|
||||
let message = Text::translate("editGamerule.inGame.discardChanges.message", &[]);
|
||||
Confirm::new(font, size, &title, &message)
|
||||
}
|
||||
|
||||
impl RulesScreen {
|
||||
@@ -301,13 +272,10 @@ impl RulesScreen {
|
||||
let (w, h) = size;
|
||||
let on = |x: f32, y: f32, bw: f32, bh: f32| ui::hovered(cursor, x, y, bw, bh);
|
||||
if self.confirm {
|
||||
let c = Confirm::new(&atlas.font, size);
|
||||
let [(yx, yy), (nx, ny)] = c.buttons;
|
||||
if on(yx, yy, 150.0, 20.0) {
|
||||
return Some(Exit::Discard);
|
||||
}
|
||||
if on(nx, ny, 150.0, 20.0) {
|
||||
self.confirm = false;
|
||||
match confirm(&atlas.font, size).answer_at(cursor) {
|
||||
Some(true) => return Some(Exit::Discard),
|
||||
Some(false) => self.confirm = false,
|
||||
None => {}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
@@ -367,7 +335,7 @@ impl RulesScreen {
|
||||
let (w, h) = size;
|
||||
let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0);
|
||||
if self.confirm {
|
||||
return Confirm::new(&atlas.font, size).buttons.into_iter().any(on);
|
||||
return confirm(&atlas.font, size).answer_at(cursor).is_some();
|
||||
}
|
||||
let [done, cancel] = footer(w, h);
|
||||
if (on(done) && !self.invalid()) || on(cancel) {
|
||||
@@ -384,15 +352,11 @@ impl RulesScreen {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
if self.confirm {
|
||||
let c = Confirm::new(&atlas.font, (w, h));
|
||||
for (line, x, y) in &c.lines {
|
||||
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
|
||||
}
|
||||
for ((bx, by), key) in c.buttons.into_iter().zip(["gui.yes", "gui.no"]) {
|
||||
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
|
||||
let label = Text::translate(key, &[]);
|
||||
ui::button(batch, atlas, bx, by, 150.0, 20.0, &label, true, hover);
|
||||
}
|
||||
let labels = [
|
||||
Text::translate("gui.yes", &[]),
|
||||
Text::translate("gui.no", &[]),
|
||||
];
|
||||
confirm(&atlas.font, (w, h)).draw(batch, atlas, cursor, [&labels[0], &labels[1]]);
|
||||
return;
|
||||
}
|
||||
let rows = self.rows();
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
//! way.
|
||||
use super::{
|
||||
Game, Options, PackResources, Resources, UiResources,
|
||||
execution_role::ExecutionRole,
|
||||
lan::{LanSettings, Owner},
|
||||
world_loading::WorldRequest,
|
||||
};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
@@ -26,7 +28,6 @@ use horizon_protocol::connection::Pipe;
|
||||
use horizon_protocol::{crypto::offline_uuid, text::translatable};
|
||||
use std::{
|
||||
net::TcpListener,
|
||||
path::Path,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, AtomicI32, AtomicU16, AtomicU32, Ordering},
|
||||
@@ -170,11 +171,6 @@ struct Launch {
|
||||
player: String,
|
||||
}
|
||||
|
||||
/// Whether this world opens on a server thread: asked for.
|
||||
pub(super) fn wanted(options: &Options, _world: &Path) -> bool {
|
||||
options.server_thread && options.dedicated.is_none()
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Minecraft.hasSingleplayerServer`: a world of this window's own, run
|
||||
/// in the frame or on its server thread.
|
||||
@@ -215,9 +211,10 @@ impl Game<'_> {
|
||||
};
|
||||
let mut options = self.options.clone();
|
||||
// A server without a console: no local player, no window, no control socket.
|
||||
// Compatibility for gameplay checks outside startup/world loading.
|
||||
options.dedicated = Some(Vec::new());
|
||||
options.server_thread = false;
|
||||
options.integrated = true;
|
||||
options.execution_role = ExecutionRole::IntegratedServer;
|
||||
options.control = None;
|
||||
options.benchmark = None;
|
||||
options.frames = None;
|
||||
@@ -287,13 +284,13 @@ impl Game<'_> {
|
||||
tasks: Receiver<Task>,
|
||||
ready: &Sender<Ready>,
|
||||
) -> Result<()> {
|
||||
self.open_world(
|
||||
self.load_world(WorldRequest::new(
|
||||
&launch.folder,
|
||||
launch.name.as_deref(),
|
||||
launch.seed,
|
||||
launch.creative,
|
||||
launch.create.as_ref(),
|
||||
)?;
|
||||
)?)?;
|
||||
let saved = Box::new(self.level.clone());
|
||||
// Math.max(2, options.renderDistance): past vanilla's 32 when the
|
||||
// window's render distance is (Horizon's options go to 64).
|
||||
|
||||
@@ -834,8 +834,11 @@ impl Game<'_> {
|
||||
self.lan_object_states(host_entity, hub, out);
|
||||
}
|
||||
|
||||
/// Run the hub for this tick and apply what the guests did.
|
||||
pub(super) fn tick_lan(&mut self) {
|
||||
/// Run the hub for this tick and apply what the guests did. While the
|
||||
/// single player game is `paused` only the connections run
|
||||
/// (`IntegratedServer.tickPaused`): `ServerGamePacketListenerImpl.tick`
|
||||
/// skips `tickPlayer`, so no player's air, hunger or damage moves on.
|
||||
pub(super) fn tick_lan(&mut self, paused: bool) {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
@@ -1000,7 +1003,9 @@ impl Game<'_> {
|
||||
host.states = states;
|
||||
}
|
||||
self.lan_flush_effects(&mut host);
|
||||
self.tick_guests(&mut host);
|
||||
if !paused {
|
||||
self.tick_guests(&mut host);
|
||||
}
|
||||
self.lan_living_flags(&mut host);
|
||||
self.tick_server_travel(&mut host);
|
||||
self.lan_swings(&mut host);
|
||||
|
||||
@@ -1277,10 +1277,24 @@ impl Game<'_> {
|
||||
id: i32,
|
||||
location: Option<[f64; 3]>,
|
||||
) -> Option<([f64; 3], [f64; 3])> {
|
||||
let eye = {
|
||||
let g = &host.hub.guests[gi];
|
||||
[g.position[0], g.position[1] + g.eye_height(), g.position[2]]
|
||||
};
|
||||
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 {
|
||||
// A boat's mobs sit over its middle: aim low at its near
|
||||
// side, so the ray meets the boat before them (the server
|
||||
// attacks the entity it is sent).
|
||||
let carries = u32::try_from(id).is_ok_and(|v| {
|
||||
self.boats.iter().any(|b| b.id == v) && !self.boat_mobs(v).is_empty()
|
||||
});
|
||||
if carries {
|
||||
let near = |k: usize| eye[k].clamp(lo[k] + 0.01, hi[k] - 0.01);
|
||||
return Some([near(0), lo[1] + 0.05, near(2)]);
|
||||
}
|
||||
return Some(middle);
|
||||
};
|
||||
let point = [0, 1, 2].map(|k| origin[k] + l[k]);
|
||||
@@ -1311,7 +1325,6 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let g = &mut host.hub.guests[gi];
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let d = [centre[0] - eye[0], centre[1] - eye[1], centre[2] - eye[2]];
|
||||
let horizontal = (d[0] * d[0] + d[2] * d[2]).sqrt();
|
||||
g.yaw = (-d[0].atan2(d[2])).to_degrees() as f32;
|
||||
@@ -1926,7 +1939,7 @@ impl Game<'_> {
|
||||
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
|
||||
let at = g.position;
|
||||
let sneaking = g.sneaking;
|
||||
let sounds = self.as_guest(host, conn, |game| {
|
||||
let events = self.as_guest(host, conn, |game| {
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
@@ -2023,20 +2036,17 @@ impl Game<'_> {
|
||||
// A hurt since the last tick marked the velocity (Entity.markHurt):
|
||||
// ServerEntity.sendChanges sends it as it is after this travel.
|
||||
let marked = std::mem::take(&mut player.health.sync_velocity);
|
||||
let sounds = events
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
MotionEvent::HurtSound(sound) => Some(sound),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(sounds, marked.then_some(player.velocity))
|
||||
(events, marked.then_some(player.velocity))
|
||||
});
|
||||
let Some((sounds, marked)) = sounds else {
|
||||
let Some((events, marked)) = events else {
|
||||
return;
|
||||
};
|
||||
for sound in sounds {
|
||||
self.player_hurt_sound(sound, at);
|
||||
for event in events {
|
||||
match event {
|
||||
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
|
||||
MotionEvent::Fall(event) => self.landing_block_updates(&event),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let (Some(v), Some(g)) = (marked, host.hub.guest(conn)) {
|
||||
g.send(&C::SetEntityMotion(play::SetEntityMotion {
|
||||
@@ -2439,6 +2449,32 @@ impl Game<'_> {
|
||||
.map_or(&[][..], |log| log.get(logged..).unwrap_or(&[]))
|
||||
.to_vec();
|
||||
let facts = self.res.facts();
|
||||
// BlockItem.place: `playSound(player, ...)`, for the others only. The
|
||||
// placer's client plays it as its block appears.
|
||||
let placed: Vec<([f64; 3], String)> = changed
|
||||
.iter()
|
||||
.map(|&(x, y, z)| {
|
||||
let at = [f64::from(x) + 0.5, f64::from(y) + 0.5, f64::from(z) + 0.5];
|
||||
let name = &facts.get(self.world.block(x, y, z)).sounds.place;
|
||||
(at, name.trim_start_matches("minecraft:").to_owned())
|
||||
})
|
||||
.collect();
|
||||
if let Some(capture) = self.audio.capture.as_mut() {
|
||||
let mut i = heard.min(capture.len());
|
||||
while i < capture.len() {
|
||||
let (name, at, ..) = &capture[i];
|
||||
let place = placed
|
||||
.iter()
|
||||
.any(|(p, n)| p == at && name.trim_start_matches("minecraft:") == n.as_str());
|
||||
if place {
|
||||
let (name, at, volume, pitch) = capture.remove(i);
|
||||
let source = horizon_client::settings::sound_source(&name) as i32;
|
||||
host.hub.sound(&name, at, volume, pitch, source, Some(conn));
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed.is_empty()
|
||||
|| !changed.iter().all(|&(x, y, z)| {
|
||||
super::remote::hand_operated(&facts.get(self.world.block(x, y, z)).name)
|
||||
|
||||
@@ -48,10 +48,9 @@ impl Game<'_> {
|
||||
if let Some(id) = state.mob
|
||||
&& state.session.villager
|
||||
{
|
||||
// startTrading with the guest's reputation (their effects are
|
||||
// not modelled on the host: no Hero of the Village).
|
||||
// startTrading with the guest's reputation. The session already
|
||||
// captured their Hero effect while they acted as the player.
|
||||
state.session.reputation = self.villager_reputation_of(id, uuid);
|
||||
state.session.hero = None;
|
||||
state.session.update_special_prices();
|
||||
}
|
||||
let title = self.merchant_display_name(&state);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! 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 super::{Game, Keys, lan::LanHost, minecarts::SEAT};
|
||||
use horizon_client::collisions::SceneCollisions;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_protocol::{
|
||||
@@ -120,8 +120,8 @@ impl Game<'_> {
|
||||
game.dismount(i);
|
||||
return None;
|
||||
}
|
||||
let p = game.boats[i].position;
|
||||
return Some([p.x, p.y + SEAT, p.z]);
|
||||
let p = game.player_boat_seat(i);
|
||||
return Some([p.x, p.y, 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
|
||||
@@ -161,19 +161,19 @@ impl Game<'_> {
|
||||
let at = self
|
||||
.boats
|
||||
.iter()
|
||||
.find(|b| b.id == vehicle)
|
||||
.map(|b| b.position)
|
||||
.position(|b| b.id == vehicle)
|
||||
.map(|i| (self.boats[i].position, self.player_boat_seat(i)))
|
||||
.or_else(|| {
|
||||
self.carts
|
||||
.iter()
|
||||
.find(|c| c.id == vehicle)
|
||||
.map(|c| c.position)
|
||||
.map(|c| (c.position, c.position.add_vector(Vec3::new(0.0, SEAT, 0.0))))
|
||||
});
|
||||
if let Some(p) = at {
|
||||
if let Some((p, seat)) = 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.position = [seat.x, seat.y, seat.z];
|
||||
g.on_ground = false;
|
||||
return;
|
||||
}
|
||||
@@ -289,10 +289,11 @@ impl Game<'_> {
|
||||
b.previous_yaw = b.yaw;
|
||||
b.yaw = p.yaw;
|
||||
b.on_ground = p.on_ground;
|
||||
let seat = self.player_boat_seat(i);
|
||||
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];
|
||||
g.position = [seat.x, seat.y, seat.z];
|
||||
}
|
||||
|
||||
/// `handlePaddleBoat`: the paddles of the boat the player drives.
|
||||
|
||||
@@ -221,7 +221,7 @@ impl Game<'_> {
|
||||
/// holds it (`self.lan` is taken during the network tick).
|
||||
pub(super) fn enter_level(&mut self, host: Option<&mut LanHost>, target: Dim) -> Result<()> {
|
||||
// The in-frame game keeps its one level (`enter_dimension`).
|
||||
if self.options.dedicated.is_none() {
|
||||
if self.options.execution_role.is_client() {
|
||||
return Ok(());
|
||||
}
|
||||
if target != self.dim {
|
||||
@@ -261,7 +261,7 @@ impl Game<'_> {
|
||||
/// nobody is in it; returns the level to put back afterwards. Only a
|
||||
/// server holds other levels.
|
||||
pub(super) fn enter_command_level(&mut self, dim: Dim) -> Option<Dim> {
|
||||
if self.options.dedicated.is_none() || dim == self.dim {
|
||||
if self.options.execution_role.is_client() || dim == self.dim {
|
||||
return None;
|
||||
}
|
||||
let back = self.dim;
|
||||
@@ -296,7 +296,7 @@ impl Game<'_> {
|
||||
self.update_dedicated_streaming();
|
||||
self.update_world();
|
||||
result = if paused {
|
||||
self.tick_lan();
|
||||
self.tick_lan(true);
|
||||
Ok(())
|
||||
} else if k == 0 {
|
||||
self.tick()
|
||||
|
||||
@@ -95,7 +95,7 @@ fn display_offset(kind: CartKind) -> f64 {
|
||||
if kind == CartKind::Hopper { 1.0 } else { 6.0 }
|
||||
}
|
||||
/// The minecart's passenger attachment minus Player.DEFAULT_VEHICLE_ATTACHMENT.
|
||||
const SEAT: f64 = 0.1875 - 0.6;
|
||||
pub(super) const SEAT: f64 = 0.1875 - 0.6;
|
||||
/// The placeholder container key under which an open cart's items live.
|
||||
pub(super) const CART_KEY_Y: i32 = -100_000;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::Game;
|
||||
use horizon_client::{
|
||||
hud::HudAtlas,
|
||||
lang,
|
||||
pack_screen::{Compat, PackInfo, Source as PackSource},
|
||||
pack_screen::{self, Compat, PackInfo, Source as PackSource},
|
||||
packs::{self, Pack, Position},
|
||||
text::{Style, Text},
|
||||
};
|
||||
@@ -34,10 +34,16 @@ pub fn open_folder(dir: &Path) {
|
||||
log::warn!("Horizon: could not open {}: {e}", dir.display());
|
||||
}
|
||||
}
|
||||
/// The resource root to load from, given the vanilla root and the selection.
|
||||
pub fn startup_root(vanilla: &Path, selected: &[String]) -> std::io::Result<PathBuf> {
|
||||
/// The resource root to load from, given the vanilla root, the selection and
|
||||
/// a server's packs, which go on top (`Pack.Position.TOP`, fixed).
|
||||
pub fn startup_root(
|
||||
vanilla: &Path,
|
||||
selected: &[String],
|
||||
server: &[Pack],
|
||||
) -> std::io::Result<PathBuf> {
|
||||
let all = packs::discover(vanilla, &packs_dir());
|
||||
let enabled = packs::enabled(&all, selected);
|
||||
let mut enabled = packs::enabled(&all, selected);
|
||||
enabled.extend_from_slice(server);
|
||||
packs::resource_root(vanilla, &enabled, &cache_dir())
|
||||
}
|
||||
fn info(p: &Pack) -> PackInfo {
|
||||
@@ -53,14 +59,15 @@ fn info(p: &Pack) -> PackInfo {
|
||||
PackSource::Default
|
||||
},
|
||||
compat: p.compatibility,
|
||||
icon: p.icon,
|
||||
icon: p.icon_png().as_deref().and_then(pack_screen::load_icon),
|
||||
required: false,
|
||||
position: Position::Top,
|
||||
fixed: false,
|
||||
}
|
||||
}
|
||||
/// The vanilla pack: required, at the bottom (`BuiltInPackSource`).
|
||||
fn vanilla_info() -> PackInfo {
|
||||
/// The vanilla pack: required, at the bottom (`BuiltInPackSource`), with the
|
||||
/// client JAR's `pack.png` that the prepared resources hold.
|
||||
fn vanilla_info(vanilla: &Path) -> PackInfo {
|
||||
PackInfo {
|
||||
id: "vanilla".into(),
|
||||
title: Text::translate("resourcePack.vanilla.name", &[]),
|
||||
@@ -68,7 +75,10 @@ fn vanilla_info() -> PackInfo {
|
||||
description_style: Style::default(),
|
||||
source: PackSource::BuiltIn,
|
||||
compat: Compat::Compatible,
|
||||
icon: true,
|
||||
icon: std::fs::read(vanilla.join("pack.png"))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(pack_screen::load_icon),
|
||||
required: true,
|
||||
position: Position::Bottom,
|
||||
fixed: false,
|
||||
@@ -80,7 +90,7 @@ pub(super) fn pack_infos(vanilla: &Path) -> Vec<PackInfo> {
|
||||
.iter()
|
||||
.map(info)
|
||||
.collect();
|
||||
infos.push(vanilla_info());
|
||||
infos.push(vanilla_info(vanilla));
|
||||
infos
|
||||
}
|
||||
impl Game<'_> {
|
||||
@@ -113,14 +123,7 @@ impl Game<'_> {
|
||||
self.pending_pack_reload = true;
|
||||
return;
|
||||
}
|
||||
let vanilla = self.vanilla_root();
|
||||
if self.ui.vanilla_root.as_os_str().is_empty() {
|
||||
self.ui.vanilla_root = vanilla.clone();
|
||||
}
|
||||
let result = startup_root(&vanilla, &self.settings.resource_packs)
|
||||
.map_err(Into::into)
|
||||
.and_then(|root| self.reload_resources(&root));
|
||||
if let Err(e) = result {
|
||||
if let Err(e) = self.reload_selected_packs() {
|
||||
log::warn!("Horizon: resource reload failed: {e}");
|
||||
if self.screen != super::Screen::Options {
|
||||
self.open_options_at(
|
||||
@@ -131,6 +134,16 @@ impl Game<'_> {
|
||||
self.ui.options.resource_error = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
/// Reload from the selected packs and the server's.
|
||||
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
|
||||
let vanilla = self.vanilla_root();
|
||||
if self.ui.vanilla_root.as_os_str().is_empty() {
|
||||
self.ui.vanilla_root = vanilla.clone();
|
||||
}
|
||||
let server = &self.server_packs.active;
|
||||
let root = startup_root(&vanilla, &self.settings.resource_packs, server)?;
|
||||
self.reload_resources(&root)
|
||||
}
|
||||
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
|
||||
let render = std::sync::Arc::new(self.pack.assets.reload(root)?);
|
||||
let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?;
|
||||
|
||||
@@ -38,9 +38,16 @@ impl Game<'_> {
|
||||
m.extra.rider = Some(rider);
|
||||
}
|
||||
}
|
||||
/// Riders sit on their vehicles (`Entity.positionRider`), and let go when
|
||||
/// the vehicle dies or is gone.
|
||||
/// Riders sit on their vehicles (`Entity.positionRider`), mobs or boats,
|
||||
/// and let go when the vehicle dies or is gone.
|
||||
pub(super) fn sync_riders(&mut self) {
|
||||
// Entity.baseTick.
|
||||
for m in &mut self.mobs {
|
||||
if m.extra.boarding_cooldown > 0 {
|
||||
m.extra.boarding_cooldown -= 1;
|
||||
}
|
||||
}
|
||||
let boats: std::collections::HashSet<u32> = self.boats.iter().map(|b| b.id).collect();
|
||||
let index: HashMap<u32, usize> = self
|
||||
.mobs
|
||||
.iter()
|
||||
@@ -54,9 +61,10 @@ impl Game<'_> {
|
||||
};
|
||||
let valid = self.mobs[i].alive()
|
||||
&& !self.mobs[i].removed
|
||||
&& index
|
||||
.get(&v)
|
||||
.is_some_and(|&j| self.mobs[j].alive() && !self.mobs[j].removed);
|
||||
&& (boats.contains(&v)
|
||||
|| index
|
||||
.get(&v)
|
||||
.is_some_and(|&j| self.mobs[j].alive() && !self.mobs[j].removed));
|
||||
if valid {
|
||||
riding.insert(v, self.mobs[i].id);
|
||||
} else {
|
||||
@@ -68,6 +76,19 @@ impl Game<'_> {
|
||||
for m in &mut self.mobs {
|
||||
m.extra.rider = riding.get(&m.id).copied();
|
||||
}
|
||||
self.position_riders();
|
||||
}
|
||||
/// `Entity.positionRider` for every mob passenger: in its boat, then on
|
||||
/// its mob. A server's client places them itself too, as the server sends
|
||||
/// no passenger's position.
|
||||
pub(super) fn position_riders(&mut self) {
|
||||
self.seat_boat_mobs();
|
||||
let index: HashMap<u32, usize> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, m)| (m.id, i))
|
||||
.collect();
|
||||
// Riders of riders settle over a few passes.
|
||||
for _ in 0..3 {
|
||||
for i in 0..self.mobs.len() {
|
||||
|
||||
@@ -243,6 +243,8 @@ impl Game<'_> {
|
||||
.is_some_and(|(wood, chest)| self.boat_models.has(wood, chest)) =>
|
||||
{
|
||||
if let Some((position, velocity, yaw)) = entity_nbt::boat_from_nbt(&tag) {
|
||||
let mut tag = tag;
|
||||
let riders = self.take_mob_passengers(&mut tag);
|
||||
self.next_entity += 1;
|
||||
let id = self.next_entity;
|
||||
let (wood, chest) =
|
||||
@@ -263,6 +265,9 @@ impl Game<'_> {
|
||||
b.velocity = velocity;
|
||||
self.remember(id, uuid, tag);
|
||||
self.boats.push(b);
|
||||
for rider in riders {
|
||||
self.load_mob(rider, Some(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
k if super::minecarts::CartKind::from_id(k).is_some() => {
|
||||
@@ -341,25 +346,10 @@ impl Game<'_> {
|
||||
// Structure entities placed with finalizeEntities.
|
||||
let mut tag = tag;
|
||||
let mut finalize = false;
|
||||
let mut riders = Vec::new();
|
||||
let mut unknown = Vec::new();
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
finalize = c.remove(&"horizon:finalize".into()).is_some();
|
||||
if let Some(Tag::List(list)) = c.remove(&"Passengers".into()) {
|
||||
for p in list {
|
||||
let known =
|
||||
entity_nbt::kind(&p).is_some_and(|k| self.res.mob_types().contains_key(&k));
|
||||
if known {
|
||||
riders.push(p);
|
||||
} else {
|
||||
unknown.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !unknown.is_empty() {
|
||||
c.insert("Passengers".into(), Tag::List(unknown));
|
||||
}
|
||||
}
|
||||
let riders = self.take_mob_passengers(&mut tag);
|
||||
let mut m = entity_nbt::mob_from_nbt(&tag, self.res.mob_types(), self.next_entity)?;
|
||||
m.extra.mount = mount;
|
||||
m.extra.passenger = mount.is_some();
|
||||
@@ -401,6 +391,49 @@ impl Game<'_> {
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
/// The mobs under an entity's `Passengers`, taken out of it; the
|
||||
/// passengers Horizon doesn't simulate stay.
|
||||
fn take_mob_passengers(&self, tag: &mut Tag) -> Vec<Tag> {
|
||||
let mut riders = Vec::new();
|
||||
if let Tag::Compound(c) = tag
|
||||
&& let Some(Tag::List(list)) = c.remove(&"Passengers".into())
|
||||
{
|
||||
let mut unknown = Vec::new();
|
||||
for p in list {
|
||||
let known =
|
||||
entity_nbt::kind(&p).is_some_and(|k| self.res.mob_types().contains_key(&k));
|
||||
if known {
|
||||
riders.push(p);
|
||||
} else {
|
||||
unknown.push(p);
|
||||
}
|
||||
}
|
||||
if !unknown.is_empty() {
|
||||
c.insert("Passengers".into(), Tag::List(unknown));
|
||||
}
|
||||
}
|
||||
riders
|
||||
}
|
||||
/// The mobs riding entity `id` under `tag`'s `Passengers`, after the
|
||||
/// passengers it kept from loading.
|
||||
fn put_mob_passengers(&mut self, tag: &mut Tag, id: u32) {
|
||||
let riders: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&j| self.mobs[j].extra.mount == Some(id) && self.mobs[j].alive())
|
||||
.collect();
|
||||
let riders: Vec<Tag> = riders.into_iter().map(|j| self.mob_tree_nbt(j)).collect();
|
||||
if let Tag::Compound(c) = tag {
|
||||
let mut list = match c.get(&"Passengers".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
list.extend(riders);
|
||||
if list.is_empty() {
|
||||
c.remove(&"Passengers".into());
|
||||
} else {
|
||||
c.insert("Passengers".into(), Tag::List(list));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// An entity of a type Horizon does not simulate, kept as its compound.
|
||||
fn load_other(&mut self, tag: Tag, kind: &str) -> Option<u32> {
|
||||
let position = entity_nbt::position(&tag)?;
|
||||
@@ -476,22 +509,7 @@ impl Game<'_> {
|
||||
let uuid = self.uuid_for(id);
|
||||
let mut tag = entity_nbt::mob_to_nbt(&self.mobs[i], uuid, self.persist.raw.get(&id));
|
||||
self.write_leash(&mut tag, i);
|
||||
let riders: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&j| self.mobs[j].extra.mount == Some(id) && self.mobs[j].alive())
|
||||
.collect();
|
||||
let riders: Vec<Tag> = riders.into_iter().map(|j| self.mob_tree_nbt(j)).collect();
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
let mut list = match c.get(&"Passengers".into()) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
list.extend(riders);
|
||||
if list.is_empty() {
|
||||
c.remove(&"Passengers".into());
|
||||
} else {
|
||||
c.insert("Passengers".into(), Tag::List(list));
|
||||
}
|
||||
}
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
tag
|
||||
}
|
||||
/// Forget an entity that left the world (its UUID and loaded compound).
|
||||
@@ -569,10 +587,15 @@ impl Game<'_> {
|
||||
let mut out: HashMap<(i32, i32), Vec<Tag>> =
|
||||
keys.iter().map(|k| (*k, Vec::new())).collect();
|
||||
let player_uuid = self.level.player_uuid;
|
||||
// A rider is saved inside its root vehicle, in the root's chunk.
|
||||
// A rider is saved inside its root vehicle, in the root's chunk; a
|
||||
// boat's passengers inside the boat.
|
||||
let mobs: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&i| {
|
||||
self.mob_root(i) == i
|
||||
&& self.mobs[i]
|
||||
.extra
|
||||
.mount
|
||||
.is_none_or(|v| self.boats.iter().all(|b| b.id != v))
|
||||
&& keys.contains(&chunk_of(self.mobs[i].position))
|
||||
&& self.mobs[i].alive()
|
||||
})
|
||||
@@ -650,25 +673,29 @@ impl Game<'_> {
|
||||
.push(with_raw(tag, raw.as_ref()));
|
||||
}
|
||||
}
|
||||
for b in &self.boats {
|
||||
if keys.contains(&chunk_of(b.position)) {
|
||||
let uuid = self
|
||||
.persist
|
||||
.uuids
|
||||
.get(&b.id)
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid);
|
||||
let tag = entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist.raw.get(&b.id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
out.entry(chunk_of(b.position)).or_default().push(tag);
|
||||
}
|
||||
let boats: Vec<usize> = (0..self.boats.len())
|
||||
.filter(|&i| keys.contains(&chunk_of(self.boats[i].position)))
|
||||
.collect();
|
||||
for i in boats {
|
||||
let b = &self.boats[i];
|
||||
let (id, at) = (b.id, b.position);
|
||||
let uuid = self
|
||||
.persist
|
||||
.uuids
|
||||
.get(&id)
|
||||
.copied()
|
||||
.unwrap_or_else(random_uuid);
|
||||
let mut tag = entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
out.entry(chunk_of(at)).or_default().push(tag);
|
||||
}
|
||||
if let Some(d) = &self.dragon
|
||||
&& keys.contains(&chunk_of(d.position))
|
||||
@@ -833,9 +860,16 @@ impl Game<'_> {
|
||||
}
|
||||
keep
|
||||
});
|
||||
// Riders leave with their root vehicle.
|
||||
// Riders leave with their root vehicle, a boat's with the boat.
|
||||
let leaving: HashSet<u32> = (0..self.mobs.len())
|
||||
.filter(|&i| gone(self.mobs[self.mob_root(i)].position))
|
||||
.filter(|&i| {
|
||||
let r = self.mob_root(i);
|
||||
match self.mobs[r].extra.mount {
|
||||
Some(v) if removed.contains(&v) => true,
|
||||
Some(v) if self.boats.iter().any(|b| b.id == v) => false,
|
||||
_ => gone(self.mobs[r].position),
|
||||
}
|
||||
})
|
||||
.map(|i| self.mobs[i].id)
|
||||
.collect();
|
||||
self.mobs.retain(|m| {
|
||||
|
||||
@@ -6,7 +6,6 @@ use super::{Dim, Game};
|
||||
use horizon_client::mobs::villager as mobs_villager;
|
||||
use horizon_client::{
|
||||
effects::{EffectInstance, OmenTick},
|
||||
hud_overlays,
|
||||
mobs::{
|
||||
Mob, Species, raider,
|
||||
spawn_rules::{self, SpawnLevel},
|
||||
@@ -15,9 +14,9 @@ use horizon_client::{
|
||||
},
|
||||
patrol::{GameplayAttributes, Member, PatrolLevel, PatrolSpawner},
|
||||
raid::{self, Action, BarName, RaidLevel, RaidPlayer, RaiderInfo, RaiderType, Raids, Spawn},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// Patrol and raid state owned by the game.
|
||||
@@ -1063,37 +1062,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The raid boss bars: red and notched in tens, with the raid's title.
|
||||
pub(super) fn raid_bars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
|
||||
for raid in self.raiding.raids.map.values() {
|
||||
if !raid.bar.visible || !raid.bar.player || raid.is_stopped() {
|
||||
continue;
|
||||
}
|
||||
let title = self.translate("event.minecraft.raid", "Raid");
|
||||
let name = match raid.bar.name {
|
||||
BarName::Raid => title,
|
||||
BarName::Remaining(n) => format!(
|
||||
"{title} - {}",
|
||||
self.translate(
|
||||
"event.minecraft.raid.raiders_remaining",
|
||||
"Raiders Remaining: %s"
|
||||
)
|
||||
.replace("%s", &n.to_string())
|
||||
),
|
||||
BarName::Victory => {
|
||||
self.translate("event.minecraft.raid.victory.full", "Raid - Victory")
|
||||
}
|
||||
BarName::Defeat => {
|
||||
self.translate("event.minecraft.raid.defeat.full", "Raid - Defeat")
|
||||
}
|
||||
};
|
||||
bars.push(hud_overlays::BossBar {
|
||||
name: Text::literal(name),
|
||||
progress: raid.bar.progress,
|
||||
color: 2,
|
||||
overlay: 2,
|
||||
});
|
||||
/// The raid bar a living player at `at` is in (`Raid.getPlayers`): red,
|
||||
/// notched in tens. The name is the translate component the client draws.
|
||||
pub(super) fn raid_boss_at(&self, at: [f64; 3], alive: bool) -> Option<(i32, Tag, f32)> {
|
||||
if !alive || self.dim != Dim::Overworld {
|
||||
return None;
|
||||
}
|
||||
let block = block_of(Vec3::new(at[0], at[1], at[2]));
|
||||
let (id, raid) = self.raiding.raids.nearby(block)?;
|
||||
if !raid.bar.visible || raid.is_stopped() {
|
||||
return None;
|
||||
}
|
||||
Some((id, raid_name_tag(raid.bar.name), raid.bar.progress))
|
||||
}
|
||||
/// A firework a villager launches to celebrate (`CelebrateVillagersSurvivedRaid`).
|
||||
pub(super) fn villager_firework(&mut self, at: [f64; 3], color: usize, flight: u8) {
|
||||
@@ -1239,6 +1219,36 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Raid.raidEvent`'s name: the raid title, the remaining-raiders line, or
|
||||
/// the victory and defeat lines.
|
||||
fn raid_name_tag(name: BarName) -> Tag {
|
||||
let key = |k: &str| Tag::compound([("translate", Tag::String(k.into()))]);
|
||||
match name {
|
||||
BarName::Raid => key("event.minecraft.raid"),
|
||||
BarName::Victory => key("event.minecraft.raid.victory.full"),
|
||||
BarName::Defeat => key("event.minecraft.raid.defeat.full"),
|
||||
BarName::Remaining(n) => Tag::compound([
|
||||
("translate", Tag::String("event.minecraft.raid".into())),
|
||||
(
|
||||
"extra",
|
||||
Tag::List(vec![
|
||||
Tag::String(" - ".into()),
|
||||
Tag::compound([
|
||||
(
|
||||
"translate",
|
||||
Tag::String("event.minecraft.raid.raiders_remaining".into()),
|
||||
),
|
||||
(
|
||||
"with",
|
||||
Tag::List(vec![Tag::String(n.to_string().as_str().into())]),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -140,9 +140,13 @@ pub(super) struct Remote {
|
||||
stepped: HashMap<i32, SteppedInterpolation>,
|
||||
/// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it.
|
||||
head_lerps: HashMap<i32, (f32, u32)>,
|
||||
/// Item entities waiting for their item data.
|
||||
pending_items: HashMap<i32, [f64; 3]>,
|
||||
/// Item entities waiting for their item data: where they are and their
|
||||
/// motion.
|
||||
pending_items: HashMap<i32, ([f64; 3], Vec3)>,
|
||||
health: (f32, i32, f32),
|
||||
/// `LocalPlayer.flashOnSetHealth`: set by the first health the server
|
||||
/// sends; from then on a drop starts the hurt animation.
|
||||
flash_on_set_health: bool,
|
||||
game_mode: i32,
|
||||
pub chat: Vec<(String, Instant)>,
|
||||
/// Lines received since joining, for the control socket's events.
|
||||
@@ -305,6 +309,16 @@ impl Game<'_> {
|
||||
// A signed-in account always plays under its own name.
|
||||
let auth = horizon_client::auth::current();
|
||||
let name = auth.as_ref().map_or(name, |a| a.name.as_str());
|
||||
let user = auth.as_ref().map_or_else(
|
||||
|| {
|
||||
(
|
||||
name.to_owned(),
|
||||
horizon_protocol::crypto::offline_uuid(name),
|
||||
)
|
||||
},
|
||||
|a| (a.name.clone(), a.uuid),
|
||||
);
|
||||
self.server_packs_connect(local.is_none().then_some(address), user);
|
||||
let session = match local {
|
||||
Some(pipe) => Session::connect_local(pipe, name, view, lang.clone()),
|
||||
None => {
|
||||
@@ -369,6 +383,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.apply_settings();
|
||||
self.clear_waypoints();
|
||||
self.boss_overlay.reset();
|
||||
self.remote = Some(Remote {
|
||||
session,
|
||||
brand: "unknown".into(),
|
||||
@@ -401,6 +416,7 @@ impl Game<'_> {
|
||||
head_lerps: HashMap::new(),
|
||||
pending_items: HashMap::new(),
|
||||
health: (20.0, 20, 5.0),
|
||||
flash_on_set_health: false,
|
||||
game_mode: 0,
|
||||
chat: Vec::new(),
|
||||
chat_received: 0,
|
||||
@@ -450,9 +466,11 @@ impl Game<'_> {
|
||||
self.extras_left();
|
||||
}
|
||||
self.clear_waypoints();
|
||||
self.boss_overlay.reset();
|
||||
if let Some(mut r) = self.remote.take() {
|
||||
r.session.disconnect();
|
||||
}
|
||||
self.server_packs_disconnect();
|
||||
// Single player's server thread saves the world before the title shows.
|
||||
let single_player = match self.integrated.take() {
|
||||
Some(server) => {
|
||||
@@ -596,6 +614,21 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
let value = match c {
|
||||
// Read past, not decoded: the item keeps its prototype's value.
|
||||
Component::Raw(_) => continue,
|
||||
// Trim materials and patterns are synced registries, as enchantments are.
|
||||
Component::Trim { material, pattern } => {
|
||||
let (Some(material), Some(pattern)) = (
|
||||
r.registries.name("minecraft:trim_material", *material),
|
||||
r.registries.name("minecraft:trim_pattern", *pattern),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
Tag::compound([
|
||||
("material", Tag::String(material.into())),
|
||||
("pattern", Tag::String(pattern.into())),
|
||||
])
|
||||
}
|
||||
Component::Int(v) if *kind == component::RARITY => {
|
||||
let rarity = ["common", "uncommon", "rare", "epic"];
|
||||
Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into())
|
||||
@@ -722,6 +755,9 @@ impl Game<'_> {
|
||||
self.ticks,
|
||||
self.screen == Screen::Chat,
|
||||
);
|
||||
if self.mods.running() {
|
||||
self.mods_chat(&text);
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.chat.push((text, Instant::now()));
|
||||
r.chat_received += 1;
|
||||
@@ -818,7 +854,11 @@ impl Game<'_> {
|
||||
r.joined = true;
|
||||
r.profile = uuid;
|
||||
r.profile_name = name.clone();
|
||||
r.status = format!("Joined as {name}; loading terrain...");
|
||||
// LevelLoadingScreen's line: no player name.
|
||||
r.status = horizon_client::lang::get_or(
|
||||
"multiplayer.downloadingTerrain",
|
||||
"Loading terrain...",
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::Brand(brand) => {
|
||||
@@ -827,6 +867,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Event::Reconfigured(registries) => self.set_registries(registries),
|
||||
Event::PackPush(p) => self.handle_pack_push(p),
|
||||
Event::PackPop(id) => self.handle_pack_pop(id),
|
||||
Event::DecodeError(e) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.decode_errors += 1;
|
||||
@@ -1369,7 +1411,7 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
if kind == "minecraft:item" {
|
||||
r.pending_items.insert(id, position);
|
||||
r.pending_items.insert(id, (position, Vec3::ZERO));
|
||||
}
|
||||
r.entities.insert(
|
||||
id,
|
||||
@@ -1448,6 +1490,23 @@ impl Game<'_> {
|
||||
m.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
if let Some(p) = r.pending_items.get_mut(&id) {
|
||||
p.0 = position;
|
||||
}
|
||||
// ItemEntity and FallingBlockEntity keep Entity's NO_OP
|
||||
// InterpolationHandler: moveOrInterpolateTo puts them there at
|
||||
// once, and their own tick moves them on between the server's
|
||||
// updates (`tick_remote_entities`).
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
|
||||
d.position = at;
|
||||
d.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
|
||||
f.position = at;
|
||||
return;
|
||||
}
|
||||
// AbstractInterpolationHandler.interpolateTo: without a rotation,
|
||||
// the one it was heading for.
|
||||
let rotation = rotation.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation));
|
||||
@@ -1459,11 +1518,9 @@ impl Game<'_> {
|
||||
steps: 3,
|
||||
},
|
||||
);
|
||||
if let Some(p) = r.pending_items.get_mut(&id) {
|
||||
*p = position;
|
||||
}
|
||||
}
|
||||
self.turn_remote_object(id, rotation.map(|(yaw, _)| yaw), on_ground);
|
||||
self.move_remote_text_display(id, position, rotation);
|
||||
self.turn_remote_projectile(id, rotation);
|
||||
}
|
||||
fn remove_entity(&mut self, id: i32) {
|
||||
@@ -1569,6 +1626,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Find a game slot in the menu's complete, contiguous slot mapping.
|
||||
fn menu_slot_index(menu: &str, internal: usize) -> Option<usize> {
|
||||
(0..)
|
||||
.map_while(|slot| Self::menu_slot(menu, slot))
|
||||
.position(|slot| slot == internal)
|
||||
}
|
||||
/// The workstation screen of a menu type a server opens.
|
||||
pub(super) fn remote_station_kind(menu: &str) -> Option<super::stations::Kind> {
|
||||
use super::stations::Kind;
|
||||
@@ -1773,6 +1836,9 @@ impl Game<'_> {
|
||||
self.audio.play(&name, Some(at), p.volume, p.pitch);
|
||||
}
|
||||
}
|
||||
C::StopSound(p) => self
|
||||
.audio
|
||||
.stop_sounds(p.sound.as_deref(), p.source.map(|s| s as usize)),
|
||||
C::AddEntity(p) => {
|
||||
let kind = self
|
||||
.remote
|
||||
@@ -1794,6 +1860,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.spawn_entity(p.id, &kind, p.position, rotation);
|
||||
// Entity.recreateFromPacket: setDeltaMovement(packet.getMovement()).
|
||||
self.remote_motion(p.id, p.velocity);
|
||||
if !self.spawn_remote_object(p.id, &kind, p.position, rotation[0], p.data) {
|
||||
self.spawn_remote_projectile(
|
||||
p.id,
|
||||
@@ -1804,6 +1872,7 @@ impl Game<'_> {
|
||||
p.data,
|
||||
);
|
||||
}
|
||||
self.move_remote_text_display(p.id, p.position, Some((rotation[0], rotation[1])));
|
||||
}
|
||||
C::RemoveEntities(p) => {
|
||||
for id in p.ids {
|
||||
@@ -2112,17 +2181,29 @@ impl Game<'_> {
|
||||
pl.velocity = Vec3::new(p.velocity[0], p.velocity[1], p.velocity[2]);
|
||||
}
|
||||
}
|
||||
// The same packet for another entity: Entity.lerpMotion.
|
||||
C::SetEntityMotion(p) => self.remote_motion(p.id, p.velocity),
|
||||
// ClientPacketListener.handleDamageEvent: LivingEntity.handleDamageEvent
|
||||
// jolts the walk animation and restarts the hurt flash. Its hurt
|
||||
// sound comes as a sound from Horizon's host, which plays it itself.
|
||||
// This player's own hurt tilt and flash come from the server too
|
||||
// (sent to the player itself): its own moves never hurt it.
|
||||
C::DamageEvent(p) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.health.animate_hurt();
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.limb_speed = 1.5;
|
||||
m.hurt_time = 10;
|
||||
}
|
||||
}
|
||||
C::HurtAnimation(p) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.health.animate_hurt();
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.hurt_time = 10;
|
||||
}
|
||||
}
|
||||
@@ -2188,9 +2269,14 @@ impl Game<'_> {
|
||||
self.remote_world_options_difficulty(self.ui.difficulty, p.locked);
|
||||
}
|
||||
C::GameRuleValues(p) => self.remote_world_options_rules(p.entries),
|
||||
// ClientPacketListener.handleSetHealth: LocalPlayer.hurtTo.
|
||||
C::SetHealth(p) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
let flash = self.remote.as_mut().is_some_and(|r| {
|
||||
r.health = (p.health, p.food, p.saturation);
|
||||
std::mem::replace(&mut r.flash_on_set_health, true)
|
||||
});
|
||||
if flash && let Some(pl) = self.player.as_mut() {
|
||||
pl.health.flash_health_change(p.health);
|
||||
}
|
||||
self.apply_remote_health();
|
||||
if p.health <= 0.0 && self.screen != Screen::Dead && self.player.is_some() {
|
||||
@@ -2640,6 +2726,7 @@ impl Game<'_> {
|
||||
}
|
||||
// ClientPacketListener.setActionBarText, setTitleText,
|
||||
// setSubtitleText, setTitlesAnimation and handleTitlesClear.
|
||||
C::BossEvent(p) => self.apply_boss_event(&p),
|
||||
C::SetActionBarText(p) => self.titles.set_overlay(remote_text(&p.text), false),
|
||||
C::SetTitleText(p) => self.titles.set_title(remote_text(&p.text)),
|
||||
C::SetSubtitleText(p) => self.titles.set_subtitle(remote_text(&p.text)),
|
||||
@@ -2920,7 +3007,9 @@ impl Game<'_> {
|
||||
item.stack = stack;
|
||||
return;
|
||||
}
|
||||
let Some(p) = position else { return };
|
||||
let Some((p, velocity)) = position else {
|
||||
return;
|
||||
};
|
||||
let at = Vec3::new(p[0], p[1], p[2]);
|
||||
let bob_offset = self.world.random.next_float() * std::f32::consts::PI * 2.0;
|
||||
self.dropped.push(ItemEntity {
|
||||
@@ -2931,7 +3020,7 @@ impl Game<'_> {
|
||||
stack,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
velocity,
|
||||
on_ground: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
@@ -2939,6 +3028,23 @@ impl Game<'_> {
|
||||
ticks: 0,
|
||||
});
|
||||
}
|
||||
/// `Entity.lerpMotion` (and `recreateFromPacket`'s motion) for the
|
||||
/// entities whose own tick moves them on a client: dropped items and
|
||||
/// falling blocks.
|
||||
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
|
||||
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
|
||||
d.velocity = v;
|
||||
} else if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) {
|
||||
f.velocity = v;
|
||||
} else if let Some(p) = self
|
||||
.remote
|
||||
.as_mut()
|
||||
.and_then(|r| r.pending_items.get_mut(&id))
|
||||
{
|
||||
p.1 = v;
|
||||
}
|
||||
}
|
||||
fn apply_remote_health(&mut self) {
|
||||
let Some((health, food, saturation)) = self.remote.as_ref().map(|r| r.health) else {
|
||||
return;
|
||||
@@ -2994,6 +3100,8 @@ impl Game<'_> {
|
||||
}
|
||||
None => {
|
||||
let mut player = PlayerMotion::new(self.motion, position)?;
|
||||
// Player.tick derives noPhysics from the current game mode.
|
||||
player.spectator = self.spectator();
|
||||
player.velocity = delta;
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
@@ -3028,6 +3136,8 @@ impl Game<'_> {
|
||||
/// One client tick while connected (Minecraft.tick with a ClientLevel).
|
||||
pub(super) fn tick_remote(&mut self) -> Result<()> {
|
||||
self.tick_remote_level()?;
|
||||
// Client mods tick on any server, single player's own included.
|
||||
self.tick_mods();
|
||||
// Minecraft.tick sends this even when the player cannot tick yet,
|
||||
// including while waiting for chunks. Keep it outside early returns.
|
||||
if self.remote.as_ref().is_some_and(|r| r.joined) {
|
||||
@@ -3053,6 +3163,7 @@ impl Game<'_> {
|
||||
// Minecraft.tick: TickRateManager.tick; ClientLevel.tickTime advances
|
||||
// the time only while the level runs normally.
|
||||
let running = self.clock.world_runs();
|
||||
self.boss_overlay.tick_darken(running);
|
||||
if running {
|
||||
self.world.game_time += 1;
|
||||
self.remote_clock_tick();
|
||||
@@ -3136,6 +3247,9 @@ impl Game<'_> {
|
||||
| u8::from(keys.sprint) << 6;
|
||||
// A passenger moves with its vehicle (Entity.rideTick), not by itself.
|
||||
let riding = self.screen != Screen::Dead && self.remote_ride(keys);
|
||||
// The server sends no passenger's position: they sit where their
|
||||
// vehicles are now.
|
||||
self.position_riders();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -3163,6 +3277,7 @@ impl Game<'_> {
|
||||
);
|
||||
player.velocity = Vec3::ZERO;
|
||||
} else if !dead && !riding {
|
||||
player.client_side = true;
|
||||
player.velocity = player.velocity.add_vector(pushed);
|
||||
MotionWorld {
|
||||
entities: &entity_collisions,
|
||||
@@ -3206,7 +3321,7 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// The server owns health and hunger: undo local food/fall changes.
|
||||
// The server owns health and hunger.
|
||||
self.apply_remote_health();
|
||||
self.tick_avatar(before, false);
|
||||
// The player's own model (third person, the inventory's preview).
|
||||
@@ -3246,9 +3361,10 @@ impl Game<'_> {
|
||||
player.flying_speed = a.flying_speed;
|
||||
}
|
||||
/// The local player's share of every touching entity's
|
||||
/// `LivingEntity.pushEntities`: on a client `EntitySelector.pushableBy`
|
||||
/// lets entities push only the local player, so the server never sends
|
||||
/// these pushes. `doPush(player)` is `player.push(entity)`.
|
||||
/// `LivingEntity.pushEntities`, and of the boats' pushes: on a client
|
||||
/// `EntitySelector.pushableBy` lets entities push only the local player,
|
||||
/// so the server never sends these pushes. `doPush(player)` is
|
||||
/// `player.push(entity)`.
|
||||
fn remote_pushes(&self) -> Vec3 {
|
||||
let (Some(p), Some(r)) = (self.player.as_ref(), self.remote.as_ref()) else {
|
||||
return Vec3::ZERO;
|
||||
@@ -3307,11 +3423,54 @@ impl Game<'_> {
|
||||
za *= f64::from(0.05_f32);
|
||||
push = Vec3::new(push.x - xa, push.y, push.z - za);
|
||||
}
|
||||
// AbstractBoat.push(player) from the boat's own tick (its box grown
|
||||
// by 0.2 sideways) and from the player's pushEntities (its box).
|
||||
let player_team = teams.of(Who::Player);
|
||||
let area = Vec3::new(f64::from(0.2_f32), f64::from(-0.01_f32), f64::from(0.2_f32));
|
||||
for b in self.boats.iter().filter(|b| self.riding != Some(b.id)) {
|
||||
let bb = b.bounds();
|
||||
let times = usize::from(
|
||||
bounds.intersects(bb.inflate(area))
|
||||
&& horizon_client::scoreboard::pushable_by(None, player_team, true),
|
||||
) + usize::from(
|
||||
bounds.intersects(bb)
|
||||
&& horizon_client::scoreboard::pushable_by(player_team, None, true),
|
||||
);
|
||||
if times > 0
|
||||
&& super::boat_passengers::boat_pushes(bb, bounds, false)
|
||||
&& let Some(v) = super::boat_passengers::push_impulse(b.position, p.position)
|
||||
{
|
||||
push = push.add_vector(v.scale(times as f64));
|
||||
}
|
||||
}
|
||||
push
|
||||
}
|
||||
/// ClientLevel.tickEntities; a frozen level only ticks the players
|
||||
/// (`TickRateManager.isEntityFrozen`).
|
||||
fn tick_remote_entities(&mut self, running: bool) {
|
||||
if running {
|
||||
self.tick_text_displays();
|
||||
}
|
||||
// EntityFluidInteraction.update for the entities this window only displays: the
|
||||
// server never sends the fluid contact, and the renderers read it (cod, salmon and
|
||||
// tropical fish lie on their side without `in_water`, #1591).
|
||||
// A frozen level ticks only the players, as below.
|
||||
let fluids: Vec<_> = {
|
||||
let w = self.mob_world_base(&[]);
|
||||
let players = self.remote.as_ref().map(|r| &r.level_players);
|
||||
self.mobs
|
||||
.iter()
|
||||
.map(|m| {
|
||||
(running || players.is_some_and(|p| p.contains_key(&(m.id as i32))))
|
||||
.then(|| w.fluids(m.bounds(), m.eye_y()))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for (m, fluids) in self.mobs.iter_mut().zip(fluids) {
|
||||
if let Some(fluids) = fluids {
|
||||
m.fluids = fluids;
|
||||
}
|
||||
}
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -3414,20 +3573,12 @@ impl Game<'_> {
|
||||
l.steps -= 1;
|
||||
}
|
||||
}
|
||||
// ItemEntity.tick runs on a client too: gravity, collisions and
|
||||
// friction move the item between the server's updates (every 20
|
||||
// ticks), and only the server removes it.
|
||||
let (world, facts, collision) = (&self.world, self.res.facts(), self.collision);
|
||||
for d in self.dropped.iter_mut().filter(|_| running) {
|
||||
d.previous = d.position;
|
||||
d.age += 1;
|
||||
if let Some(l) = lerps.get_mut(&(d.id as i32))
|
||||
&& l.steps > 0
|
||||
{
|
||||
let n = f64::from(l.steps);
|
||||
d.position = Vec3::new(
|
||||
d.position.x + (l.target[0] - d.position.x) / n,
|
||||
d.position.y + (l.target[1] - d.position.y) / n,
|
||||
d.position.z + (l.target[2] - d.position.z) / n,
|
||||
);
|
||||
l.steps -= 1;
|
||||
}
|
||||
d.tick(world, facts, collision, false);
|
||||
}
|
||||
head_lerps.retain(|_, (_, steps)| *steps > 0);
|
||||
for (item, ticks, at, eye, pitch, yaw) in using {
|
||||
@@ -3862,11 +4013,17 @@ impl Game<'_> {
|
||||
self.predict_item_on_block(stack, &hit);
|
||||
}
|
||||
if held_block {
|
||||
if reacts
|
||||
|| held
|
||||
.as_ref()
|
||||
.is_some_and(|stack| self.remote_can_place(&hit, stack))
|
||||
{
|
||||
let placed = if reacts {
|
||||
None
|
||||
} else {
|
||||
held.as_ref()
|
||||
.and_then(|stack| self.remote_can_place(&hit, stack))
|
||||
};
|
||||
if let Some(placed) = &placed {
|
||||
self.predict_place(placed, seq);
|
||||
self.with_hand(hand, Game::consume_held);
|
||||
}
|
||||
if reacts || placed.is_some() {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
}
|
||||
// BlockItem.place returns FAIL when placement is rejected:
|
||||
@@ -3926,18 +4083,7 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
for &(p, state) in changes {
|
||||
let pos = [p.x, p.y, p.z];
|
||||
let before = self.world.block(p.x, p.y, p.z);
|
||||
// BlockStatePredictionHandler.retainKnownServerState.
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.predictions
|
||||
.entry(pos)
|
||||
.and_modify(|e| e.0 = seq)
|
||||
.or_insert((seq, before.0 as i32));
|
||||
}
|
||||
self.set_block(pos, state.0 as i32);
|
||||
}
|
||||
self.predict_blocks(changes, seq);
|
||||
// `level.playSound(player, ...)`: the clicker hears it from its own use; the
|
||||
// server sends it to the others only.
|
||||
for (pos, sound, volume, pitch) in sounds {
|
||||
@@ -3997,12 +4143,45 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Run BlockItem.place on a disposable level before predicting its swing.
|
||||
/// The real level supplies collision shapes and building-blocking entities;
|
||||
/// block writes, random draws, scheduled ticks and events stay in the preview.
|
||||
fn remote_can_place(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
|
||||
/// MultiPlayerGameMode.useItemOn inside startPrediction, for a block item:
|
||||
/// the blocks `BlockItem.place` set at `pos` are placed on this client's
|
||||
/// level at once, so the next click of a fast bridge aims at them rather
|
||||
/// than past them (the server would refuse it). Its place sound is the
|
||||
/// placer's own (`playSound(player, ...)`: the server sends it to the
|
||||
/// others only).
|
||||
fn predict_place(&mut self, (pos, changes): &DryPlace, seq: i32) {
|
||||
let ticks = self.ticks;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
// set_block plays the place sound of the block at `placing`.
|
||||
r.placing = Some(([pos.x, pos.y, pos.z], ticks));
|
||||
}
|
||||
self.predict_blocks(changes, seq);
|
||||
}
|
||||
/// The blocks a use changed, set on this client's level and kept as
|
||||
/// predicted under the use's sequence until the server acknowledges it
|
||||
/// (`BlockStatePredictionHandler.retainKnownServerState`): a use the
|
||||
/// server refused goes back.
|
||||
fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) {
|
||||
for &(p, state) in changes {
|
||||
let pos = [p.x, p.y, p.z];
|
||||
let before = self.world.block(p.x, p.y, p.z);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.predictions
|
||||
.entry(pos)
|
||||
.and_modify(|e| e.0 = seq)
|
||||
.or_insert((seq, before.0 as i32));
|
||||
}
|
||||
self.set_block(pos, state.0 as i32);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run BlockItem.place on a disposable level: where it placed and the
|
||||
/// blocks it set. The real level supplies collision shapes and
|
||||
/// building-blocking entities; random draws, scheduled ticks and events
|
||||
/// stay in the preview.
|
||||
fn remote_can_place(&mut self, hit: &super::Hit, stack: &ItemStack) -> Option<DryPlace> {
|
||||
if !self.may_build() {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
let ctx = horizon_world::level::PlaceContext {
|
||||
clicked_pos: horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
|
||||
@@ -4017,14 +4196,16 @@ impl Game<'_> {
|
||||
};
|
||||
self.with_level(|b, level, _| {
|
||||
let mut level = DryLevel::new(level);
|
||||
matches!(
|
||||
b.place_block(
|
||||
&mut level,
|
||||
&mut horizon_world::level::Ticks::default(),
|
||||
&ctx
|
||||
),
|
||||
horizon_world::level::PlaceOutcome::Placed { .. }
|
||||
)
|
||||
match b.place_block(
|
||||
&mut level,
|
||||
&mut horizon_world::level::Ticks::default(),
|
||||
&ctx,
|
||||
) {
|
||||
horizon_world::level::PlaceOutcome::Placed { pos, .. } => {
|
||||
Some((pos, level.changed.into_iter().collect()))
|
||||
}
|
||||
horizon_world::level::PlaceOutcome::Failed => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4126,11 +4307,13 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let stack = self.stack_in(slot).cloned();
|
||||
// Another hotbar slot picked: LivingEntity.updatingUsingItem sees a
|
||||
// different stack in the hand. The off hand keeps its item.
|
||||
let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT;
|
||||
// A drawn bow, a charging crossbow, a raised shield...: held until
|
||||
// the button lets go (or another slot is picked).
|
||||
// the button lets go.
|
||||
if stack.as_ref().is_some_and(|s| self.held_until_released(s)) {
|
||||
self.using = (slot == self.selected || slot == super::shield::OFFHAND_SLOT)
|
||||
.then_some((slot, ticks + 1));
|
||||
self.using = in_hand.then_some((slot, ticks + 1));
|
||||
return;
|
||||
}
|
||||
let consumable = stack
|
||||
@@ -4144,7 +4327,7 @@ impl Game<'_> {
|
||||
// Button release belongs to the next handleKeybinds phase, which
|
||||
// sends RELEASE_USE_ITEM. Do not silently clear a use begun by a
|
||||
// quick press/release between ticks.
|
||||
if slot != self.selected {
|
||||
if !in_hand {
|
||||
self.using = None;
|
||||
return;
|
||||
}
|
||||
@@ -4465,12 +4648,10 @@ impl Game<'_> {
|
||||
for &(slot, button, input) in ops {
|
||||
let slot = match slot {
|
||||
None => -999,
|
||||
Some(internal) => {
|
||||
match (0..64).find(|&s| Self::menu_slot(&menu, s) == Some(internal)) {
|
||||
Some(s) => s as i16,
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
Some(internal) => match Self::menu_slot_index(&menu, internal) {
|
||||
Some(s) => s as i16,
|
||||
None => return false,
|
||||
},
|
||||
};
|
||||
clicks.push((slot, button, input));
|
||||
}
|
||||
@@ -4539,7 +4720,7 @@ impl Game<'_> {
|
||||
/// such slot.
|
||||
pub(super) fn remote_slot_click(&mut self, internal: usize, button: i8, input: i32) -> bool {
|
||||
let (container, menu) = self.remote_menu();
|
||||
let slot = (0..64).find(|&s| Self::menu_slot(&menu, s) == Some(internal));
|
||||
let slot = Self::menu_slot_index(&menu, internal);
|
||||
let Some(slot) = slot else { return false };
|
||||
let carried = self.to_network(&self.carried).map(|s| Hashed::of(&s));
|
||||
let state = self.remote.as_ref().map_or(0, |r| r.state_id);
|
||||
@@ -4974,6 +5155,8 @@ impl Game<'_> {
|
||||
.collect::<Vec<_>>(),
|
||||
// The player's own model exists (third person and the inventory draw it).
|
||||
"player_model": self.player_model.is_some(),
|
||||
"text_display_vertices": self.text_display_vertices,
|
||||
"text_displays": self.text_displays.len(),
|
||||
"health": r.health.0,
|
||||
"food": r.health.1,
|
||||
"decode_errors": r.decode_errors,
|
||||
@@ -5073,11 +5256,12 @@ type DrySound = (
|
||||
f32,
|
||||
f32,
|
||||
);
|
||||
/// The blocks a use on the dry level set.
|
||||
type DryChanges = Vec<(horizon_core::position::BlockPos, BlockStateId)>;
|
||||
/// What a use on the dry level changed, and the sounds it played.
|
||||
type DryUse = (
|
||||
Vec<(horizon_core::position::BlockPos, BlockStateId)>,
|
||||
Vec<DrySound>,
|
||||
);
|
||||
type DryUse = (DryChanges, Vec<DrySound>);
|
||||
/// Where `BlockItem.place` on the dry level placed, and the blocks it set.
|
||||
type DryPlace = (horizon_core::position::BlockPos, DryChanges);
|
||||
|
||||
/// The blocks a guest's hand flips that its use predicts (and hears at once): a wooden or
|
||||
/// copper door, trapdoor or fence gate, a lever or a button. An iron door or trapdoor has
|
||||
@@ -5398,4 +5582,28 @@ mod tests {
|
||||
}
|
||||
assert_eq!(Game::menu_slot("inventory", 46), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_clicks_find_slots_past_the_old_large_chest_cutoff() {
|
||||
for (menu, internal, index) in [
|
||||
("minecraft:generic_9x6", 18, 63),
|
||||
("minecraft:generic_9x6", 19, 64),
|
||||
("minecraft:generic_9x6", 0, 81),
|
||||
("minecraft:generic_9x6", 8, 89),
|
||||
("minecraft:generic_9x6", 353, 53),
|
||||
("minecraft:generic_9x5", 8, 80),
|
||||
("minecraft:generic_9x4", 8, 71),
|
||||
("minecraft:generic_9x3", 8, 62),
|
||||
("inventory", 504, 45),
|
||||
] {
|
||||
assert_eq!(
|
||||
Game::menu_slot_index(menu, internal),
|
||||
Some(index),
|
||||
"{menu}, game slot {internal}"
|
||||
);
|
||||
}
|
||||
assert_eq!(Game::menu_slot_index("minecraft:generic_9x6", 354), None);
|
||||
assert_eq!(Game::menu_slot_index("minecraft:generic_9x6", 504), None);
|
||||
assert_eq!(Game::menu_slot_index("unknown", 0), None);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,15 @@
|
||||
//! `onSyncedDataUpdated`): what a client draws from it rather than from its
|
||||
//! own simulation, which a client of a server doesn't run.
|
||||
use super::Game;
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
use horizon_client::mobs::{Mob, Species, tame};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use horizon_protocol::registry::Builtin;
|
||||
|
||||
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
|
||||
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
|
||||
/// `builtin` names the ids of built-in registries.
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
|
||||
let species = m.species();
|
||||
let baby = super::lan::baby_index(&m.kind.id);
|
||||
for (index, _, value) in values {
|
||||
@@ -42,16 +45,45 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
|
||||
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
|
||||
m.extra.size = (*size).clamp(1, 127);
|
||||
}
|
||||
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after
|
||||
// Zombie's three): what VillagerProfessionLayer draws.
|
||||
(Species::Villager, 19, Value::Ids(data))
|
||||
| (Species::ZombieVillager, 20, Value::Ids(data)) => villager_data(m, data, builtin),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// VillagerData as the network sends it: the type and the profession by
|
||||
/// their built-in registry ids, then the level.
|
||||
fn villager_data(m: &mut Mob, data: &[i32], builtin: &Builtin) {
|
||||
let &[kind, profession, level] = data else {
|
||||
return;
|
||||
};
|
||||
let index = |registry: &str, id: i32, names: &[&str]| {
|
||||
let name = builtin
|
||||
.get(registry)?
|
||||
.name(id)?
|
||||
.strip_prefix("minecraft:")?;
|
||||
names.iter().position(|n| *n == name).map(|i| i as u8)
|
||||
};
|
||||
if let Some(t) = index("minecraft:villager_type", kind, &VILLAGER_TYPES) {
|
||||
m.extra.variant = t;
|
||||
}
|
||||
if let Some(p) = index("minecraft:villager_profession", profession, &PROFESSIONS) {
|
||||
m.extra.variant2 = p;
|
||||
}
|
||||
m.extra.variant3 = level.clamp(0, i32::from(u8::MAX)) as u8;
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// SetEntityData for a mob of the server's.
|
||||
pub(super) fn remote_mob_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
let Some(r) = &self.remote else {
|
||||
return;
|
||||
};
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
apply(m, values);
|
||||
apply(m, values, &r.builtin);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,11 +104,23 @@ mod tests {
|
||||
|
||||
/// A mob of the prepared resources' type `kind`, as a client spawns it
|
||||
/// (None without the resources).
|
||||
fn mob(kind: &str) -> Option<Mob> {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
fn root() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
let types = horizon_client::mobs::MobTypes::load(&root).ok()?;
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
}
|
||||
|
||||
/// The prepared resources' built-in registries (empty without them).
|
||||
fn builtin() -> Builtin {
|
||||
Builtin::load(&root().join("registries.json")).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
|
||||
super::apply(m, values, &builtin());
|
||||
}
|
||||
|
||||
fn mob(kind: &str) -> Option<Mob> {
|
||||
let types = horizon_client::mobs::MobTypes::load(&root()).ok()?;
|
||||
let t = std::sync::Arc::new(types.types.get(kind)?.clone());
|
||||
let mut m = Mob::new(1, t, horizon_core::geometry::Vec3::ZERO, 0.0);
|
||||
client_defaults(&mut m);
|
||||
@@ -133,4 +177,45 @@ mod tests {
|
||||
apply(&mut m, &[(0, s::BYTE, Value::Byte(0))]);
|
||||
assert_eq!(m.fire_ticks, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_villager_takes_its_type_profession_and_level() {
|
||||
let Some(mut m) = mob("minecraft:villager") else {
|
||||
return;
|
||||
};
|
||||
let registries = builtin();
|
||||
let id = |registry: &str, name: &str| registries.get(registry)?.id(name);
|
||||
let (Some(desert), Some(farmer), Some(librarian)) = (
|
||||
id("minecraft:villager_type", "minecraft:desert"),
|
||||
id("minecraft:villager_profession", "minecraft:farmer"),
|
||||
id("minecraft:villager_profession", "minecraft:librarian"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let name = |m: &Mob| {
|
||||
(
|
||||
VILLAGER_TYPES[m.extra.variant as usize],
|
||||
PROFESSIONS[m.extra.variant2 as usize],
|
||||
m.extra.variant3,
|
||||
)
|
||||
};
|
||||
apply(
|
||||
&mut m,
|
||||
&[(19, s::VILLAGER_DATA, Value::Ids(vec![desert, farmer, 2]))],
|
||||
);
|
||||
assert_eq!(name(&m), ("desert", "farmer", 2));
|
||||
// A job taken later: only the profession and the level change.
|
||||
apply(
|
||||
&mut m,
|
||||
&[(19, s::VILLAGER_DATA, Value::Ids(vec![desert, librarian, 5]))],
|
||||
);
|
||||
assert_eq!(name(&m), ("desert", "librarian", 5));
|
||||
// A zombie villager's data comes after Zombie's three entries.
|
||||
let mut z = mob("minecraft:zombie_villager").unwrap();
|
||||
apply(
|
||||
&mut z,
|
||||
&[(20, s::VILLAGER_DATA, Value::Ids(vec![desert, farmer, 3]))],
|
||||
);
|
||||
assert_eq!(name(&z), ("desert", "farmer", 3));
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,12 @@ impl Game<'_> {
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
let block = position.map(|v| v.floor() as i32);
|
||||
self.remove_remote_object(id);
|
||||
if let Some(cart) = super::minecarts::CartKind::from_id(kind) {
|
||||
if kind == "minecraft:text_display" {
|
||||
self.text_displays.insert(
|
||||
id as i32,
|
||||
super::text_displays::RemoteTextDisplay::new(position, yaw),
|
||||
);
|
||||
} else if let Some(cart) = super::minecarts::CartKind::from_id(kind) {
|
||||
let mut c = super::minecarts::Minecart::new(id, cart, at);
|
||||
c.yaw = yaw;
|
||||
c.previous_yaw = yaw;
|
||||
@@ -93,6 +98,10 @@ impl Game<'_> {
|
||||
|
||||
/// `handleSetEntityData` for an object: what changes how it looks.
|
||||
pub(super) fn remote_object_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
if let Some(display) = self.text_displays.get_mut(&id) {
|
||||
display.metadata(values);
|
||||
return;
|
||||
}
|
||||
let id = id as u32;
|
||||
for (index, _, value) in values {
|
||||
if self.item_frames.iter().any(|f| f.id == id) {
|
||||
@@ -273,6 +282,7 @@ impl Game<'_> {
|
||||
|
||||
/// Every object a server sent (a new connection, level or dimension).
|
||||
pub(super) fn clear_remote_objects(&mut self) {
|
||||
self.text_displays.clear();
|
||||
self.carts.clear();
|
||||
self.boats.clear();
|
||||
self.armor_stands.clear();
|
||||
@@ -281,6 +291,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_object(&mut self, id: u32) {
|
||||
self.text_displays.remove(&(id as i32));
|
||||
self.carts.retain(|c| c.id != id);
|
||||
self.boats.retain(|b| b.id != id);
|
||||
self.armor_stands.retain(|a| a.id != id);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! end crystals on a server's client (`ClientPacketListener.handleAddEntity`
|
||||
//! and `handleSetEntityData`): the server simulates them, the client draws
|
||||
//! them where it says and interpolates their moves.
|
||||
use super::{FallingBlock, Game};
|
||||
use super::{FallStep, FallingBlock, Game};
|
||||
use horizon_client::projectiles::{Projectile, ProjectileKind};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
@@ -24,6 +24,14 @@ fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
|
||||
"minecraft:llama_spit" => ProjectileKind::LlamaSpit,
|
||||
"minecraft:wind_charge" => ProjectileKind::WindCharge,
|
||||
"minecraft:breeze_wind_charge" => ProjectileKind::BreezeWindCharge,
|
||||
// Homing state stays on the server; the client interpolates its positions.
|
||||
"minecraft:shulker_bullet" => ProjectileKind::ShulkerBullet {
|
||||
target: horizon_client::mobs::Who::Player,
|
||||
dir: None,
|
||||
steps: 0,
|
||||
delta: [0.0; 3],
|
||||
seed: 0,
|
||||
},
|
||||
"minecraft:wither_skull" => ProjectileKind::WitherSkull { dangerous: false },
|
||||
_ => return None,
|
||||
})
|
||||
@@ -185,11 +193,17 @@ impl Game<'_> {
|
||||
t.previous = t.position;
|
||||
t.position = step(t.id, t.position);
|
||||
}
|
||||
for f in &mut self.falling {
|
||||
f.previous = f.position;
|
||||
f.age += 1;
|
||||
f.position = step(f.id, f.position);
|
||||
// FallingBlockEntity.tick runs on a client too: the block falls by
|
||||
// itself between the server's updates (every 20 ticks), then waits
|
||||
// where it lands for the server to place it and remove it.
|
||||
let mut falling = std::mem::take(&mut self.falling);
|
||||
for f in &mut falling {
|
||||
if let Ok(FallStep::Landed(_)) = self.fall_step(f, false) {
|
||||
// Block.updateEntityMovementAfterFallOn, then the air drag.
|
||||
f.velocity = Vec3::new(f.velocity.x * 0.98, 0.0, f.velocity.z * 0.98);
|
||||
}
|
||||
}
|
||||
self.falling = falling;
|
||||
for p in &mut self.creatures.projectiles {
|
||||
p.previous = p.position;
|
||||
p.age += 1;
|
||||
|
||||
@@ -88,8 +88,10 @@ impl Game<'_> {
|
||||
for m in &mut self.mobs {
|
||||
if m.extra.mount == Some(vehicle_id) && !passengers.contains(&(m.id as i32)) {
|
||||
m.extra.mount = None;
|
||||
m.extra.passenger = false;
|
||||
} else if passengers.contains(&(m.id as i32)) {
|
||||
m.extra.mount = Some(vehicle_id);
|
||||
m.extra.passenger = true;
|
||||
}
|
||||
}
|
||||
if passengers.contains(&me) {
|
||||
@@ -129,17 +131,20 @@ impl Game<'_> {
|
||||
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)
|
||||
// In front of a mob that shares the boat.
|
||||
Some(self.player_boat_seat(i))
|
||||
} else {
|
||||
// A minecart: the server moves it, the rider sits in it.
|
||||
self.carts.iter().find(|c| c.id == id).map(|c| c.position)
|
||||
self.carts.iter().find(|c| c.id == id).map(|c| {
|
||||
c.position
|
||||
.add_vector(Vec3::new(0.0, super::minecarts::SEAT, 0.0))
|
||||
})
|
||||
};
|
||||
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.position = v;
|
||||
}
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
|
||||
@@ -13,6 +13,7 @@ pub const HELP: &[&str] = &[
|
||||
"signedit X Y Z [front|back] open the sign editor for the sign at X Y Z",
|
||||
"section-draws [flat|indirect] how terrain sections are drawn (A/B checks); replies the mode",
|
||||
"section-cull [on|off] occlusion culling of solid sections (A/B checks); replies the mode",
|
||||
"section-sides [on|off] leave out solid quads facing away from the eye (A/B checks); replies the mode",
|
||||
];
|
||||
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -100,7 +100,10 @@ impl Game<'_> {
|
||||
skybox: self.dim == Dim::Overworld,
|
||||
blindness: None,
|
||||
darkness: self.darkness_fog(),
|
||||
boss_world_fog: self.dragon_bar_shown(),
|
||||
darken_world: self
|
||||
.boss_overlay
|
||||
.darken_amount(self.clock.fraction() as f32),
|
||||
boss_world_fog: self.boss_overlay.should_create_world_fog(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
//! A server's resource packs bound to the game (`ClientCommonPacketListenerImpl.
|
||||
//! handleResourcePackPush` and `handleResourcePackPop`, its `PackConfirmScreen`,
|
||||
//! and `DownloadedPackSource` as `Minecraft` drives it): the prompt, the
|
||||
//! server list's setting, downloads into `downloads/`, and a resource reload
|
||||
//! with the server's packs on top of the player's own.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
confirm_screen::Confirm,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
java_text, multiplayer, packs,
|
||||
scoreboard::{comp_text, tag_to_json},
|
||||
server_packs::{Prompt, ServerPacks, action, download, parse_sha1, valid_url},
|
||||
text::{Text, named_color},
|
||||
toasts::SystemToast,
|
||||
ui,
|
||||
};
|
||||
use horizon_core::jmath;
|
||||
use horizon_protocol::{packets::configuration::ResourcePackPush, servers};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::mpsc::{Receiver, TryRecvError},
|
||||
};
|
||||
|
||||
/// `ClientCommonPacketListenerImpl.PackConfirmScreen`: the packs it asks
|
||||
/// about, whether the last one is required, and the server's message.
|
||||
pub(super) struct PackPrompt {
|
||||
requests: Vec<(u128, String, String)>,
|
||||
required: bool,
|
||||
prompt: Option<Text>,
|
||||
}
|
||||
impl PackPrompt {
|
||||
fn texts(&self) -> (Text, Text, [Text; 2]) {
|
||||
let t = |key: &str| Text::translate(key, &[]);
|
||||
let (title, line2) = if self.required {
|
||||
let yellow = named_color("yellow").unwrap_or(0xFFFF55);
|
||||
(
|
||||
t("multiplayer.requiredTexturePrompt.line1"),
|
||||
t("multiplayer.requiredTexturePrompt.line2")
|
||||
.with_color(yellow)
|
||||
.bold(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
t("multiplayer.texturePrompt.line1"),
|
||||
t("multiplayer.texturePrompt.line2"),
|
||||
)
|
||||
};
|
||||
// preparePackPrompt.
|
||||
let message = match &self.prompt {
|
||||
Some(prompt) => Text::translate(
|
||||
"multiplayer.texturePrompt.serverPrompt",
|
||||
&[line2, prompt.clone()],
|
||||
),
|
||||
None => line2,
|
||||
};
|
||||
let buttons = if self.required {
|
||||
[t("gui.proceed"), t("menu.disconnect")]
|
||||
} else {
|
||||
[t("gui.yes"), t("gui.no")]
|
||||
};
|
||||
(title, message, buttons)
|
||||
}
|
||||
fn layout(&self, atlas: &HudAtlas, size: (f32, f32)) -> Confirm {
|
||||
let (title, message, _) = self.texts();
|
||||
Confirm::new(&atlas.font, size, &title, &message)
|
||||
}
|
||||
}
|
||||
|
||||
/// The game's `DownloadedPackSource`.
|
||||
#[derive(Default)]
|
||||
pub(super) struct ServerPackState {
|
||||
packs: ServerPacks,
|
||||
/// `downloads/` in the game folder, opened at the first download.
|
||||
queue: Option<download::Queue>,
|
||||
pub(super) prompt: Option<PackPrompt>,
|
||||
/// The server list entry the setting is saved to (none: a direct join).
|
||||
address: Option<String>,
|
||||
/// The account the downloads are made for (the download headers).
|
||||
user: (String, u128),
|
||||
/// The packs loaded on top of the player's own, bottom first.
|
||||
pub(super) active: Vec<packs::Pack>,
|
||||
/// The download batches' notifiers, oldest first.
|
||||
notifiers: Vec<PackNotifier>,
|
||||
}
|
||||
/// `DownloadedPackSource.createDownloadNotifier`: one batch's progress in its
|
||||
/// own `SystemToast`.
|
||||
struct PackNotifier {
|
||||
progress: Receiver<download::Progress>,
|
||||
total: usize,
|
||||
/// `count`: the requests started.
|
||||
count: usize,
|
||||
/// `failCount`.
|
||||
failed: usize,
|
||||
/// `totalBytes`: the Content-Length of the request in flight.
|
||||
size: Option<u64>,
|
||||
/// The bytes it has written so far: none before the first response.
|
||||
bytes: Option<u64>,
|
||||
/// The last request has finished.
|
||||
done: bool,
|
||||
toast: Option<SystemToast>,
|
||||
}
|
||||
impl PackNotifier {
|
||||
fn new(progress: Receiver<download::Progress>, total: usize) -> Self {
|
||||
Self {
|
||||
progress,
|
||||
total,
|
||||
count: 0,
|
||||
failed: 0,
|
||||
size: None,
|
||||
bytes: None,
|
||||
done: false,
|
||||
toast: None,
|
||||
}
|
||||
}
|
||||
/// The batch's events since the last call, with the toast updated once
|
||||
/// for all of them. False once the batch has ended and its toast is gone.
|
||||
fn poll(&mut self) -> bool {
|
||||
let (mut changed, mut hide) = (false, false);
|
||||
let ended = loop {
|
||||
let event = match self.progress.try_recv() {
|
||||
Ok(event) => event,
|
||||
Err(e) => break e == TryRecvError::Disconnected,
|
||||
};
|
||||
changed |= match event {
|
||||
download::Progress::RequestStart => {
|
||||
self.count += 1;
|
||||
true
|
||||
}
|
||||
download::Progress::DownloadStart(size) => {
|
||||
self.size = size;
|
||||
self.bytes = Some(0);
|
||||
true
|
||||
}
|
||||
download::Progress::Bytes(bytes) => {
|
||||
self.bytes = Some(bytes);
|
||||
true
|
||||
}
|
||||
download::Progress::RequestFinished(ok) => {
|
||||
self.failed += usize::from(!ok);
|
||||
self.done = self.count == self.total;
|
||||
// Every pack came: forceHide; else the failure title.
|
||||
hide |= self.done && self.failed == 0;
|
||||
self.done && self.failed > 0
|
||||
}
|
||||
};
|
||||
};
|
||||
if changed {
|
||||
let (title, message) = self.text();
|
||||
let (title, message) = (title.plain(), message.map(|m| m.plain()));
|
||||
// SystemToast.addOrUpdate.
|
||||
match &mut self.toast {
|
||||
Some(toast) => toast.reset(title, message),
|
||||
None => self.toast = Some(SystemToast::new(title, message)),
|
||||
}
|
||||
}
|
||||
if hide && let Some(toast) = &mut self.toast {
|
||||
toast.force_hide();
|
||||
}
|
||||
!ended || self.toast.is_some()
|
||||
}
|
||||
/// The toast's title and message.
|
||||
fn text(&self) -> (Text, Option<Text>) {
|
||||
if self.done && self.failed > 0 {
|
||||
let failed = Text::tr("download.pack.failed", &[&self.failed, &self.total]);
|
||||
return (failed, None);
|
||||
}
|
||||
let title = Text::tr("download.pack.title", &[&self.count, &self.total]);
|
||||
let message = self.bytes.map(|bytes| match self.size {
|
||||
Some(size) if size > 0 => {
|
||||
Text::tr("download.pack.progress.percent", &[&(bytes * 100 / size)])
|
||||
}
|
||||
_ => Text::tr("download.pack.progress.bytes", &[&human_readable(bytes)]),
|
||||
});
|
||||
(title, message)
|
||||
}
|
||||
}
|
||||
/// `com.mojang.realmsclient.Unit.humanReadable`, for
|
||||
/// `download.pack.progress.bytes`.
|
||||
fn human_readable(bytes: u64) -> String {
|
||||
if bytes < 1024 {
|
||||
return format!("{bytes} B");
|
||||
}
|
||||
let exp = (jmath::log(bytes as f64) / jmath::log(1024.0)) as i32;
|
||||
let prefix = &"KMGTPE"[exp as usize - 1..exp as usize];
|
||||
let value = bytes as f64 / 1024f64.powi(exp);
|
||||
format!("{} {prefix}B", java_text::format_fixed(value, 1))
|
||||
}
|
||||
|
||||
fn saves() -> PathBuf {
|
||||
std::env::current_dir().unwrap_or_default().join("saves")
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ConnectScreen`: the server's setting decides what its packs do
|
||||
/// (`configureForServerControl`); single player's own server takes them
|
||||
/// all, quietly (`configureForLocalWorld`).
|
||||
pub(super) fn server_packs_connect(&mut self, address: Option<&str>, user: (String, u128)) {
|
||||
let state = &mut self.server_packs;
|
||||
state.user = user;
|
||||
state.prompt = None;
|
||||
state.notifiers.clear();
|
||||
let Some(address) = address else {
|
||||
state.address = None;
|
||||
state.packs.local_world();
|
||||
return;
|
||||
};
|
||||
let file = multiplayer::servers_file(&saves());
|
||||
let entry = servers::load(&file).into_iter().find(|s| s.ip == address);
|
||||
let setting = entry.as_ref().and_then(|e| e.accept_textures);
|
||||
state.address = entry.map(|e| e.ip);
|
||||
state.packs.connect(Prompt::for_server(setting));
|
||||
}
|
||||
/// `Minecraft.clearDownloadedResourcePacks`: every server pack goes at once.
|
||||
pub(super) fn server_packs_disconnect(&mut self) {
|
||||
self.server_packs.prompt = None;
|
||||
self.server_packs.notifiers.clear();
|
||||
self.server_packs.packs.disconnect();
|
||||
self.tick_server_packs();
|
||||
}
|
||||
|
||||
/// `handleResourcePackPush`.
|
||||
pub(super) fn handle_pack_push(&mut self, p: ResourcePackPush) {
|
||||
if !valid_url(&p.url) {
|
||||
self.send_pack_status(p.id, action::INVALID_URL);
|
||||
return;
|
||||
}
|
||||
let setting = self.server_setting();
|
||||
let prompt = Prompt::for_server(setting);
|
||||
if prompt != Prompt::Pending && (!p.required || prompt != Prompt::Declined) {
|
||||
self.server_packs
|
||||
.packs
|
||||
.push(p.id, &p.url, parse_sha1(&p.hash));
|
||||
return;
|
||||
}
|
||||
// addOrUpdatePackPrompt: one prompt for every pack pushed before it is answered.
|
||||
let text = p.prompt.as_ref().map(|t| comp_text(&tag_to_json(t)));
|
||||
let state = &mut self.server_packs;
|
||||
let open = state.prompt.get_or_insert(PackPrompt {
|
||||
requests: Vec::new(),
|
||||
required: p.required,
|
||||
prompt: None,
|
||||
});
|
||||
open.requests.push((p.id, p.url, p.hash));
|
||||
open.required = p.required;
|
||||
open.prompt = text;
|
||||
self.capture(false);
|
||||
}
|
||||
/// `handleResourcePackPop`: one pack, or all of them.
|
||||
pub(super) fn handle_pack_pop(&mut self, id: Option<u128>) {
|
||||
match id {
|
||||
Some(id) => self.server_packs.packs.pop(id),
|
||||
None => self.server_packs.packs.pop_all(),
|
||||
}
|
||||
}
|
||||
/// The joined server's entry in the server list: its `acceptTextures`.
|
||||
fn server_setting(&self) -> Option<bool> {
|
||||
let address = self.server_packs.address.as_deref()?;
|
||||
servers::load(&multiplayer::servers_file(&saves()))
|
||||
.into_iter()
|
||||
.find(|s| s.ip == address)?
|
||||
.accept_textures
|
||||
}
|
||||
fn send_pack_status(&self, id: u128, status: i32) {
|
||||
if let Some(r) = &self.remote {
|
||||
r.session.send_pack_status(id, status);
|
||||
}
|
||||
}
|
||||
|
||||
/// The prompt's answer (`PackConfirmScreen`'s callback): the server's
|
||||
/// setting follows it, declining a required pack disconnects, and every
|
||||
/// pack it asked about is pushed.
|
||||
pub(super) fn answer_pack_prompt(&mut self, yes: bool) -> Result<()> {
|
||||
let Some(prompt) = self.server_packs.prompt.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
if self.player.is_some() && self.screen == super::Screen::Playing {
|
||||
self.capture(true);
|
||||
}
|
||||
let setting = if yes {
|
||||
self.server_packs.packs.allow();
|
||||
Some(true)
|
||||
} else {
|
||||
self.server_packs.packs.reject();
|
||||
if prompt.required {
|
||||
let reason = Text::translate("multiplayer.requiredTexturePrompt.disconnect", &[]);
|
||||
return self.leave_server(Some(reason.plain()));
|
||||
}
|
||||
Some(false)
|
||||
};
|
||||
for (id, url, hash) in prompt.requests {
|
||||
self.server_packs.packs.push(id, &url, parse_sha1(&hash));
|
||||
}
|
||||
// ServerList.saveSingleServer.
|
||||
if let Some(address) = &self.server_packs.address {
|
||||
let file = multiplayer::servers_file(&saves());
|
||||
let mut list = servers::load(&file);
|
||||
for entry in list.iter_mut().filter(|s| &s.ip == address) {
|
||||
entry.accept_textures = setting;
|
||||
}
|
||||
if let Err(e) = servers::save(&file, &list) {
|
||||
log::warn!("Horizon: could not save {}: {e}", file.display());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs what the packs need (`createUpdateScheduler`): the downloads
|
||||
/// that finished, the ones to start, a resource reload when the active
|
||||
/// packs change, and the statuses for the server.
|
||||
pub(super) fn tick_server_packs(&mut self) {
|
||||
self.server_packs.notifiers.retain_mut(PackNotifier::poll);
|
||||
// A reload waits for the world's resources (`reload_packs`).
|
||||
if self.rest_loader.is_some() || self.world_loader.is_some() {
|
||||
return;
|
||||
}
|
||||
let finished = self
|
||||
.server_packs
|
||||
.queue
|
||||
.as_ref()
|
||||
.map(download::Queue::finished)
|
||||
.unwrap_or_default();
|
||||
for batch in finished {
|
||||
self.server_packs.packs.downloaded(batch);
|
||||
}
|
||||
while self.server_packs.packs.wants_tick() {
|
||||
let tick = self.server_packs.packs.tick();
|
||||
if !tick.download.is_empty() {
|
||||
self.start_pack_downloads(tick.download);
|
||||
}
|
||||
if let Some(mut load) = tick.reload {
|
||||
while let Some(recovery) = {
|
||||
let ok = self.reload_server_packs(&load);
|
||||
self.server_packs.packs.reloaded(ok)
|
||||
} {
|
||||
load = recovery;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, status) in self.server_packs.packs.take_replies() {
|
||||
self.send_pack_status(id, status);
|
||||
}
|
||||
}
|
||||
fn start_pack_downloads(&mut self, requests: Vec<horizon_client::server_packs::Request>) {
|
||||
let state = &mut self.server_packs;
|
||||
if state.queue.is_none() {
|
||||
let dir = std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join("downloads");
|
||||
match download::Queue::open(dir) {
|
||||
Ok(queue) => state.queue = Some(queue),
|
||||
Err(e) => {
|
||||
log::error!("Horizon: failed to open the download queue: {e}");
|
||||
// Every request failed, as the notifier would hear it.
|
||||
let (progress, events) = std::sync::mpsc::channel();
|
||||
for _ in &requests {
|
||||
let _ = progress.send(download::Progress::RequestStart);
|
||||
let _ = progress.send(download::Progress::RequestFinished(false));
|
||||
}
|
||||
state
|
||||
.notifiers
|
||||
.push(PackNotifier::new(events, requests.len()));
|
||||
let failed = requests.iter().map(|r| r.id).collect();
|
||||
state.packs.downloaded(horizon_client::server_packs::Batch {
|
||||
downloaded: Vec::new(),
|
||||
failed,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let headers = download::headers(&state.user.0, state.user.1);
|
||||
let (progress, events) = std::sync::mpsc::channel();
|
||||
state
|
||||
.notifiers
|
||||
.push(PackNotifier::new(events, requests.len()));
|
||||
if let Some(queue) = &state.queue {
|
||||
queue.download(requests, headers, progress);
|
||||
}
|
||||
}
|
||||
/// `DownloadedPackSource.startReload`: the packs on top of the player's
|
||||
/// own, then `Minecraft.reloadResourcePacks`. False when a pack has no
|
||||
/// readable `pack.mcmeta` or the reload failed.
|
||||
fn reload_server_packs(&mut self, load: &[(u128, PathBuf)]) -> bool {
|
||||
let packs: Option<Vec<_>> = load
|
||||
.iter()
|
||||
.map(|(id, path)| packs::server_pack(*id, path))
|
||||
.collect();
|
||||
let Some(packs) = packs else {
|
||||
log::warn!("Horizon: invalid pack metadata in a server pack, ignoring all");
|
||||
return false;
|
||||
};
|
||||
self.server_packs.active = packs;
|
||||
match self.reload_selected_packs() {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: server resource pack reload failed: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn draw_pack_prompt(&self, batch: &mut HudBatch) {
|
||||
let Some(prompt) = &self.server_packs.prompt else {
|
||||
return;
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
if self.player.is_none() {
|
||||
// In place of the connecting screen, on its background.
|
||||
batch.fill(
|
||||
atlas,
|
||||
0.0,
|
||||
0.0,
|
||||
w,
|
||||
h,
|
||||
horizon_client::hud::rgba(20, 20, 20, 255),
|
||||
);
|
||||
}
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.player.is_some());
|
||||
let (_, _, [yes, no]) = prompt.texts();
|
||||
prompt
|
||||
.layout(atlas, (w, h))
|
||||
.draw(batch, atlas, self.cursor, [&yes, &no]);
|
||||
}
|
||||
/// The download notifiers' toasts, each in the next 32-pixel slot
|
||||
/// below an advancement or recipe toast (`ToastManager`).
|
||||
pub(super) fn draw_pack_toasts(&mut self, batch: &mut HudBatch) {
|
||||
let multiplier = f64::from(self.settings.notification_display_time);
|
||||
let mut slot = usize::from(!self.adv.toasts.is_empty());
|
||||
for notifier in &mut self.server_packs.notifiers {
|
||||
let Some(toast) = &mut notifier.toast else {
|
||||
continue;
|
||||
};
|
||||
let (sound, finished) = toast.update(multiplier);
|
||||
if let Some(show) = sound {
|
||||
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
|
||||
self.audio
|
||||
.play(&format!("minecraft:{event}"), None, 1.0, 1.0);
|
||||
}
|
||||
if finished {
|
||||
notifier.toast = None;
|
||||
continue;
|
||||
}
|
||||
toast.draw(&self.gui.atlas, batch, 32.0 * slot as f32);
|
||||
slot += 1;
|
||||
}
|
||||
}
|
||||
/// A left click on the prompt: its answer when it hit a button.
|
||||
pub(super) fn pack_prompt_click(&mut self) -> Result<()> {
|
||||
let Some(prompt) = &self.server_packs.prompt else {
|
||||
return Ok(());
|
||||
};
|
||||
let answer = prompt
|
||||
.layout(&self.gui.atlas, self.gui_size())
|
||||
.answer_at(self.cursor);
|
||||
match answer {
|
||||
Some(yes) => {
|
||||
self.audio.play_button_click();
|
||||
self.answer_pack_prompt(yes)
|
||||
}
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use download::Progress::*;
|
||||
|
||||
fn notifier(total: usize, events: &[download::Progress]) -> PackNotifier {
|
||||
let (send, receive) = std::sync::mpsc::channel();
|
||||
for event in events {
|
||||
send.send(*event).unwrap();
|
||||
}
|
||||
let mut notifier = PackNotifier::new(receive, total);
|
||||
assert!(notifier.poll());
|
||||
notifier
|
||||
}
|
||||
fn texts(n: &PackNotifier) -> (serde_json::Value, Option<serde_json::Value>) {
|
||||
let (title, message) = n.text();
|
||||
(title.component(), message.map(|m| m.component()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_toast_counts_requests_and_shows_progress() {
|
||||
let n = notifier(2, &[RequestStart]);
|
||||
let (title, message) = texts(&n);
|
||||
assert_eq!(title["translate"], "download.pack.title");
|
||||
assert_eq!(title["with"], serde_json::json!([1, 2]));
|
||||
assert_eq!(message, None);
|
||||
let n = notifier(2, &[RequestStart, DownloadStart(Some(400)), Bytes(100)]);
|
||||
assert_eq!(
|
||||
texts(&n).1.unwrap(),
|
||||
serde_json::json!({"translate": "download.pack.progress.percent", "with": [25]})
|
||||
);
|
||||
let n = notifier(1, &[RequestStart, DownloadStart(None), Bytes(1280)]);
|
||||
assert_eq!(
|
||||
texts(&n).1.unwrap()["with"],
|
||||
serde_json::json!([{"text": "1.3 KB"}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_pack_turns_the_toast_into_the_failure_count() {
|
||||
let events = [
|
||||
RequestStart,
|
||||
RequestFinished(false),
|
||||
RequestStart,
|
||||
DownloadStart(Some(3)),
|
||||
Bytes(3),
|
||||
];
|
||||
let (send, receive) = std::sync::mpsc::channel();
|
||||
for event in events {
|
||||
send.send(event).unwrap();
|
||||
}
|
||||
let mut n = PackNotifier::new(receive, 2);
|
||||
assert!(n.poll());
|
||||
// Not the last request yet: still downloading.
|
||||
assert_eq!(texts(&n).0["translate"], "download.pack.title");
|
||||
send.send(RequestFinished(true)).unwrap();
|
||||
drop(send);
|
||||
assert!(n.poll(), "the failure toast stays until it slides out");
|
||||
let (title, message) = texts(&n);
|
||||
assert_eq!(
|
||||
title,
|
||||
serde_json::json!({"translate": "download.pack.failed", "with": [1, 2]})
|
||||
);
|
||||
assert_eq!(message, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_batch_without_a_toast_is_dropped() {
|
||||
let (send, receive) = std::sync::mpsc::channel::<download::Progress>();
|
||||
drop(send);
|
||||
assert!(!PackNotifier::new(receive, 1).poll());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_read_as_realms_unit_human_readable() {
|
||||
// com.mojang.realmsclient.Unit.humanReadable, run on the 26.3 jar.
|
||||
for (bytes, text) in [
|
||||
(0, "0 B"),
|
||||
(1023, "1023 B"),
|
||||
(1024, "1.0 KB"),
|
||||
(1280, "1.3 KB"),
|
||||
(1535, "1.5 KB"),
|
||||
(10_000, "9.8 KB"),
|
||||
(1_048_575, "1024.0 KB"),
|
||||
(1_048_576, "1.0 MB"),
|
||||
(52_428_800, "50.0 MB"),
|
||||
(262_144_000, "250.0 MB"),
|
||||
(1_073_741_824, "1.0 GB"),
|
||||
] {
|
||||
assert_eq!(human_readable(bytes), text, "{bytes}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,12 +219,11 @@ pub(super) fn spawn_loader(
|
||||
.name("resource-loader".into())
|
||||
.spawn(move || {
|
||||
let mut pack_error = None;
|
||||
let root = packs::startup_root(&vanilla_root, &settings.resource_packs).unwrap_or_else(
|
||||
|error| {
|
||||
let root = packs::startup_root(&vanilla_root, &settings.resource_packs, &[])
|
||||
.unwrap_or_else(|error| {
|
||||
pack_error = Some(error.to_string());
|
||||
vanilla_root.clone()
|
||||
},
|
||||
);
|
||||
});
|
||||
let root = mods::content_root(&root, &settings.mods.enabled, &dev_mods);
|
||||
let result = load_resources(
|
||||
root,
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Server text displays: metadata, interpolation, billboard transforms and world text.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
hud::{HudBatch, rgba},
|
||||
mesher::Vertex,
|
||||
text::Text,
|
||||
text_display::{Align, TextDisplayData},
|
||||
};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
|
||||
pub(super) struct RemoteTextDisplay {
|
||||
pub data: TextDisplayData,
|
||||
from: TextDisplayData,
|
||||
shown: TextDisplayData,
|
||||
position: glam::DVec3,
|
||||
previous: glam::DVec3,
|
||||
target: glam::DVec3,
|
||||
rotation: [f32; 2],
|
||||
old_rotation: [f32; 2],
|
||||
target_rotation: [f32; 2],
|
||||
steps: i32,
|
||||
age: i64,
|
||||
start: i64,
|
||||
duration: i32,
|
||||
dirty: bool,
|
||||
start_dirty: bool,
|
||||
duration_dirty: bool,
|
||||
initialized: bool,
|
||||
/// `Display.lastProgress`: the progress the last frame drew, where a new
|
||||
/// interpolation starts from.
|
||||
last_progress: f32,
|
||||
/// `TextDisplay.clientDisplayCache`: the split lines, until the render
|
||||
/// state changes.
|
||||
lines: Option<Lines>,
|
||||
}
|
||||
|
||||
/// `TextDisplay.CachedInfo`: each line and its width, and the widest.
|
||||
struct Lines {
|
||||
lines: Vec<(Text, f32)>,
|
||||
width: f32,
|
||||
}
|
||||
|
||||
/// `Display.RENDER_STATE_IDS` and `TextDisplay.TEXT_RENDER_STATE_IDS`: the
|
||||
/// data a render state is built from (not the view range, size or glow).
|
||||
fn renders(id: u8) -> bool {
|
||||
matches!(id, 11..=16 | 18 | 19 | 23..=27)
|
||||
}
|
||||
|
||||
impl RemoteTextDisplay {
|
||||
pub fn new(position: [f64; 3], yaw: f32) -> Self {
|
||||
Self {
|
||||
data: Default::default(),
|
||||
from: Default::default(),
|
||||
shown: Default::default(),
|
||||
position: position.into(),
|
||||
previous: position.into(),
|
||||
target: position.into(),
|
||||
rotation: [yaw, 0.0],
|
||||
old_rotation: [yaw, 0.0],
|
||||
target_rotation: [yaw, 0.0],
|
||||
steps: 0,
|
||||
age: 0,
|
||||
start: 0,
|
||||
duration: 0,
|
||||
dirty: true,
|
||||
start_dirty: false,
|
||||
duration_dirty: false,
|
||||
initialized: false,
|
||||
last_progress: 0.0,
|
||||
lines: None,
|
||||
}
|
||||
}
|
||||
fn progress(&self, partial: f32) -> f32 {
|
||||
if self.duration <= 0 {
|
||||
1.0
|
||||
} else {
|
||||
(((self.age - self.start) as f32 + partial) / self.duration as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
pub fn metadata(&mut self, values: &[(u8, i32, Value)]) {
|
||||
self.start_dirty |= values.iter().any(|v| v.0 == 8);
|
||||
self.duration_dirty |= values.iter().any(|v| v.0 == 9);
|
||||
self.dirty |= values.iter().any(|v| renders(v.0));
|
||||
self.data.apply(values);
|
||||
}
|
||||
fn tick(&mut self) {
|
||||
self.age += 1;
|
||||
self.previous = self.position;
|
||||
self.old_rotation = self.rotation;
|
||||
if self.steps > 0 {
|
||||
self.position += (self.target - self.position) / f64::from(self.steps);
|
||||
for i in 0..2 {
|
||||
self.rotation[i] +=
|
||||
angle(self.target_rotation[i] - self.rotation[i]) / self.steps as f32;
|
||||
}
|
||||
self.steps -= 1;
|
||||
}
|
||||
if self.start_dirty {
|
||||
self.start = self.age + i64::from(self.data.interpolation_delay);
|
||||
}
|
||||
if self.duration_dirty {
|
||||
self.duration = self.data.interpolation_duration;
|
||||
}
|
||||
if self.dirty {
|
||||
self.from = if self.initialized && self.duration > 0 {
|
||||
self.shown.blend(&self.from, self.last_progress)
|
||||
} else {
|
||||
self.data.clone()
|
||||
};
|
||||
self.shown = self.data.clone();
|
||||
self.initialized = true;
|
||||
self.lines = None;
|
||||
}
|
||||
self.dirty = false;
|
||||
self.start_dirty = false;
|
||||
self.duration_dirty = false;
|
||||
}
|
||||
fn move_to(&mut self, position: [f64; 3], rotation: Option<(f32, f32)>) {
|
||||
self.target = position.into();
|
||||
if let Some((yaw, pitch)) = rotation {
|
||||
self.target_rotation = [yaw, pitch];
|
||||
}
|
||||
self.steps = self.data.teleport_duration.clamp(0, 59);
|
||||
if self.steps == 0 {
|
||||
self.position = self.target;
|
||||
self.previous = self.target;
|
||||
self.rotation = self.target_rotation;
|
||||
self.old_rotation = self.rotation;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn angle(d: f32) -> f32 {
|
||||
(d + 180.0).rem_euclid(360.0) - 180.0
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn move_remote_text_display(
|
||||
&mut self,
|
||||
id: i32,
|
||||
position: [f64; 3],
|
||||
rotation: Option<(f32, f32)>,
|
||||
) {
|
||||
if let Some(d) = self.text_displays.get_mut(&id) {
|
||||
d.move_to(position, rotation);
|
||||
}
|
||||
}
|
||||
pub(super) fn tick_text_displays(&mut self) {
|
||||
for display in self.text_displays.values_mut() {
|
||||
display.tick();
|
||||
}
|
||||
}
|
||||
pub(super) fn text_display_geometry(
|
||||
&mut self,
|
||||
camera: glam::DVec3,
|
||||
partial: f64,
|
||||
normal: &mut Vec<Vertex>,
|
||||
see_through: &mut Vec<Vertex>,
|
||||
) {
|
||||
self.text_display_vertices = 0;
|
||||
let atlas = &self.gui.atlas;
|
||||
let front = self.camera_type == super::perspective::CameraType::ThirdPersonFront;
|
||||
let angles = if front {
|
||||
[self.yaw + 180.0, -self.pitch]
|
||||
} else {
|
||||
[self.yaw, self.pitch]
|
||||
};
|
||||
for display in self.text_displays.values_mut() {
|
||||
display.last_progress = display.progress(partial as f32);
|
||||
let d = display.shown.blend(&display.from, display.last_progress);
|
||||
let position = display.previous.lerp(display.position, partial);
|
||||
let range = f64::from(d.view_range) * 64.0 * f64::from(self.settings.entity_distance);
|
||||
if position.distance_squared(camera) >= range * range || d.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let rotation = std::array::from_fn(|i| {
|
||||
display.old_rotation[i]
|
||||
+ angle(display.rotation[i] - display.old_rotation[i]) * partial as f32
|
||||
});
|
||||
let mut matrix = glam::Mat4::from_translation((position - camera).as_vec3())
|
||||
* d.matrix(rotation, angles)
|
||||
* glam::Mat4::from_rotation_y(std::f32::consts::PI)
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(-0.025));
|
||||
if !matrix.is_finite() {
|
||||
continue;
|
||||
}
|
||||
// TextDisplayRenderer.splitLines: Font.split, no indent on wrapped lines.
|
||||
let Lines { lines, width } = display.lines.get_or_insert_with(|| {
|
||||
let lines: Vec<_> = atlas
|
||||
.font
|
||||
.split(&d.text, d.line_width as f32)
|
||||
.into_iter()
|
||||
.map(|line| {
|
||||
let width = atlas.font.width_text(&line).ceil();
|
||||
(line, width)
|
||||
})
|
||||
.collect();
|
||||
let width = lines.iter().map(|l| l.1).fold(0.0, f32::max);
|
||||
Lines { lines, width }
|
||||
});
|
||||
let width = *width;
|
||||
let height = lines.len() as f32 * 10.0 - 1.0;
|
||||
matrix *=
|
||||
glam::Mat4::from_translation(glam::Vec3::new(1.0 - width / 2.0, -height, 0.0));
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
let bg = if d.flags & 4 != 0 {
|
||||
self.settings.text_background(0.25)
|
||||
} else {
|
||||
d.background as u32
|
||||
};
|
||||
if bg != 0 {
|
||||
batch.fill(
|
||||
atlas,
|
||||
-1.0,
|
||||
-1.0,
|
||||
width + 1.0,
|
||||
height + 1.0,
|
||||
rgba(
|
||||
(bg >> 16) as u8,
|
||||
(bg >> 8) as u8,
|
||||
bg as u8,
|
||||
(bg >> 24) as u8,
|
||||
),
|
||||
);
|
||||
}
|
||||
let align = d.align();
|
||||
for (i, (line, line_width)) in lines.iter().enumerate() {
|
||||
let x = match align {
|
||||
Align::Left => 0.0,
|
||||
Align::Right => width - line_width,
|
||||
Align::Center => (width - line_width) / 2.0,
|
||||
};
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
line,
|
||||
x,
|
||||
i as f32 * 10.0,
|
||||
rgba(255, 255, 255, d.opacity as u8),
|
||||
d.flags & 1 != 0,
|
||||
);
|
||||
}
|
||||
let world_light = self.world.light(
|
||||
position.x.floor() as i32,
|
||||
position.y.floor() as i32,
|
||||
position.z.floor() as i32,
|
||||
);
|
||||
let light = if d.brightness == -1 {
|
||||
horizon_client::block_entity_render::vertex_light(world_light)
|
||||
} else {
|
||||
((d.brightness as u32 >> 20 & 15) * 16)
|
||||
| (((d.brightness as u32 >> 4 & 15) * 16) << 8)
|
||||
};
|
||||
let through = d.flags & 2 != 0;
|
||||
let out = if through {
|
||||
&mut *see_through
|
||||
} else {
|
||||
&mut *normal
|
||||
};
|
||||
for quad in batch.vertices.as_chunks::<6>().0 {
|
||||
for i in [0, 1, 2, 5] {
|
||||
let v = quad[i];
|
||||
let point =
|
||||
matrix.transform_point3(glam::Vec3::new(v.pos[0], v.pos[1], v.depth));
|
||||
out.push(Vertex {
|
||||
pos: point.to_array(),
|
||||
uv: v.uv,
|
||||
color: v.color,
|
||||
light,
|
||||
layer: if through { 2 } else { 0 },
|
||||
normal: [0; 4],
|
||||
});
|
||||
}
|
||||
}
|
||||
self.text_display_vertices += batch.vertices.len() / 6 * 4;
|
||||
self.map_patches.extend(batch.patches);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn metadata_and_position_interpolation_keep_their_separate_clocks() {
|
||||
let mut d = RemoteTextDisplay::new([0.0; 3], 0.0);
|
||||
d.tick();
|
||||
d.metadata(&[
|
||||
(8, 1, Value::Int(0)),
|
||||
(9, 1, Value::Int(4)),
|
||||
(10, 1, Value::Int(2)),
|
||||
(11, 0, Value::Floats(vec![4.0, 0.0, 0.0])),
|
||||
]);
|
||||
d.tick();
|
||||
assert_eq!(d.shown.blend(&d.from, d.progress(0.5)).translation[0], 0.5);
|
||||
d.move_to([2.0, 0.0, 0.0], Some((90.0, 0.0)));
|
||||
d.tick();
|
||||
assert_eq!(d.position.x, 1.0);
|
||||
assert_eq!(d.rotation[0], 45.0);
|
||||
d.tick();
|
||||
assert_eq!(d.position.x, 2.0);
|
||||
assert_eq!(d.shown.blend(&d.from, d.progress(0.0)).translation[0], 2.0);
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,8 @@
|
||||
//! its boss bar, and its skulls hitting and exploding.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
hud_overlays,
|
||||
mobs::{Damage, DamageKind, Species, Who},
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
text::Text,
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -78,45 +76,6 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The wither boss bars (purple), within range.
|
||||
pub(super) fn wither_bars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
for m in self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.species() == Species::Wither && m.alive())
|
||||
{
|
||||
if m.position.distance_squared(p.position) > 64.0 * 64.0 {
|
||||
continue;
|
||||
}
|
||||
let progress = if m.extra.counter > 0 {
|
||||
1.0 - m.extra.counter as f32 / horizon_client::mobs::wither::INVULNERABLE as f32
|
||||
} else {
|
||||
(m.health / m.attrs.max_health).clamp(0.0, 1.0)
|
||||
};
|
||||
let name = m
|
||||
.custom_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.translate("entity.minecraft.wither", "Wither"));
|
||||
bars.push(hud_overlays::BossBar {
|
||||
name: Text::literal(name),
|
||||
progress,
|
||||
color: 5,
|
||||
overlay: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
pub(super) fn wither_near(&self) -> bool {
|
||||
self.player.as_ref().is_some_and(|p| {
|
||||
self.mobs.iter().any(|m| {
|
||||
m.species() == Species::Wither
|
||||
&& m.alive()
|
||||
&& m.position.distance_squared(p.position) <= 64.0 * 64.0
|
||||
})
|
||||
})
|
||||
}
|
||||
/// `WitherSkull.onHitEntity` / `onHit`: 8 damage (healing the wither 5
|
||||
/// when it kills), withering for 10 seconds, then a power 1 blast.
|
||||
pub(super) fn wither_skull_hit(
|
||||
|
||||
@@ -22,6 +22,23 @@ pub(super) struct PreparedWorld {
|
||||
}
|
||||
|
||||
impl WorldRequest {
|
||||
pub fn new(
|
||||
folder: &str,
|
||||
name: Option<&str>,
|
||||
seed: Option<i64>,
|
||||
creative: bool,
|
||||
create: Option<&horizon_client::title::WorldOptions>,
|
||||
) -> Result<Self> {
|
||||
Ok(Self {
|
||||
save_root: std::env::current_dir()?.join("saves").join(folder),
|
||||
folder: folder.to_owned(),
|
||||
name: name.map(str::to_owned),
|
||||
seed,
|
||||
creative,
|
||||
create: create.cloned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prepare(
|
||||
self,
|
||||
res: &Resources,
|
||||
@@ -245,13 +262,12 @@ pub(super) fn make_streaming(
|
||||
streaming.biome_blend = settings.biome_blend;
|
||||
streaming.smooth_lighting = settings.smooth_lighting;
|
||||
streaming.cutout_leaves = settings.cutout_leaves;
|
||||
streaming.cull_leaves = settings.horizon.cull_leaves;
|
||||
streaming.biome_zoom = BiomeZoom::new(obfuscate_seed(seed));
|
||||
streaming.sky_light = dim != Dim::Nether;
|
||||
streaming.nether_shading = dim == Dim::Nether;
|
||||
// A server draws nothing: neither the integrated server nor the
|
||||
// dedicated one meshes sections.
|
||||
streaming.mesh = !options.integrated && options.dedicated.is_none();
|
||||
streaming.mesh = options.execution_role.is_client();
|
||||
Ok((streaming, save, locator))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::{
|
||||
environment::Sky,
|
||||
graphics::GraphicsApi,
|
||||
hud::{HudBatch, HudRenderer},
|
||||
mesher::{SectionMesh, TerrainVertex, Vertex},
|
||||
mesher::{SectionMesh, Sides, TerrainVertex, Vertex},
|
||||
sprites::SpriteAnimation,
|
||||
};
|
||||
use glam::{DVec3, Mat4, Vec3, Vec4};
|
||||
@@ -156,9 +156,9 @@ struct SectionGpu {
|
||||
page: u32,
|
||||
start: u64,
|
||||
solid: u32,
|
||||
/// Where the solid quads' buckets by facing side end (`mesher::Sides`).
|
||||
sides: Sides,
|
||||
translucent: u32,
|
||||
/// The solid quads by facing: back-facing groups are skipped.
|
||||
faces: crate::mesher::FaceGroups,
|
||||
sort: translucency::SortState,
|
||||
sorted_indices: Option<(u32, u64)>,
|
||||
/// Its id for flat draws (`flat::Flat::add`), when they are on.
|
||||
@@ -268,6 +268,8 @@ pub struct Frame<'a> {
|
||||
pub night_vision: f32,
|
||||
/// Torch flicker added to the block light factor of 1.4.
|
||||
pub block_flicker: f32,
|
||||
/// `lightmap.fsh` BossOverlayWorldDarkeningFactor (a wither's bar).
|
||||
pub boss_darken: f32,
|
||||
/// `Screen.extractBlurredBackground`: blur the frame by this radius
|
||||
/// (`menuBackgroundBlurriness`) before the screen is drawn over it.
|
||||
pub menu_blur: Option<f32>,
|
||||
@@ -325,6 +327,8 @@ pub struct Renderer {
|
||||
/// counts reach a multi-draw's entries (Vulkan, Metal).
|
||||
cull: Option<cull::Cull>,
|
||||
cull_on: bool,
|
||||
/// Solid quads facing away from the eye are left out (`mesher::Sides`).
|
||||
sides_on: bool,
|
||||
sky: wgpu::RenderPipeline,
|
||||
dark_disc: wgpu::RenderPipeline,
|
||||
lines: wgpu::RenderPipeline,
|
||||
@@ -2157,7 +2161,7 @@ impl Renderer {
|
||||
cull::Cull::new(&device, config.format.remove_srgb_suffix(), depth_test);
|
||||
cull.always = cull_env.as_deref() == Ok("all");
|
||||
if !cull.always && adapter.get_info().backend == wgpu::Backend::Metal {
|
||||
cull.min_draws = cull::TILED_MIN_DRAWS;
|
||||
cull.min_sections = cull::TILED_MIN_SECTIONS;
|
||||
}
|
||||
cull
|
||||
});
|
||||
@@ -2204,6 +2208,7 @@ impl Renderer {
|
||||
translucent,
|
||||
flat_on: flat.is_some(),
|
||||
cull_on: cull.is_some(),
|
||||
sides_on: true,
|
||||
cull,
|
||||
flat,
|
||||
sky,
|
||||
@@ -2453,6 +2458,14 @@ impl Renderer {
|
||||
self.flat_on = on;
|
||||
Ok(())
|
||||
}
|
||||
/// Whether solid quads facing away from the eye are left out.
|
||||
pub fn section_sides(&self) -> bool {
|
||||
self.sides_on
|
||||
}
|
||||
/// Switches leaving out solid quads facing away, for A/B checks.
|
||||
pub fn set_section_sides(&mut self, on: bool) {
|
||||
self.sides_on = on;
|
||||
}
|
||||
/// Whether solid sections are occlusion culled (`cull`).
|
||||
pub fn section_cull(&self) -> bool {
|
||||
self.cull_on
|
||||
@@ -2575,8 +2588,12 @@ impl Renderer {
|
||||
page,
|
||||
start,
|
||||
solid,
|
||||
sides: if mesh.sides.quads() == solid {
|
||||
mesh.sides
|
||||
} else {
|
||||
Sides::unsorted(solid)
|
||||
},
|
||||
translucent,
|
||||
faces: mesh.faces,
|
||||
sort: translucency::SortState::new(
|
||||
mesh.translucent
|
||||
.as_chunks::<4>()
|
||||
@@ -2778,6 +2795,7 @@ impl Renderer {
|
||||
gamma: f32,
|
||||
night_vision: f32,
|
||||
flicker: f32,
|
||||
boss_darken: f32,
|
||||
) {
|
||||
// lightmap.fsh: per (block, sky) level.
|
||||
let brightness = |level: f32| level / (4.0 - 3.0 * level);
|
||||
@@ -2795,6 +2813,8 @@ impl Renderer {
|
||||
let parabolic = (2.0 * block_level - 1.0).powi(2);
|
||||
let block_color = Vec3::from(sky.block_light_tint).lerp(Vec3::ONE, 0.9 * parabolic);
|
||||
color += block_color * block_brightness;
|
||||
// lightmap.fsh: a boss bar mixes the light toward 70/60/60%.
|
||||
color = color.lerp(color * Vec3::new(0.7, 0.6, 0.6), boss_darken);
|
||||
color -= Vec3::splat(darkness);
|
||||
color = color.clamp(Vec3::ZERO, Vec3::ONE);
|
||||
let max = color.max_element().max(1e-6);
|
||||
@@ -2909,6 +2929,7 @@ impl Renderer {
|
||||
(self.gamma - frame.darkness.0).max(0.0),
|
||||
frame.night_vision,
|
||||
frame.block_flicker,
|
||||
frame.boss_darken,
|
||||
);
|
||||
let view_proj = self.view_projection(frame);
|
||||
if frame.title {
|
||||
@@ -3042,6 +3063,9 @@ impl Renderer {
|
||||
};
|
||||
let camera = frame.camera;
|
||||
let camera_section = camera_section(camera);
|
||||
// Solid quads facing away from the eye are left out: the bob moves
|
||||
// the eye off `camera`, so it is found from the projection.
|
||||
let eye = projection_center(view_proj).filter(|_| self.sides_on);
|
||||
let culling = !frame.title && frame.cave_culling;
|
||||
if culling {
|
||||
crate::spans::time("occlusion", || {
|
||||
@@ -3106,10 +3130,6 @@ impl Renderer {
|
||||
// as one list of indirect draws over the section arena.
|
||||
self.draws.clear();
|
||||
self.draw_pages.clear();
|
||||
// The eye, camera-relative: view bobbing moves it off the camera.
|
||||
let eye = (frame.bob * crate::camera::view_matrix(frame.yaw, frame.pitch))
|
||||
.inverse()
|
||||
.transform_point3(Vec3::ZERO);
|
||||
// Occlusion culling leaves the sections its last result did not see
|
||||
// to phase 2, after the others, with instance counts of 0. Whether
|
||||
// the frame has draws enough goes by the last one's.
|
||||
@@ -3117,7 +3137,7 @@ impl Renderer {
|
||||
.cull
|
||||
.as_mut()
|
||||
.filter(|c| {
|
||||
self.cull_on && !frame.title && self.multi_draw && self.section_draws >= c.min_draws
|
||||
self.cull_on && !frame.title && self.multi_draw && visible.len() >= c.min_sections
|
||||
})
|
||||
.and_then(|cull| cull.begin(&self.device).then_some(cull));
|
||||
self.phase2_draws.clear();
|
||||
@@ -3135,12 +3155,11 @@ impl Renderer {
|
||||
let start = draws.len();
|
||||
let instances = u32::from(first_phase);
|
||||
let mut indices = 0;
|
||||
// Only the solid groups that can face the eye.
|
||||
s.faces.ranges(eye - Vec3::from(v.origin), |first, quads| {
|
||||
let first = s.start + u64::from(first) * 4;
|
||||
draws.push([quads * 6, instances, 0, first as u32, i as u32]);
|
||||
pages.push((s.page, None));
|
||||
facing_runs(&s.sides, eye, v.origin, |first, quads| {
|
||||
indices += quads * 6;
|
||||
let base = s.start as u32 + first * 4;
|
||||
draws.push([quads * 6, instances, 0, base, i as u32]);
|
||||
pages.push((s.page, None));
|
||||
});
|
||||
let runs = if first_phase {
|
||||
&mut self.solid_runs
|
||||
@@ -3178,10 +3197,10 @@ impl Renderer {
|
||||
for (i, v) in visible_lods.iter().enumerate() {
|
||||
let s = &self.lods[&v.key].mesh;
|
||||
let start = self.draws.len();
|
||||
s.faces.ranges(eye - Vec3::from(v.origin), |first, quads| {
|
||||
let first = s.start + u64::from(first) * 4;
|
||||
self.draws
|
||||
.push([quads * 6, 1, 0, first as u32, (visible.len() + i) as u32]);
|
||||
let instance = (visible.len() + i) as u32;
|
||||
facing_runs(&s.sides, eye, v.origin, |first, quads| {
|
||||
let base = s.start as u32 + first * 4;
|
||||
self.draws.push([quads * 6, 1, 0, base, instance]);
|
||||
self.draw_pages.push((s.page, None));
|
||||
});
|
||||
self.solid_runs
|
||||
@@ -3520,7 +3539,9 @@ impl Renderer {
|
||||
}
|
||||
let flat_frame = match &mut self.flat {
|
||||
Some(flat)
|
||||
if self.flat_on && !self.draws.is_empty() && self.draws.len() >= flat.min_draws =>
|
||||
if self.flat_on
|
||||
&& !self.draws.is_empty()
|
||||
&& self.drawn_sections >= flat.min_sections =>
|
||||
{
|
||||
let sections = &self.sections;
|
||||
let lods = &self.lods;
|
||||
@@ -4202,6 +4223,34 @@ fn world_projection(fov_degrees: f32, aspect: f32, far: f32) -> Mat4 {
|
||||
Mat4::perspective_rh(fov_degrees.to_radians(), aspect, far, 0.05)
|
||||
}
|
||||
|
||||
/// Where a perspective `view_proj` sees from, in its own (camera-relative)
|
||||
/// space: the point whose clip x, y and w are all 0. `None` for a
|
||||
/// projection without one (orthographic).
|
||||
fn projection_center(view_proj: Mat4) -> Option<Vec3> {
|
||||
let m = view_proj.as_dmat4();
|
||||
let rows = [m.row(0), m.row(1), m.row(3)];
|
||||
let a = glam::DMat3::from_cols(
|
||||
DVec3::new(rows[0].x, rows[1].x, rows[2].x),
|
||||
DVec3::new(rows[0].y, rows[1].y, rows[2].y),
|
||||
DVec3::new(rows[0].z, rows[1].z, rows[2].z),
|
||||
);
|
||||
let det = a.determinant();
|
||||
if !det.is_normal() {
|
||||
return None;
|
||||
}
|
||||
let b = DVec3::new(rows[0].w, rows[1].w, rows[2].w);
|
||||
Some((a.inverse() * -b).as_vec3())
|
||||
}
|
||||
/// Calls `draw(first, quads)` for each run of a mesh's solid quads that can
|
||||
/// face `eye` (`projection_center`), the mesh at the camera-relative
|
||||
/// `origin`; all of them when there is no eye.
|
||||
fn facing_runs(sides: &Sides, eye: Option<Vec3>, origin: [f32; 3], mut draw: impl FnMut(u32, u32)) {
|
||||
match eye {
|
||||
Some(eye) => sides.visible((eye - Vec3::from(origin)).to_array(), draw),
|
||||
None if sides.quads() > 0 => draw(0, sides.quads()),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
fn frustum(m: Mat4) -> [Vec4; 6] {
|
||||
let r = |i| m.row(i);
|
||||
let planes = [
|
||||
@@ -4429,6 +4478,25 @@ mod tests {
|
||||
assert_eq!(super::outline_width(3840), 5.0);
|
||||
}
|
||||
#[test]
|
||||
fn the_projection_center_follows_the_view_bob() {
|
||||
use glam::{Mat4, Vec3};
|
||||
let projection = super::world_projection(70.0, 16.0 / 9.0, 512.0);
|
||||
let view = crate::camera::view_matrix(30.0, 20.0);
|
||||
let eye = super::projection_center(projection * view).unwrap();
|
||||
assert!(eye.length() < 1e-5, "{eye}");
|
||||
// A pose translated by the bob: the eye is where the pose's inverse
|
||||
// takes the view's origin.
|
||||
let bob = Mat4::from_rotation_z(0.02) * Mat4::from_translation(Vec3::new(0.03, -0.08, 0.0));
|
||||
let expected = (bob * view).inverse().transform_point3(Vec3::ZERO);
|
||||
let eye = super::projection_center(projection * bob * view).unwrap();
|
||||
assert!(eye.distance(expected) < 1e-5, "{eye} {expected}");
|
||||
assert!(expected.length() > 0.05);
|
||||
assert!(
|
||||
super::projection_center(Mat4::orthographic_rh(-1.0, 1.0, -1.0, 1.0, 0.0, 1.0))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() {
|
||||
let validated = |backends| {
|
||||
super::instance_descriptor(backends)
|
||||
|
||||
@@ -46,13 +46,15 @@ const RETEST: u32 = 8;
|
||||
/// more than they save. Else one frame in `PROBE` tests every section.
|
||||
const MIN_SHARE: f32 = 0.1;
|
||||
const PROBE: u32 = 64;
|
||||
/// On Metal, section draws from which a frame culls. Apple's GPUs store and
|
||||
/// reload the whole frame at each of the passes the test adds, a fixed cost
|
||||
/// that the sections it hides repay only at high render distances: on an M4
|
||||
/// Pro at 1080p and render distance 12 (1,000–1,600 draws) culling added
|
||||
/// about 0.05 ms of GPU time while hiding 15–30% of the solid indices; at
|
||||
/// 32 (drawn flat, over this many draws) it saved 0.25–1.6 ms.
|
||||
pub const TILED_MIN_DRAWS: usize = 4096;
|
||||
/// On Metal, sections in view from which a frame culls. Apple's GPUs store
|
||||
/// and reload the whole frame at each of the passes the test adds, a fixed
|
||||
/// cost that the sections it hides repay only at high render distances: on
|
||||
/// an M4 Pro at 1080p and render distance 12 (1,000–1,600 draws) culling
|
||||
/// added about 0.05 ms of GPU time while hiding 15–30% of the solid
|
||||
/// indices; at 32 (over 4,096 draws) it saved 0.25–1.6 ms. Those draws were
|
||||
/// 2.2 to 2.5 per section; a section's draw count now depends on the sides
|
||||
/// it shows the eye (`mesher::Sides`), so the bar counts sections.
|
||||
pub const TILED_MIN_SECTIONS: usize = 1600;
|
||||
/// Boxes over more than this share of the screen count as seen untested.
|
||||
const BIG: f32 = 0.25;
|
||||
/// Results read back at once, some frames late so the CPU never waits.
|
||||
@@ -148,8 +150,8 @@ pub struct Cull {
|
||||
pub hidden: ([usize; 2], f32),
|
||||
/// Cull every frame, whatever the hidden share (A/B checks).
|
||||
pub always: bool,
|
||||
/// Frames with fewer section draws than this do not cull.
|
||||
pub min_draws: usize,
|
||||
/// Frames with fewer sections in view than this do not cull.
|
||||
pub min_sections: usize,
|
||||
}
|
||||
|
||||
fn buffer(
|
||||
@@ -348,7 +350,7 @@ impl Cull {
|
||||
stamp: 0,
|
||||
hidden: ([0; 2], 1.0),
|
||||
always: false,
|
||||
min_draws: 0,
|
||||
min_sections: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,17 +96,19 @@ pub struct Flat {
|
||||
pub calls: Vec<Call>,
|
||||
pub phase1_calls: usize,
|
||||
pub solid_calls: usize,
|
||||
/// Frames with fewer section draws keep the per-section draws.
|
||||
pub min_draws: usize,
|
||||
/// Frames with fewer sections in view keep the per-section draws.
|
||||
pub min_sections: usize,
|
||||
range_align: u64,
|
||||
}
|
||||
|
||||
/// Section draws from which a frame is drawn flat. Below it the index list
|
||||
/// costs the GPU more than the draw calls cost the CPU: on an M4 Pro at
|
||||
/// render distance 16 (2,900 draws) the copy and the list add about 0.4 ms
|
||||
/// of GPU time for 0.5–0.8 ms less CPU time; at 32 (9,300 draws) the CPU
|
||||
/// saves 1.3 ms.
|
||||
const MIN_DRAWS: usize = 4096;
|
||||
/// Sections in view (and distant tiles) from which a frame is drawn flat.
|
||||
/// Below it the index list costs the GPU more than the draw calls cost the
|
||||
/// CPU: on an M4 Pro at render distance 16 (2,900 draws) the copy and the
|
||||
/// list add about 0.4 ms of GPU time for 0.5–0.8 ms less CPU time; at 32
|
||||
/// (9,300 draws) the CPU saves 1.3 ms. The bar was 4,096 draws when they
|
||||
/// were 2.2 to 2.5 per section; a section's draw count now depends on the
|
||||
/// sides it shows the eye (`mesher::Sides`), so it counts sections.
|
||||
const MIN_SECTIONS: usize = 1600;
|
||||
|
||||
/// Whether flat draws apply: the backend emulates multi-draw, and vertex
|
||||
/// shaders can read storage buffers large enough for an index page.
|
||||
@@ -337,10 +339,10 @@ impl Flat {
|
||||
calls: Vec::new(),
|
||||
phase1_calls: 0,
|
||||
solid_calls: 0,
|
||||
min_draws: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
|
||||
min_sections: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
|
||||
0
|
||||
} else {
|
||||
MIN_DRAWS
|
||||
MIN_SECTIONS
|
||||
},
|
||||
range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16)
|
||||
.max(1),
|
||||
|
||||
@@ -227,8 +227,6 @@ pub struct Graphics {
|
||||
pub far_detail: FarDetail,
|
||||
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
|
||||
pub cave_culling: bool,
|
||||
/// Hide shared leaf and mangrove-root faces, reducing foliage density.
|
||||
pub cull_leaves: bool,
|
||||
/// Skip drawing entities hidden behind opaque terrain.
|
||||
pub entity_culling: bool,
|
||||
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
|
||||
@@ -262,7 +260,6 @@ impl Default for Graphics {
|
||||
lod_distance: 0,
|
||||
far_detail: FarDetail::Full,
|
||||
cave_culling: true,
|
||||
cull_leaves: false,
|
||||
entity_culling: false,
|
||||
chunk_updates: 0,
|
||||
upload_budget_kib: 0,
|
||||
@@ -308,7 +305,6 @@ impl Graphics {
|
||||
"lodDistance" => g.lod_distance = number(g.lod_distance).min(1024),
|
||||
"farDetail" => g.far_detail = FarDetail::parse(value),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"cullLeaves" => g.cull_leaves = flag(g.cull_leaves),
|
||||
"entityCulling" => g.entity_culling = flag(g.entity_culling),
|
||||
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
|
||||
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
|
||||
@@ -343,12 +339,11 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\ncullLeaves:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\ngraphicsApi:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\ngraphicsApi:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
bool_text(self.cave_culling),
|
||||
bool_text(self.cull_leaves),
|
||||
bool_text(self.entity_culling),
|
||||
self.chunk_updates,
|
||||
self.upload_budget_kib,
|
||||
@@ -448,7 +443,6 @@ mod tests {
|
||||
lod_distance: 256,
|
||||
far_detail: FarDetail::Reduced,
|
||||
cave_culling: false,
|
||||
cull_leaves: true,
|
||||
entity_culling: true,
|
||||
chunk_updates: 12,
|
||||
upload_budget_kib: 4096,
|
||||
@@ -475,7 +469,7 @@ mod tests {
|
||||
Graphics::default()
|
||||
);
|
||||
assert_eq!(
|
||||
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:maybe\nentityCulling:maybe\n"),
|
||||
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\n"),
|
||||
Graphics::default()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ pub enum H {
|
||||
LodDistance,
|
||||
FarDetail,
|
||||
CaveCulling,
|
||||
CullLeaves,
|
||||
EntityCulling,
|
||||
ChunkUpdates,
|
||||
UploadBudget,
|
||||
@@ -138,11 +137,6 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
Saves,
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.",
|
||||
),
|
||||
h(
|
||||
H::CullLeaves,
|
||||
None,
|
||||
"Hides shared leaf and mangrove root faces; foliage can look less dense.",
|
||||
),
|
||||
h(
|
||||
H::EntityCulling,
|
||||
None,
|
||||
@@ -343,7 +337,6 @@ impl H {
|
||||
},
|
||||
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
H::CullLeaves => labelled("Cull Leaves", on_off(g.cull_leaves)),
|
||||
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
|
||||
H::ChunkUpdates => match g.chunk_updates {
|
||||
0 => labelled("Chunk Updates", "Unlimited"),
|
||||
@@ -418,7 +411,6 @@ impl H {
|
||||
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
|
||||
}
|
||||
H::CaveCulling => g.cave_culling = !g.cave_culling,
|
||||
H::CullLeaves => g.cull_leaves = !g.cull_leaves,
|
||||
H::EntityCulling => g.entity_culling = !g.entity_culling,
|
||||
H::Sky => g.sky = !g.sky,
|
||||
H::Fog => g.fog = !g.fog,
|
||||
@@ -455,7 +447,6 @@ impl Item {
|
||||
let g = &s.horizon;
|
||||
Some(match self {
|
||||
Item::Horizon(H::CaveCulling) => g.cave_culling,
|
||||
Item::Horizon(H::CullLeaves) => g.cull_leaves,
|
||||
Item::Horizon(H::EntityCulling) => g.entity_culling,
|
||||
Item::Horizon(H::Sky) => g.sky,
|
||||
Item::Horizon(H::Fog) => g.fog,
|
||||
@@ -1003,48 +994,6 @@ mod tests {
|
||||
assert_eq!(s.horizon.upload_budget_kib, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cull_leaves_is_opt_in_and_toggles_from_advanced_graphics() {
|
||||
let mut s = Settings::default();
|
||||
let item = Item::Horizon(H::CullLeaves);
|
||||
assert_eq!(item.switch(&s), Some(false));
|
||||
assert_eq!(item.label(&s).plain(), "Cull Leaves: Off");
|
||||
for enabled in [false, true] {
|
||||
s.horizon.cull_leaves = enabled;
|
||||
for preset in Preset::ALL {
|
||||
crate::graphics::apply_preset(preset, &mut s);
|
||||
assert_eq!(s.horizon.cull_leaves, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 4000.0);
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, size.0, size.1);
|
||||
let switch = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == item))
|
||||
.unwrap();
|
||||
let point = (switch.x + 1.0, switch.y + 1.0);
|
||||
for enabled in [false, true] {
|
||||
s.horizon.preset = Preset::Balanced;
|
||||
assert_eq!(
|
||||
ui.click(
|
||||
Level::Advanced,
|
||||
&mut s,
|
||||
point,
|
||||
size,
|
||||
[854, 480],
|
||||
false,
|
||||
false
|
||||
),
|
||||
vec![Action::ButtonClick, Action::Changed]
|
||||
);
|
||||
assert_eq!(item.switch(&s), Some(enabled));
|
||||
assert_eq!(s.horizon.preset, Preset::Custom);
|
||||
}
|
||||
assert_eq!(item.label(&s).plain(), "Cull Leaves: On");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_culling_is_opt_in_and_toggles_from_advanced_graphics() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -253,6 +253,22 @@ impl PlayerHealth {
|
||||
self.hurt_animation_time -= 1;
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.animateHurt` / `handleDamageEvent` on a client: the hurt
|
||||
/// tilt and red flash restart.
|
||||
pub fn animate_hurt(&mut self) {
|
||||
self.hurt.hurt_duration = 10;
|
||||
self.hurt.hurt_time = 10;
|
||||
self.hurt_animation_time = 10;
|
||||
}
|
||||
/// `LocalPlayer.hurtTo` with `flashOnSetHealth`: a drop below the current
|
||||
/// health starts the hurt animation.
|
||||
pub fn flash_health_change(&mut self, health: f32) {
|
||||
let damage = self.health() - health;
|
||||
if damage > 0.0 {
|
||||
self.hurt.last_hurt = damage;
|
||||
self.animate_hurt();
|
||||
}
|
||||
}
|
||||
}
|
||||
impl PlayerMotion {
|
||||
pub fn hurt(
|
||||
@@ -274,6 +290,9 @@ impl PlayerMotion {
|
||||
amount: f32,
|
||||
blocking: Option<Blocking>,
|
||||
) -> Result<BlockOutcome> {
|
||||
if self.client_side {
|
||||
return Ok(BlockOutcome::default());
|
||||
}
|
||||
let source = resources
|
||||
.damage
|
||||
.source::<()>(source, None, None, None)
|
||||
|
||||
@@ -81,6 +81,12 @@ pub const MINIMAP_SPRITE: &str = "horizon/minimap";
|
||||
pub const SKIN_SLOTS: usize = 32;
|
||||
/// Reused by the visible rows of the classic world selector.
|
||||
pub const WORLD_ICON_SLOTS: usize = 32;
|
||||
/// Atlas slots for the pack icons on the pack screen, one per icon drawn
|
||||
/// (`PackSelectionScreen.loadPackIcon` registers a texture per pack): two lists
|
||||
/// of 36-pixel rows at GUI scale 2 on a 4K screen still fit.
|
||||
pub const PACK_ICON_SLOTS: usize = 64;
|
||||
/// A pack icon slot's side: vanilla's own icons are 128x128.
|
||||
pub const PACK_ICON_SIZE: u32 = 128;
|
||||
/// Atlas slots for armour trim textures made at run time (`trims`), each the
|
||||
/// size of the largest trim texture.
|
||||
pub const TRIM_SLOTS: usize = 32;
|
||||
@@ -252,7 +258,7 @@ impl HudAtlas {
|
||||
)
|
||||
}
|
||||
/// Empty slots written at run time: map textures (game/maps.rs), the
|
||||
/// minimap, downloaded skins, world icons and the on-screen keyboard's text.
|
||||
/// minimap, downloaded skins, world and pack icons and the on-screen keyboard's text.
|
||||
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32)) {
|
||||
for i in 0..MAP_SLOTS {
|
||||
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
|
||||
@@ -284,6 +290,15 @@ impl HudAtlas {
|
||||
vec![0; 64 * 64 * 4],
|
||||
));
|
||||
}
|
||||
let side = PACK_ICON_SIZE as usize;
|
||||
for i in 0..PACK_ICON_SLOTS {
|
||||
images.push((
|
||||
format!("horizon/pack-icon{i}"),
|
||||
PACK_ICON_SIZE,
|
||||
PACK_ICON_SIZE,
|
||||
vec![0; side * side * 4],
|
||||
));
|
||||
}
|
||||
// The on-screen keyboard's text and corner masks (ui_theme), rasterized at run time.
|
||||
images.push((
|
||||
"horizon/ui-text".into(),
|
||||
|
||||
@@ -55,6 +55,9 @@ impl PlayerMotion {
|
||||
Ok(())
|
||||
}
|
||||
pub fn food_tick(&mut self, resources: &HealthResources, rules: HealthRules) -> Result<()> {
|
||||
if self.client_side {
|
||||
return Ok(());
|
||||
}
|
||||
let h = &mut self.health;
|
||||
let mut world = TickWorld {
|
||||
resources,
|
||||
|
||||
@@ -119,6 +119,88 @@ fn component_value(tag: &horizon_world::nbt::Tag) -> crate::server::json::Json {
|
||||
}
|
||||
}
|
||||
|
||||
/// The inverse of [`component_value`]: a JSON component (a server.properties
|
||||
/// prompt) as ComponentSerialization.CODEC writes it with NbtOps. An array is
|
||||
/// its first component with the rest appended (`createFromList`); a list of
|
||||
/// components mixing plain strings and objects has the strings as `{text}`.
|
||||
pub fn component_tag(v: &crate::server::json::Json) -> horizon_world::nbt::Tag {
|
||||
use crate::server::json::Json;
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
fn list(items: &[Json]) -> Tag {
|
||||
same_kind(items.iter().map(component_tag).collect())
|
||||
}
|
||||
fn same_kind(mut tags: Vec<Tag>) -> Tag {
|
||||
if tags.iter().any(|t| matches!(t, Tag::Compound(_))) {
|
||||
for t in &mut tags {
|
||||
if let Tag::String(s) = t {
|
||||
*t = Tag::compound([("text", Tag::String(s.clone()))]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Tag::List(tags)
|
||||
}
|
||||
fn value(v: &Json) -> Tag {
|
||||
match v {
|
||||
Json::Bool(b) => Tag::Byte(i8::from(*b)),
|
||||
Json::Number(n) => n.parse().map_or_else(
|
||||
|_| Tag::Double(n.parse::<f64>().unwrap_or(0.0).to_bits()),
|
||||
Tag::Int,
|
||||
),
|
||||
Json::String(s) => Tag::String(JavaString::from(s.as_str())),
|
||||
Json::Array(items) => list(items),
|
||||
Json::Object(members) => Tag::compound(
|
||||
members
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), value(v)))
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
Json::Null => Tag::String(JavaString::from("")),
|
||||
}
|
||||
}
|
||||
match v {
|
||||
Json::Array(items) if !items.is_empty() => {
|
||||
let mut first = component_tag(&items[0]);
|
||||
if let Tag::String(s) = &first {
|
||||
first = Tag::compound([("text", Tag::String(s.clone()))]);
|
||||
}
|
||||
if let Tag::Compound(map) = &mut first {
|
||||
let mut extra = match map.remove(&JavaString::from("extra")) {
|
||||
Some(Tag::List(e)) => e,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
extra.extend(items[1..].iter().map(component_tag));
|
||||
map.insert(JavaString::from("extra"), same_kind(extra));
|
||||
}
|
||||
first
|
||||
}
|
||||
Json::Object(members) => {
|
||||
if let [(key, Json::String(text))] = members.as_slice()
|
||||
&& key == "text"
|
||||
{
|
||||
return Tag::String(JavaString::from(text.as_str()));
|
||||
}
|
||||
Tag::compound(
|
||||
members
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let tag = match k.as_str() {
|
||||
"extra" | "with" => match v {
|
||||
Json::Array(items) => list(items),
|
||||
other => value(other),
|
||||
},
|
||||
_ => value(v),
|
||||
};
|
||||
(k.as_str(), tag)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
Json::String(s) | Json::Number(s) => Tag::String(JavaString::from(s.as_str())),
|
||||
Json::Bool(b) => Tag::String(JavaString::from(b.to_string().as_str())),
|
||||
_ => Tag::String(JavaString::from("")),
|
||||
}
|
||||
}
|
||||
|
||||
/// StringUtil.isValidPlayerName.
|
||||
pub fn valid_name(name: &str) -> bool {
|
||||
(1..=16).contains(&name.len()) && name.chars().all(|c| c as u32 > 32 && (c as u32) < 127)
|
||||
@@ -745,6 +827,14 @@ fn login_and_configure(
|
||||
send(c, cfg::Clientbound::RegistryData(packet))?;
|
||||
}
|
||||
send(c, cfg::Clientbound::UpdateTags(sync.tags.clone()))?;
|
||||
if let Some(pack) = &shared.config.resource_pack
|
||||
&& let Some(rejected) = offer_pack(c, pack, &mut info)?
|
||||
{
|
||||
let (reason, text) = pack_rejected(shared, name, rejected);
|
||||
let _ = send(c, cfg::Clientbound::Disconnect(cfg::Disconnect { reason }));
|
||||
lost(shared, &format!("{name} ({})", uuid_string(uuid)), &text);
|
||||
return Ok(None);
|
||||
}
|
||||
send(c, cfg::Clientbound::FinishConfiguration(Empty))?;
|
||||
loop {
|
||||
let (id, payload) = c.recv().map_err(e)?;
|
||||
@@ -763,6 +853,79 @@ fn login_and_configure(
|
||||
}))
|
||||
}
|
||||
|
||||
/// ServerboundResourcePackPacket.Action.DECLINED.
|
||||
const PACK_DECLINED: i32 = 1;
|
||||
|
||||
/// ServerCommonPacketListenerImpl.handleResourcePackResponse: a player who
|
||||
/// declined the required pack is disconnected. The reason and its text.
|
||||
fn pack_rejected(shared: &Shared, name: &str, pack: u128) -> (horizon_world::nbt::Tag, String) {
|
||||
let reason = translatable("multiplayer.requiredTexturePrompt.disconnect", &[]);
|
||||
let Some(log) = &shared.config.log else {
|
||||
return (reason, String::new());
|
||||
};
|
||||
let line = format!(
|
||||
"Disconnecting {name} due to resource pack {} rejection",
|
||||
uuid_string(pack)
|
||||
);
|
||||
(log.write)("Server thread", "INFO", &line);
|
||||
let text = horizon_protocol::text::plain(&reason, &log.lang);
|
||||
(reason, text)
|
||||
}
|
||||
|
||||
/// ServerResourcePackConfigurationTask: offer the server's pack, then wait
|
||||
/// for the client's final answer (`Action.isTerminal`: anything but
|
||||
/// ACCEPTED or DOWNLOADED). The player may take a while to answer the
|
||||
/// prompt, so the connection is kept alive meanwhile, as
|
||||
/// ServerConfigurationPacketListenerImpl.tick does. The id of a required
|
||||
/// pack the client declined, which disconnects it.
|
||||
fn offer_pack(
|
||||
c: &mut Connection,
|
||||
pack: &crate::server::properties::ResourcePackInfo,
|
||||
info: &mut ClientInformation,
|
||||
) -> Result<Option<u128>, String> {
|
||||
let e = |e: horizon_protocol::Error| e.to_string();
|
||||
let push = cfg::ResourcePackPush {
|
||||
id: pack.id,
|
||||
url: pack.url.clone(),
|
||||
hash: pack.hash.clone(),
|
||||
required: pack.required,
|
||||
prompt: pack.prompt.as_ref().map(component_tag),
|
||||
};
|
||||
c.send(&cfg::Clientbound::ResourcePackPush(push).encode())
|
||||
.map_err(e)?;
|
||||
std::thread::scope(|scope| {
|
||||
let (stop, stopped) = channel::<()>();
|
||||
let writer = &mut c.writer;
|
||||
scope.spawn(move || {
|
||||
let start = Instant::now();
|
||||
while stopped.recv_timeout(KEEP_ALIVE_EVERY) == Err(RecvTimeoutError::Timeout) {
|
||||
let id = i64::try_from(start.elapsed().as_millis()).unwrap_or(0);
|
||||
let keep_alive = cfg::Clientbound::KeepAlive(cfg::KeepAlive { id });
|
||||
if writer.send(&keep_alive.encode()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let answer = loop {
|
||||
let (id, payload) = match c.reader.recv() {
|
||||
Ok(packet) => packet,
|
||||
Err(err) => break Err(err.to_string()),
|
||||
};
|
||||
match cfg::Serverbound::decode(id, &payload) {
|
||||
Ok(cfg::Serverbound::ResourcePack(r)) if !matches!(r.action, 3 | 4) => {
|
||||
let rejected = r.action == PACK_DECLINED && pack.required;
|
||||
break Ok(rejected.then_some(r.id));
|
||||
}
|
||||
Ok(cfg::Serverbound::ClientInformation(i)) => *info = i,
|
||||
Ok(_) => {}
|
||||
Err(err) => break Err(err.to_string()),
|
||||
}
|
||||
};
|
||||
drop(stop);
|
||||
answer
|
||||
})
|
||||
}
|
||||
|
||||
fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::SocketAddr) {
|
||||
let conn_id = shared.next_id();
|
||||
let events = shared.events.clone();
|
||||
@@ -784,6 +947,7 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
|
||||
})
|
||||
.ok()
|
||||
};
|
||||
let name = parts.name.clone();
|
||||
let joined = Joined {
|
||||
conn: conn_id,
|
||||
uuid: parts.uuid,
|
||||
@@ -795,8 +959,20 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
|
||||
latency,
|
||||
address,
|
||||
};
|
||||
let required_pack = shared
|
||||
.config
|
||||
.resource_pack
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.required);
|
||||
let reason = if events.send(NetEvent::Joined(Box::new(joined))).is_ok() {
|
||||
read_loop(&mut reader, conn_id, &events, &alive)
|
||||
match read_loop(&mut reader, conn_id, &events, &alive, required_pack) {
|
||||
Ok(reason) => reason,
|
||||
Err(rejected) => {
|
||||
let (reason, text) = pack_rejected(shared, &name, rejected);
|
||||
let _ = tx.send(Out::Disconnect(reason));
|
||||
text
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"server closed".to_owned()
|
||||
};
|
||||
@@ -808,12 +984,16 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
|
||||
shared.release(parts.uuid);
|
||||
}
|
||||
|
||||
/// Forward the player's packets until the connection ends: why it did, or
|
||||
/// the id of a required resource pack the player declined (with
|
||||
/// `required_pack`), which disconnects them.
|
||||
fn read_loop(
|
||||
reader: &mut ConnReader,
|
||||
conn: u32,
|
||||
events: &Sender<NetEvent>,
|
||||
alive: &AtomicI64,
|
||||
) -> String {
|
||||
required_pack: bool,
|
||||
) -> Result<String, u128> {
|
||||
loop {
|
||||
let (id, payload) = match reader.recv() {
|
||||
Ok(p) => p,
|
||||
@@ -824,19 +1004,24 @@ fn read_loop(
|
||||
|| e.0.contains("reset by peer")
|
||||
|| e.0.contains("aborted") =>
|
||||
{
|
||||
return "Disconnected".to_owned();
|
||||
return Ok("Disconnected".to_owned());
|
||||
}
|
||||
Err(e) => return e.to_string(),
|
||||
Err(e) => return Ok(e.to_string()),
|
||||
};
|
||||
match play::Serverbound::decode(id, &payload) {
|
||||
Ok(play::Serverbound::KeepAlive(k)) => alive.store(k.id, Ordering::Relaxed),
|
||||
Ok(play::Serverbound::ResourcePack(r))
|
||||
if r.action == PACK_DECLINED && required_pack =>
|
||||
{
|
||||
return Err(r.id);
|
||||
}
|
||||
Ok(p) => {
|
||||
if events.send(NetEvent::Packet(conn, Box::new(p))).is_err() {
|
||||
return "server closed".to_owned();
|
||||
return Ok("server closed".to_owned());
|
||||
}
|
||||
}
|
||||
// A malformed packet disconnects like vanilla's decoder exception.
|
||||
Err(e) => return format!("Invalid packet {id}: {e}"),
|
||||
Err(e) => return Ok(format!("Invalid packet {id}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -988,6 +1173,24 @@ fn write_loop(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompts_become_the_components_vanilla_sends() {
|
||||
let tag = |json: &str| {
|
||||
let tag = component_tag(&crate::server::json::parse(json, true).unwrap());
|
||||
component_json(&tag)
|
||||
};
|
||||
assert_eq!(tag(r#"{"text":"Hi"}"#), r#""Hi""#);
|
||||
assert_eq!(tag(r#""Hi""#), r#""Hi""#);
|
||||
assert_eq!(
|
||||
tag(r#"["Hi ",{"text":"there","bold":true}]"#),
|
||||
r#"{"text":"Hi ","extra":[{"text":"there","bold":true}]}"#
|
||||
);
|
||||
assert_eq!(
|
||||
tag(r#"{"translate":"a","with":["b",{"text":"c","color":"red"}]}"#),
|
||||
r#"{"translate":"a","with":["b",{"text":"c","color":"red"}]}"#
|
||||
);
|
||||
}
|
||||
|
||||
fn unpack(out: Out) -> Vec<Vec<u8>> {
|
||||
match out {
|
||||
Out::Packet(body) => vec![body],
|
||||
|
||||
@@ -432,18 +432,21 @@ impl Guest {
|
||||
}
|
||||
|
||||
/// The off hand slot is conventionally 40 in saves and 45 in the menu.
|
||||
/// `synced_id` names an entry of a synced registry (enchantments, trim
|
||||
/// materials and patterns) by its id on this connection.
|
||||
pub fn stack_to_network(
|
||||
item_id: impl Fn(&str) -> i32,
|
||||
enchantment_id: impl Fn(&str) -> Option<i32>,
|
||||
synced_id: impl Fn(&str, &str) -> Option<i32>,
|
||||
s: &Option<ItemStack>,
|
||||
) -> Option<Stack> {
|
||||
network_stack(&item_id, &enchantment_id, s)
|
||||
network_stack(&item_id, &synced_id, s)
|
||||
}
|
||||
fn network_stack(
|
||||
item_id: &dyn Fn(&str) -> i32,
|
||||
enchantment_id: &dyn Fn(&str) -> Option<i32>,
|
||||
synced_id: &dyn Fn(&str, &str) -> Option<i32>,
|
||||
s: &Option<ItemStack>,
|
||||
) -> Option<Stack> {
|
||||
let enchantment_id = |name: &str| synced_id("minecraft:enchantment", name);
|
||||
let s = s.as_ref()?;
|
||||
let mut stack = Stack::new(item_id(&s.item), s.count as i32);
|
||||
if s.damage > 0 {
|
||||
@@ -481,6 +484,25 @@ fn network_stack(
|
||||
horizon_protocol::item::Component::Int(rgb),
|
||||
));
|
||||
}
|
||||
// ArmorTrim: both holders as references into the synced registries.
|
||||
if let Some((material, pattern)) = &d.trim {
|
||||
let id = |registry: &str, name: &str| {
|
||||
if name.contains(':') {
|
||||
synced_id(registry, name)
|
||||
} else {
|
||||
synced_id(registry, &format!("minecraft:{name}"))
|
||||
}
|
||||
};
|
||||
if let (Some(material), Some(pattern)) = (
|
||||
id("minecraft:trim_material", material),
|
||||
id("minecraft:trim_pattern", pattern),
|
||||
) {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::TRIM,
|
||||
horizon_protocol::item::Component::Trim { material, pattern },
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(id) = d.map_id {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::MAP_ID,
|
||||
@@ -504,7 +526,7 @@ fn network_stack(
|
||||
let charged = d
|
||||
.charged
|
||||
.iter()
|
||||
.filter_map(|c| network_stack(item_id, enchantment_id, &Some(c.clone())))
|
||||
.filter_map(|c| network_stack(item_id, synced_id, &Some(c.clone())))
|
||||
.collect();
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::CHARGED_PROJECTILES,
|
||||
@@ -545,3 +567,32 @@ fn network_stack(
|
||||
}
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_protocol::item::{Component, component};
|
||||
|
||||
#[test]
|
||||
fn trims_go_as_synced_registry_references() {
|
||||
let mut chestplate = ItemStack::new("minecraft:diamond_chestplate", 1);
|
||||
chestplate.data_mut().trim = Some(("minecraft:gold".into(), "coast".into()));
|
||||
let synced_id = |registry: &str, name: &str| match (registry, name) {
|
||||
("minecraft:trim_material", "minecraft:gold") => Some(4),
|
||||
("minecraft:trim_pattern", "minecraft:coast") => Some(1),
|
||||
_ => None,
|
||||
};
|
||||
let stack = stack_to_network(|_| 7, synced_id, &Some(chestplate.clone())).unwrap();
|
||||
assert_eq!(
|
||||
stack.component(component::TRIM),
|
||||
Some(&Component::Trim {
|
||||
material: 4,
|
||||
pattern: 1
|
||||
})
|
||||
);
|
||||
// A name the connection's registry lacks sends no trim.
|
||||
chestplate.data_mut().trim = Some(("minecraft:x".into(), "coast".into()));
|
||||
let stack = stack_to_network(|_| 7, synced_id, &Some(chestplate)).unwrap();
|
||||
assert_eq!(stack.component(component::TRIM), None);
|
||||
}
|
||||
}
|
||||
@@ -351,7 +351,7 @@ impl Hub {
|
||||
pub fn stack(&mut self, s: &Option<ItemStack>) -> Option<horizon_protocol::item::Stack> {
|
||||
let id = s.as_ref().map(|s| self.item_id(&s.item))?;
|
||||
let sync = self.sync.clone();
|
||||
stack_to_network(|_| id, |name| sync.id("minecraft:enchantment", name), s)
|
||||
stack_to_network(|_| id, |registry, name| sync.id(registry, name), s)
|
||||
}
|
||||
|
||||
pub fn broadcast(&self, packet: &C) {
|
||||
@@ -1827,7 +1827,9 @@ impl Hub {
|
||||
fn flush_advancements(&mut self, i: usize, show: bool) {
|
||||
let sync = self.sync.clone();
|
||||
let g = &mut self.guests[i];
|
||||
let unlocked = std::mem::take(&mut g.recipes_unlocked);
|
||||
let mut unlocked = std::mem::take(&mut g.recipes_unlocked);
|
||||
// A recipe given and taken before this flush must not reappear.
|
||||
unlocked.retain(|id| g.recipe_book.knows(id));
|
||||
if let Some(add) = sync.advancements.recipes_added(&unlocked, &sync) {
|
||||
g.send(&raw(ids::RECIPE_BOOK_ADD, add));
|
||||
}
|
||||
|
||||
@@ -139,6 +139,9 @@ pub struct NetConfig {
|
||||
/// Online mode: players are authenticated (none on a LAN world).
|
||||
pub online: Option<Arc<OnlineMode>>,
|
||||
pub log: Option<NetLog>,
|
||||
/// server.properties `resource-pack`: offered to every player during
|
||||
/// configuration (a dedicated server's; MinecraftServer.getServerResourcePack).
|
||||
pub resource_pack: Option<crate::server::properties::ResourcePackInfo>,
|
||||
}
|
||||
impl NetConfig {
|
||||
/// MinecraftServer.enforceSecureProfile.
|
||||
@@ -164,6 +167,7 @@ impl NetConfig {
|
||||
gate: None,
|
||||
online: None,
|
||||
log: None,
|
||||
resource_pack: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ pub fn uuid_ints(uuid: u128) -> [i32; 4] {
|
||||
uuid as i32,
|
||||
]
|
||||
}
|
||||
/// `UUIDUtil.uuidFromIntArray`.
|
||||
pub fn ints_uuid(ints: [i32; 4]) -> u128 {
|
||||
ints.iter()
|
||||
.fold(0, |acc, &i| acc << 32 | u128::from(i as u32))
|
||||
}
|
||||
|
||||
/// Whether a saved file exists for this player.
|
||||
pub fn exists(world: &Path, uuid: u128) -> bool {
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod block_entity_render;
|
||||
pub mod block_transform;
|
||||
pub mod blocks;
|
||||
pub mod border_render;
|
||||
pub mod boss_overlay;
|
||||
pub mod bossbar;
|
||||
pub mod breaking;
|
||||
pub mod brewing;
|
||||
@@ -41,6 +42,7 @@ pub mod command;
|
||||
pub mod command_block;
|
||||
pub mod command_storage;
|
||||
pub mod component;
|
||||
pub mod confirm_screen;
|
||||
pub mod container_screen;
|
||||
pub mod containers;
|
||||
pub mod control;
|
||||
@@ -157,6 +159,7 @@ pub mod screen_effects;
|
||||
pub mod screen_layout;
|
||||
pub mod sculk;
|
||||
pub use horizon_server_files as server;
|
||||
pub mod server_packs;
|
||||
pub mod settings;
|
||||
pub mod shadows;
|
||||
pub mod signs;
|
||||
@@ -175,6 +178,7 @@ pub mod tab_list;
|
||||
pub mod telemetry;
|
||||
pub mod testgen;
|
||||
pub mod text;
|
||||
pub mod text_display;
|
||||
pub mod tick_chunk_membership;
|
||||
pub mod ticket_levels;
|
||||
pub mod title;
|
||||
|
||||
@@ -689,7 +689,7 @@ impl Lod {
|
||||
}
|
||||
let finer = finer.remove(&key).unwrap_or_default();
|
||||
let input = snapshot.mesh_input(key, finer);
|
||||
let mesh = mesher.mesh(
|
||||
let mut mesh = mesher.mesh(
|
||||
input.key,
|
||||
&input.columns(),
|
||||
&covered,
|
||||
@@ -698,6 +698,7 @@ impl Lod {
|
||||
&input.neighbors,
|
||||
&input.finer,
|
||||
);
|
||||
mesh.sort_solid();
|
||||
meshes.push((input.key, mesh));
|
||||
}
|
||||
Done::Mesh {
|
||||
@@ -1727,10 +1728,6 @@ mod tests {
|
||||
let expected = (key.size() * key.size() - 16 * 16) as f32;
|
||||
assert_eq!(top_area(&mesh.solid, 70.0), expected);
|
||||
assert_eq!(top_area(&mesh.translucent, 80.0 - 1.0 / 9.0), expected);
|
||||
assert_eq!(
|
||||
mesh.faces.quads.iter().sum::<u32>() as usize,
|
||||
mesh.solid.len() / 4
|
||||
);
|
||||
assert!(mesh.translucent.iter().all(|v| v.light == 240));
|
||||
let side = |axis: usize, plane: f32, low: f32, high: f32| {
|
||||
let other = if axis == 0 { 2 } else { 0 };
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
use super::{Column, Span, TileKey};
|
||||
use crate::{
|
||||
fast_hash::{FxHashMap, FxHashSet},
|
||||
mesher::{FaceGroups, OPAQUE_LAYER, SectionMesh, TerrainVertex},
|
||||
mesher::{OPAQUE_LAYER, SectionMesh, TerrainVertex},
|
||||
streaming::WorkerContext,
|
||||
};
|
||||
use horizon_assets::model::Direction;
|
||||
@@ -558,7 +558,6 @@ fn mesh_columns_with_edges(
|
||||
emit(&mut mesh, key, rect)
|
||||
}
|
||||
}
|
||||
mesh.faces = FaceGroups::group(&mut mesh.solid);
|
||||
mesh
|
||||
}
|
||||
|
||||
@@ -1003,7 +1002,6 @@ mod tests {
|
||||
}
|
||||
let mesh = mesh(&columns);
|
||||
assert_eq!(mesh.solid.len(), 6 * 4);
|
||||
assert_eq!(mesh.faces.quads.iter().sum::<u32>(), 6);
|
||||
assert!(mesh.translucent.is_empty());
|
||||
for q in mesh.solid.as_chunks::<4>().0.iter() {
|
||||
let center = q
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
//! `ModelBlockRenderer`/`BlockModelLighter` (smooth light, ambient occlusion,
|
||||
//! cardinal shading, tint sources, block offsets; see `lighting`). Fluids
|
||||
//! follow `FluidRenderer` (see `fluid`).
|
||||
mod facing;
|
||||
mod fluid;
|
||||
mod lighting;
|
||||
mod sides;
|
||||
use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::Biomes,
|
||||
@@ -13,7 +13,6 @@ use crate::{
|
||||
chunk::{Chunk, MIN_Y},
|
||||
models::catalog::BakedFace,
|
||||
};
|
||||
pub use facing::{FaceGroups, GROUPS as FACE_GROUPS};
|
||||
use horizon_core::{
|
||||
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
|
||||
};
|
||||
@@ -22,6 +21,7 @@ use horizon_world::{
|
||||
fluid::{FluidKind, FluidState},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
pub use sides::Sides;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// An entity, item and effect vertex (the entity pipelines), with a face
|
||||
@@ -116,8 +116,10 @@ mod vertex_tests {
|
||||
}
|
||||
pub struct SectionMesh {
|
||||
pub solid: Vec<TerrainVertex>,
|
||||
/// `solid`'s quads by the side they face (`facing`).
|
||||
pub faces: FaceGroups,
|
||||
/// The buckets `solid` is sorted in by the side its quads face: empty
|
||||
/// until `sort_solid`, which the meshing workers call; `solid` is in
|
||||
/// model order before.
|
||||
pub sides: Sides,
|
||||
pub translucent: Vec<TerrainVertex>,
|
||||
/// VisGraph: bit `a * 6 + b` is set when faces `a` and `b` (Direction
|
||||
/// order) are connected through non-opaque blocks.
|
||||
@@ -131,13 +133,20 @@ impl Default for SectionMesh {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
solid: Vec::new(),
|
||||
faces: FaceGroups::default(),
|
||||
sides: Sides::default(),
|
||||
translucent: Vec::new(),
|
||||
visibility: ALL_VISIBLE,
|
||||
block_entities: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SectionMesh {
|
||||
/// Sorts the solid quads by the side they face, for the renderer to
|
||||
/// leave out those facing away from the eye (`sides`).
|
||||
pub fn sort_solid(&mut self) {
|
||||
self.sides = sides::sort(&mut self.solid);
|
||||
}
|
||||
}
|
||||
pub const ALL_VISIBLE: u64 = (1 << 36) - 1;
|
||||
/// VisGraph.resolve: flood fill non-opaque cells and connect the faces each region touches.
|
||||
fn visibility(pad: &Padded, info: &[Info]) -> u64 {
|
||||
@@ -230,8 +239,6 @@ fn visibility(pad: &Padded, info: &[Info]) -> u64 {
|
||||
result
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod leaf_culling_tests;
|
||||
#[cfg(test)]
|
||||
mod particle_tests;
|
||||
#[cfg(test)]
|
||||
mod visibility_tests;
|
||||
@@ -277,7 +284,6 @@ struct Info {
|
||||
overlay: bool,
|
||||
/// `LeavesBlock`: with opaque leaves the faces between two leaf blocks are skipped.
|
||||
leaves: bool,
|
||||
mangrove_roots: bool,
|
||||
blocks_flow: bool,
|
||||
ice: bool,
|
||||
sturdy: u8,
|
||||
@@ -440,7 +446,6 @@ impl MeshTables {
|
||||
legacy_solid: s.legacy_solid,
|
||||
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
|
||||
leaves: s.has_class("LeavesBlock"),
|
||||
mangrove_roots: s.has_class("MangroveRootsBlock"),
|
||||
blocks_flow: assets.blocks_fluid_flow.contains(&*s.name),
|
||||
ice: s.has_class("IceBlock"),
|
||||
sturdy: s.sturdy_full,
|
||||
@@ -692,18 +697,10 @@ pub struct Mesher<'a> {
|
||||
pub smooth_lighting: bool,
|
||||
/// The `cutoutLeaves` option (fancy graphics); off makes leaves opaque.
|
||||
pub cutout_leaves: bool,
|
||||
/// Cull shared leaf and mangrove-root faces, including transparent leaves.
|
||||
pub cull_leaves: bool,
|
||||
}
|
||||
impl Mesher<'_> {
|
||||
/// `region[dz][dx]` are the chunks around the meshed column (centre 1,1).
|
||||
pub fn mesh(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> SectionMesh {
|
||||
let mut out = self.tesselate(region, sy);
|
||||
out.faces = facing::group(&mut out.solid);
|
||||
out
|
||||
}
|
||||
/// The section's quads in model order, before `facing::group`.
|
||||
pub(crate) fn tesselate(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> SectionMesh {
|
||||
let mut out = SectionMesh::default();
|
||||
let center = region[1][1].expect("centre chunk");
|
||||
let section = ¢er.sections[sy as usize];
|
||||
@@ -721,6 +718,8 @@ impl Mesher<'_> {
|
||||
[center.x, center.z, base_y],
|
||||
);
|
||||
let info = &self.tables.info;
|
||||
let catalog = &self.render.model_catalog;
|
||||
let mut models = Vec::new();
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
@@ -750,13 +749,15 @@ impl Mesher<'_> {
|
||||
continue;
|
||||
}
|
||||
let world = BlockPos::new(center.x * 16 + x, base_y + y, center.z * 16 + z);
|
||||
let Ok(models) = self.render.model_catalog.collect(BlockStateId(id), world)
|
||||
else {
|
||||
if catalog
|
||||
.models_at(BlockStateId(id), world, &mut models)
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
};
|
||||
}
|
||||
// ModelBlockRenderer: the first part decides smooth lighting
|
||||
// for all; light-emitting blocks are always lit flat.
|
||||
let Some((_, first)) = models.first() else {
|
||||
let Some(first) = models.first() else {
|
||||
continue;
|
||||
};
|
||||
let ao = self.smooth_lighting && state.emission == 0 && first.ambient_occlusion;
|
||||
@@ -773,12 +774,9 @@ impl Mesher<'_> {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
|
||||
let other = &info[neighbor as usize];
|
||||
// Adapted from CullLeaves (MIT); see THIRD_PARTY_NOTICES.md.
|
||||
let shared_foliage = (state.leaves
|
||||
&& other.leaves
|
||||
&& (!self.cutout_leaves || self.cull_leaves))
|
||||
|| (self.cull_leaves && state.mangrove_roots && other.mangrove_roots);
|
||||
visible[d as usize] = !shared_foliage
|
||||
// LeavesBlock.skipRendering with opaque leaves.
|
||||
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
|
||||
visible[d as usize] = !opaque_leaves
|
||||
&& self
|
||||
.visibility
|
||||
.should_render_face(
|
||||
@@ -789,7 +787,7 @@ impl Mesher<'_> {
|
||||
)
|
||||
.unwrap_or(true);
|
||||
}
|
||||
for (_, model) in &models {
|
||||
for model in &models {
|
||||
for face in &model.faces {
|
||||
if face.quad.cull.is_some_and(|d| !visible[d as usize]) {
|
||||
continue;
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
//! Solid quads grouped by the side they face, as Sodium's `ModelQuadFacing`
|
||||
//! does. A quad in the plane `x = p` facing +X is front-facing only while the
|
||||
//! eye is at `x > p`, and terrain culls back faces, so when the eye is on the
|
||||
//! back side of every quad of a group the renderer skips the group's draw:
|
||||
//! the GPU would have discarded all of it after shading its vertices. The
|
||||
//! picture is unchanged; the vertex work for those quads is gone.
|
||||
use super::TerrainVertex;
|
||||
use glam::Vec3;
|
||||
|
||||
/// Groups in buffer order: the negative sides, quads not in an axis plane
|
||||
/// (rotated model elements, sloped fluid tops), then the positive sides.
|
||||
/// With the eye outside a section on every axis, one side per axis is
|
||||
/// visible, so the drawn groups form one to three runs.
|
||||
pub const GROUPS: usize = 7;
|
||||
const UNALIGNED: usize = 3;
|
||||
/// The group of a quad facing `-axis` (0 x, 1 y, 2 z) is `NEGATIVE[axis]`, of
|
||||
/// one facing `+axis` `GROUPS - 1 - NEGATIVE[axis]`.
|
||||
const NEGATIVE: [usize; 3] = [0, 2, 1];
|
||||
/// How far (blocks) the eye must be behind every plane of a group before
|
||||
/// the group is skipped: rounding on the GPU never makes such a quad
|
||||
/// front-facing.
|
||||
const MARGIN: f32 = 1.0 / 16.0;
|
||||
|
||||
/// The solid quads' groups, filled by `group`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FaceGroups {
|
||||
/// Quads per group, in buffer order.
|
||||
pub quads: [u32; GROUPS],
|
||||
/// Per axis group, the plane nearest the side the group faces: the
|
||||
/// lowest plane of a positive group and the highest of a negative one.
|
||||
planes: [f32; GROUPS],
|
||||
}
|
||||
|
||||
impl Default for FaceGroups {
|
||||
/// Everything unaligned: always drawn.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
quads: [0; GROUPS],
|
||||
planes: [0.0; GROUPS],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FaceGroups {
|
||||
/// Group arbitrary-sized terrain quads, including distant LOD tiles.
|
||||
pub fn group(solid: &mut Vec<TerrainVertex>) -> Self {
|
||||
group(solid)
|
||||
}
|
||||
|
||||
/// Whether group `g` can hold a front-facing quad for an eye at `eye`,
|
||||
/// relative to the section's minimum corner.
|
||||
pub fn visible(&self, g: usize, eye: Vec3) -> bool {
|
||||
if g == UNALIGNED {
|
||||
return true;
|
||||
}
|
||||
let (axis, positive) = axis(g);
|
||||
if positive {
|
||||
eye[axis] > self.planes[g] - MARGIN
|
||||
} else {
|
||||
eye[axis] < self.planes[g] + MARGIN
|
||||
}
|
||||
}
|
||||
/// The quad ranges (first quad, quad count) to draw for an eye at `eye`
|
||||
/// relative to the section's minimum corner, adjacent groups merged.
|
||||
pub fn ranges(&self, eye: Vec3, mut out: impl FnMut(u32, u32)) {
|
||||
let mut first = 0;
|
||||
let mut run: Option<(u32, u32)> = None;
|
||||
for g in 0..GROUPS {
|
||||
let quads = self.quads[g];
|
||||
if quads > 0 {
|
||||
if self.visible(g, eye) {
|
||||
run = Some(match run {
|
||||
Some((start, len)) => (start, len + quads),
|
||||
None => (first, quads),
|
||||
});
|
||||
} else if let Some((start, len)) = run.take() {
|
||||
out(start, len);
|
||||
}
|
||||
}
|
||||
first += quads;
|
||||
}
|
||||
if let Some((start, len)) = run {
|
||||
out(start, len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The axis and sign of each axis group (`UNALIGNED`'s entry is unused).
|
||||
const AXES: [(usize, bool); GROUPS] = {
|
||||
let mut axes = [(0, false); GROUPS];
|
||||
let mut axis = 0;
|
||||
while axis < 3 {
|
||||
axes[NEGATIVE[axis]] = (axis, false);
|
||||
axes[GROUPS - 1 - NEGATIVE[axis]] = (axis, true);
|
||||
axis += 1;
|
||||
}
|
||||
axes
|
||||
};
|
||||
|
||||
/// The axis and sign of axis group `g`.
|
||||
fn axis(g: usize) -> (usize, bool) {
|
||||
debug_assert_ne!(g, UNALIGNED);
|
||||
AXES[g]
|
||||
}
|
||||
|
||||
/// The group of one quad, and its plane: an axis group only when all four
|
||||
/// corners lie exactly in one axis plane.
|
||||
fn facing(quad: &[TerrainVertex]) -> (usize, f32) {
|
||||
let p = |i: usize| Vec3::from(quad[i].pos);
|
||||
let normal = (p(1) - p(0)).cross(p(2) - p(0));
|
||||
for axis in 0..3 {
|
||||
let [a, b] = [(axis + 1) % 3, (axis + 2) % 3];
|
||||
let plane = quad[0].pos[axis];
|
||||
if normal[axis] != 0.0
|
||||
&& normal[a] == 0.0
|
||||
&& normal[b] == 0.0
|
||||
&& quad.iter().all(|v| v.pos[axis] == plane)
|
||||
{
|
||||
let negative = NEGATIVE[axis];
|
||||
let g = if normal[axis] > 0.0 {
|
||||
GROUPS - 1 - negative
|
||||
} else {
|
||||
negative
|
||||
};
|
||||
return (g, plane);
|
||||
}
|
||||
}
|
||||
(UNALIGNED, 0.0)
|
||||
}
|
||||
|
||||
/// Reorder `solid` by group, keeping the order of quads within a group (so
|
||||
/// coplanar quads such as a grass block's overlay still draw after their
|
||||
/// base), and return the groups.
|
||||
pub fn group(solid: &mut Vec<TerrainVertex>) -> FaceGroups {
|
||||
let mut groups = FaceGroups::default();
|
||||
for (g, plane) in groups.planes.iter_mut().enumerate() {
|
||||
if g != UNALIGNED {
|
||||
*plane = if axis(g).1 { f32::MAX } else { f32::MIN };
|
||||
}
|
||||
}
|
||||
let faces: Vec<u8> = solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|quad| {
|
||||
let (g, plane) = facing(quad);
|
||||
groups.quads[g] += 1;
|
||||
if g != UNALIGNED {
|
||||
let nearest = &mut groups.planes[g];
|
||||
*nearest = if axis(g).1 {
|
||||
nearest.min(plane)
|
||||
} else {
|
||||
nearest.max(plane)
|
||||
};
|
||||
}
|
||||
g as u8
|
||||
})
|
||||
.collect();
|
||||
if faces.windows(2).all(|w| w[0] <= w[1]) {
|
||||
return groups;
|
||||
}
|
||||
let mut next = [0usize; GROUPS];
|
||||
let mut start = 0;
|
||||
for g in 0..GROUPS {
|
||||
next[g] = start;
|
||||
start += groups.quads[g] as usize;
|
||||
}
|
||||
let mut sorted = vec![solid[0]; solid.len()];
|
||||
for (quad, &g) in solid.as_chunks::<4>().0.iter().zip(&faces) {
|
||||
let at = next[g as usize] * 4;
|
||||
sorted[at..at + 4].copy_from_slice(quad);
|
||||
next[g as usize] += 1;
|
||||
}
|
||||
*solid = sorted;
|
||||
groups
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn vertex(pos: [f32; 3], layer: u16) -> TerrainVertex {
|
||||
TerrainVertex::new(pos, [0.0; 2], layer, 0, 0)
|
||||
}
|
||||
/// A unit quad in the plane `axis = plane`, wound to face `sign`, tagged
|
||||
/// with `layer` so tests can follow it.
|
||||
fn quad(axis: usize, plane: f32, sign: f32, layer: u16) -> Vec<TerrainVertex> {
|
||||
let corner = |u: f32, v: f32| {
|
||||
let mut p = [0.0; 3];
|
||||
p[axis] = plane;
|
||||
p[(axis + 1) % 3] = u;
|
||||
p[(axis + 2) % 3] = v;
|
||||
vertex(p, layer)
|
||||
};
|
||||
let mut q = vec![
|
||||
corner(0.0, 0.0),
|
||||
corner(1.0, 0.0),
|
||||
corner(1.0, 1.0),
|
||||
corner(0.0, 1.0),
|
||||
];
|
||||
if sign < 0.0 {
|
||||
q.reverse();
|
||||
}
|
||||
q
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quads_group_by_the_side_their_winding_faces() {
|
||||
for a in 0..3 {
|
||||
for sign in [-1.0, 1.0] {
|
||||
let q = quad(a, 4.0, sign, 0);
|
||||
let (g, plane) = facing(&q);
|
||||
assert_eq!(axis(g), (a, sign > 0.0), "axis {a} sign {sign}");
|
||||
assert_eq!(plane, 4.0);
|
||||
let normal = (Vec3::from(q[1].pos) - Vec3::from(q[0].pos))
|
||||
.cross(Vec3::from(q[2].pos) - Vec3::from(q[0].pos));
|
||||
assert_eq!(normal[a].signum(), sign);
|
||||
}
|
||||
}
|
||||
// A cross plant's diagonal quad faces no axis.
|
||||
let diagonal = [
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
]
|
||||
.map(|p| vertex(p, 0));
|
||||
assert_eq!(facing(&diagonal).0, UNALIGNED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouping_is_stable_and_skips_only_back_facing_groups() {
|
||||
// +X at x = 3 and x = 7 (layers 1, 2), -X at x = 5 (3), an unaligned
|
||||
// quad (4), a coplanar overlay on the x = 3 quad (5).
|
||||
let diagonal = [
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
]
|
||||
.map(|p| vertex(p, 4));
|
||||
let mut solid = [
|
||||
quad(0, 3.0, 1.0, 1),
|
||||
quad(0, 5.0, -1.0, 3),
|
||||
diagonal.to_vec(),
|
||||
quad(0, 7.0, 1.0, 2),
|
||||
quad(0, 3.0, 1.0, 5),
|
||||
]
|
||||
.concat();
|
||||
let groups = group(&mut solid);
|
||||
let layers: Vec<u16> = solid.chunks(4).map(|q| q[0].layer).collect();
|
||||
assert_eq!(layers, [3, 4, 1, 2, 5]);
|
||||
let ranges = |eye: Vec3| {
|
||||
let mut out = Vec::new();
|
||||
groups.ranges(eye, |first, count| out.push((first, count)));
|
||||
out
|
||||
};
|
||||
// Between the planes: every group can face the eye.
|
||||
assert_eq!(ranges(Vec3::new(4.0, 8.0, 8.0)), [(0, 5)]);
|
||||
// Beyond x = 7: the -X quad at x = 5 faces away.
|
||||
assert_eq!(ranges(Vec3::new(20.0, 8.0, 8.0)), [(1, 4)]);
|
||||
// Before x = 3: every +X quad faces away.
|
||||
assert_eq!(ranges(Vec3::new(-10.0, 8.0, 8.0)), [(0, 2)]);
|
||||
// Within the margin of the nearest plane: still drawn.
|
||||
assert_eq!(ranges(Vec3::new(3.0, 8.0, 8.0)), [(0, 5)]);
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
use super::*;
|
||||
use horizon_assets::model::Direction as ModelDirection;
|
||||
|
||||
#[test]
|
||||
fn leaf_culling_removes_only_internal_leaf_and_root_faces() {
|
||||
let root =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
if !root.join("horizon-resources.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let state = |name| assets.registry.default_state(name).unwrap().0;
|
||||
let oak = state("minecraft:oak_leaves");
|
||||
let birch = state("minecraft:birch_leaves");
|
||||
let roots = state("minecraft:mangrove_roots");
|
||||
let stone = state("minecraft:stone");
|
||||
let mesh = |blocks: &[([i32; 3], u32)], cull_leaves, cutout_leaves| {
|
||||
let mut center = Chunk::empty(0, 0);
|
||||
let mut east = Chunk::empty(1, 0);
|
||||
for &([x, y, z], state) in blocks {
|
||||
let chunk = if x < 16 { &mut center } else { &mut east };
|
||||
chunk.set(x, y, z, state);
|
||||
}
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(¢er);
|
||||
region[1][2] = Some(&east);
|
||||
Mesher {
|
||||
nether_shading: false,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
biomes: &biomes,
|
||||
visibility: visibility.clone(),
|
||||
zoom: BiomeZoom::new(0),
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves,
|
||||
cull_leaves,
|
||||
}
|
||||
.tesselate(®ion, (0 - MIN_Y) / 16)
|
||||
.solid
|
||||
};
|
||||
|
||||
// Native leaves can contain unculled interior geometry as well as their
|
||||
// outer faces. Select the touching cull group from the actual baked model,
|
||||
// preserving every other quad and its full vertex attributes.
|
||||
let without_face = |at: [i32; 3], id, direction| {
|
||||
let vertices = mesh(&[(at, id)], false, true);
|
||||
let models = render
|
||||
.model_catalog
|
||||
.collect(BlockStateId(id), BlockPos::new(at[0], at[1], at[2]))
|
||||
.unwrap();
|
||||
let faces: Vec<_> = models.iter().flat_map(|(_, model)| &model.faces).collect();
|
||||
assert_eq!(vertices.len(), faces.len() * 4);
|
||||
let retained: Vec<_> = vertices
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.zip(faces)
|
||||
.filter(|(_, face)| face.quad.cull != Some(direction))
|
||||
.flat_map(|(quad, _)| quad.iter().copied())
|
||||
.collect();
|
||||
assert!(retained.len() < vertices.len(), "fixture has a cull group");
|
||||
retained
|
||||
};
|
||||
|
||||
// Same-species leaves, mixed leaves and roots hide only their touching
|
||||
// model groups. Disabling the option restores the isolated models.
|
||||
for (a, b) in [(oak, oak), (oak, birch), (roots, roots)] {
|
||||
let blocks = [([7, 7, 7], a), ([8, 7, 7], b)];
|
||||
let off = mesh(&blocks, false, true);
|
||||
let on = mesh(&blocks, true, true);
|
||||
assert_eq!(
|
||||
off.len(),
|
||||
mesh(&[blocks[0]], false, true).len() + mesh(&[blocks[1]], false, true).len()
|
||||
);
|
||||
let mut retained = without_face(blocks[0].0, a, ModelDirection::East);
|
||||
retained.extend(without_face(blocks[1].0, b, ModelDirection::West));
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&on),
|
||||
bytemuck::cast_slice::<_, u8>(&retained)
|
||||
);
|
||||
}
|
||||
|
||||
// Roots do not join leaves into a single culling group. Ordinary opaque
|
||||
// neighbors also keep their existing occlusion behavior.
|
||||
for (a, b) in [(oak, roots), (roots, oak), (oak, stone)] {
|
||||
let blocks = [([7, 7, 7], a), ([8, 7, 7], b)];
|
||||
let off = mesh(&blocks, false, true);
|
||||
let on = mesh(&blocks, true, true);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&on),
|
||||
bytemuck::cast_slice::<_, u8>(&off)
|
||||
);
|
||||
}
|
||||
|
||||
// Opaque leaves already hide internal leaf faces independently of this
|
||||
// option, and a leaf beside air retains its complete baked model.
|
||||
let pair = [([7, 7, 7], oak), ([8, 7, 7], birch)];
|
||||
assert_eq!(
|
||||
mesh(&pair, false, false).len(),
|
||||
mesh(&pair, true, false).len()
|
||||
);
|
||||
let isolated = [([7, 7, 7], oak)];
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&mesh(&isolated, false, true)),
|
||||
bytemuck::cast_slice::<_, u8>(&mesh(&isolated, true, true))
|
||||
);
|
||||
|
||||
// The padding read must also remove the center chunk's internal face
|
||||
// against a different leaf species in the neighboring chunk.
|
||||
let boundary = [([15, 7, 7], oak), ([16, 7, 7], birch)];
|
||||
assert_eq!(
|
||||
mesh(&boundary, false, true).len(),
|
||||
mesh(&[boundary[0]], false, true).len()
|
||||
);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&mesh(&boundary, true, true)),
|
||||
bytemuck::cast_slice::<_, u8>(&without_face(boundary[0].0, oak, ModelDirection::East))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
//! A mesh's solid quads sorted by the side they face, so that the renderer
|
||||
//! can leave out the quads turned away from the eye before the GPU shades
|
||||
//! their vertices.
|
||||
//!
|
||||
//! A quad in the plane `x = p` whose corners go counter-clockwise seen from
|
||||
//! +X faces +X. From an eye at `x < p` it is seen from behind, and at
|
||||
//! `x = p` edge on; the solid pipeline culls back faces, so it draws nothing
|
||||
//! from there. A bucket of such quads draws nothing at all while the eye is
|
||||
//! at or below the lowest of their planes, and likewise for the other five
|
||||
//! axis directions. The seventh bucket holds every other quad (sloped,
|
||||
//! rotated, degenerate, or whose two triangles wind opposite ways): it is
|
||||
//! drawn from everywhere.
|
||||
//!
|
||||
//! The buckets lie one after another in the mesh, in an order chosen so that
|
||||
//! the buckets an eye can see are usually one or two runs of quads: one draw
|
||||
//! each. Inside a bucket quads keep their model order, so coplanar quads
|
||||
//! facing one way (an overlay on its base) still draw in that order.
|
||||
use super::TerrainVertex;
|
||||
|
||||
/// The buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y. Seen from
|
||||
/// above (+Y, the commonest case), each pair of horizontal sides with a
|
||||
/// common corner is then one run, or two.
|
||||
const BUCKETS: [Bucket; 7] = [
|
||||
Bucket::Side(0, true),
|
||||
Bucket::Side(2, true),
|
||||
Bucket::Other,
|
||||
Bucket::Side(1, true),
|
||||
Bucket::Side(2, false),
|
||||
Bucket::Side(0, false),
|
||||
Bucket::Side(1, false),
|
||||
];
|
||||
const OTHER: usize = 2;
|
||||
/// How far, in blocks, the eye must be behind a bucket's planes before the
|
||||
/// bucket is left out. The renderer computes both the eye and the section
|
||||
/// origins in `f32`, with errors far below this.
|
||||
const MARGIN: f32 = 1.0 / 16.0;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Bucket {
|
||||
/// Quads in a plane across `axis` facing its positive (`true`) or
|
||||
/// negative direction.
|
||||
Side(usize, bool),
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Where each bucket of a mesh's solid quads ends, and the planes that tell
|
||||
/// whether it can face the eye.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Sides {
|
||||
/// The solid quads up to the end of each bucket, in `BUCKETS` order.
|
||||
ends: [u32; 7],
|
||||
/// Per bucket facing a positive direction, its lowest plane; facing a
|
||||
/// negative one, its highest (unused for the other bucket).
|
||||
planes: [f32; 7],
|
||||
}
|
||||
|
||||
impl Sides {
|
||||
/// `quads` solid quads in one bucket drawn from everywhere: for meshes
|
||||
/// that were not sorted.
|
||||
pub fn unsorted(quads: u32) -> Self {
|
||||
let mut ends = [0; 7];
|
||||
ends[OTHER..].fill(quads);
|
||||
Self {
|
||||
ends,
|
||||
planes: [0.0; 7],
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of solid quads the buckets hold.
|
||||
pub fn quads(&self) -> u32 {
|
||||
self.ends[6]
|
||||
}
|
||||
|
||||
/// Calls `draw(first, count)` for each run of quads an eye at `eye`
|
||||
/// (relative to the mesh's origin) can see the front of, nearest the
|
||||
/// start first. Empty buckets do not break a run.
|
||||
pub fn visible(&self, eye: [f32; 3], mut draw: impl FnMut(u32, u32)) {
|
||||
let mut start = 0;
|
||||
let mut run: Option<u32> = None;
|
||||
for (i, bucket) in BUCKETS.iter().enumerate() {
|
||||
let end = self.ends[i];
|
||||
if end == start {
|
||||
continue;
|
||||
}
|
||||
let seen = match *bucket {
|
||||
Bucket::Side(axis, true) => eye[axis] > self.planes[i] - MARGIN,
|
||||
Bucket::Side(axis, false) => eye[axis] < self.planes[i] + MARGIN,
|
||||
Bucket::Other => true,
|
||||
};
|
||||
match (seen, run) {
|
||||
(true, None) => run = Some(start),
|
||||
(false, Some(first)) => {
|
||||
draw(first, start - first);
|
||||
run = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
if let Some(first) = run {
|
||||
draw(first, start - first);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bucket of a quad: the axis plane it lies in and the side its
|
||||
/// triangles (corners 0, 1, 2 and 0, 2, 3, as the renderer draws them) face.
|
||||
fn bucket(quad: &[TerrainVertex; 4]) -> usize {
|
||||
let p = quad.map(|v| v.pos);
|
||||
for axis in 0..3 {
|
||||
if p.iter().any(|v| v[axis] != p[0][axis]) {
|
||||
continue;
|
||||
}
|
||||
// The normal's component along `axis`, twice each triangle's area.
|
||||
let (u, w) = ((axis + 1) % 3, (axis + 2) % 3);
|
||||
let area = |a: usize, b: usize, c: usize| {
|
||||
(p[b][u] - p[a][u]) * (p[c][w] - p[a][w]) - (p[b][w] - p[a][w]) * (p[c][u] - p[a][u])
|
||||
};
|
||||
let (first, second) = (area(0, 1, 2), area(0, 2, 3));
|
||||
let positive = first >= 0.0 && second >= 0.0;
|
||||
let negative = first <= 0.0 && second <= 0.0;
|
||||
if positive == negative {
|
||||
// Opposite windings, or no area at all.
|
||||
return OTHER;
|
||||
}
|
||||
return BUCKETS
|
||||
.iter()
|
||||
.position(|b| matches!(*b, Bucket::Side(a, s) if a == axis && s == positive))
|
||||
.expect("every side has a bucket");
|
||||
}
|
||||
OTHER
|
||||
}
|
||||
|
||||
/// Reorders `solid`'s quads by bucket, keeping their order inside each.
|
||||
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
let (quads, rest) = solid.as_chunks::<4>();
|
||||
debug_assert!(rest.is_empty(), "solid vertices come in quads");
|
||||
let buckets: Vec<u8> = quads.iter().map(|q| bucket(q) as u8).collect();
|
||||
let mut counts = [0u32; 7];
|
||||
let mut planes = [0f32; 7];
|
||||
for (quad, &b) in quads.iter().zip(&buckets) {
|
||||
let b = usize::from(b);
|
||||
if let Bucket::Side(axis, positive) = BUCKETS[b] {
|
||||
let p = quad[0].pos[axis];
|
||||
planes[b] = match (counts[b], positive) {
|
||||
(0, _) => p,
|
||||
(_, true) => planes[b].min(p),
|
||||
(_, false) => planes[b].max(p),
|
||||
};
|
||||
}
|
||||
counts[b] += 1;
|
||||
}
|
||||
let mut ends = [0u32; 7];
|
||||
let mut total = 0;
|
||||
for (end, count) in ends.iter_mut().zip(counts) {
|
||||
total += count;
|
||||
*end = total;
|
||||
}
|
||||
if counts.iter().filter(|&&c| c > 0).count() > 1 {
|
||||
let mut next: [u32; 7] = std::array::from_fn(|b| ends[b] - counts[b]);
|
||||
let mut sorted = vec![quads[0]; quads.len()];
|
||||
for (quad, &b) in quads.iter().zip(&buckets) {
|
||||
let slot = &mut next[usize::from(b)];
|
||||
sorted[*slot as usize] = *quad;
|
||||
*slot += 1;
|
||||
}
|
||||
*solid = sorted.into_flattened();
|
||||
}
|
||||
Sides { ends, planes }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn quad(corners: [[f32; 3]; 4], layer: u16) -> [TerrainVertex; 4] {
|
||||
corners.map(|p| TerrainVertex::new(p, [0.0; 2], layer, 0, 0))
|
||||
}
|
||||
|
||||
/// A unit block's six faces at `origin`, wound counter-clockwise seen
|
||||
/// from outside, as block models are.
|
||||
fn cube(origin: [f32; 3], layer: u16) -> Vec<TerrainVertex> {
|
||||
let [x, y, z] = origin;
|
||||
let (a, b, c) = (x + 1.0, y + 1.0, z + 1.0);
|
||||
[
|
||||
[[a, y, z], [a, b, z], [a, b, c], [a, y, c]], // +X
|
||||
[[x, y, z], [x, y, c], [x, b, c], [x, b, z]], // -X
|
||||
[[x, b, z], [x, b, c], [a, b, c], [a, b, z]], // +Y
|
||||
[[x, y, z], [a, y, z], [a, y, c], [x, y, c]], // -Y
|
||||
[[x, y, c], [a, y, c], [a, b, c], [x, b, c]], // +Z
|
||||
[[x, y, z], [x, b, z], [a, b, z], [a, y, z]], // -Z
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|q| quad(q, layer))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn runs(sides: &Sides, eye: [f32; 3]) -> Vec<(u32, u32)> {
|
||||
let mut out = Vec::new();
|
||||
sides.visible(eye, |first, count| out.push((first, count)));
|
||||
out
|
||||
}
|
||||
|
||||
/// The quads of `solid` whose triangles face `eye`, by their layer.
|
||||
fn front_facing(solid: &[TerrainVertex], eye: [f32; 3]) -> Vec<u16> {
|
||||
let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.filter(|q| {
|
||||
[[0, 1, 2], [0, 2, 3]].iter().any(|t| {
|
||||
let (e1, e2) = (sub(q[t[1]].pos, q[t[0]].pos), sub(q[t[2]].pos, q[t[0]].pos));
|
||||
let n = [
|
||||
e1[1] * e2[2] - e1[2] * e2[1],
|
||||
e1[2] * e2[0] - e1[0] * e2[2],
|
||||
e1[0] * e2[1] - e1[1] * e2[0],
|
||||
];
|
||||
let to_eye = sub(eye, q[t[0]].pos);
|
||||
n[0] * to_eye[0] + n[1] * to_eye[1] + n[2] * to_eye[2] > 0.0
|
||||
})
|
||||
})
|
||||
.map(|q| q[0].layer)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cube_faces_go_to_their_buckets_in_order() {
|
||||
let mut solid = cube([3.0, 4.0, 5.0], 0);
|
||||
for (i, v) in solid.iter_mut().enumerate() {
|
||||
v.layer = (i / 4) as u16;
|
||||
}
|
||||
let sides = sort(&mut solid);
|
||||
assert_eq!(sides.ends, [1, 2, 2, 3, 4, 5, 6]);
|
||||
let layers: Vec<u16> = solid.iter().step_by(4).map(|v| v.layer).collect();
|
||||
// +X, +Z, +Y, -Z, -X, -Y.
|
||||
assert_eq!(layers, [0, 4, 2, 5, 1, 3]);
|
||||
assert_eq!(sides.planes[0], 4.0); // +X at x = 4
|
||||
assert_eq!(sides.planes[5], 3.0); // -X at x = 3
|
||||
assert_eq!(sides.planes[3], 5.0); // +Y at y = 5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_drawn_runs_hold_every_quad_that_faces_the_eye() {
|
||||
// Blocks across a section, with a sloped quad and a bowtie.
|
||||
let mut solid = Vec::new();
|
||||
let mut layer = 0;
|
||||
for origin in [
|
||||
[0.0, 0.0, 0.0],
|
||||
[7.0, 3.0, 12.0],
|
||||
[15.0, 15.0, 15.0],
|
||||
[2.0, 9.0, 4.0],
|
||||
] {
|
||||
for q in cube(origin, 0).chunks(4) {
|
||||
solid.extend(q.iter().map(|v| TerrainVertex { layer, ..*v }));
|
||||
layer += 1;
|
||||
}
|
||||
}
|
||||
let slope = [
|
||||
[1.0, 1.0, 1.0],
|
||||
[2.0, 1.0, 1.0],
|
||||
[2.0, 2.0, 2.0],
|
||||
[1.0, 2.0, 2.0],
|
||||
];
|
||||
let bowtie = [
|
||||
[5.0, 1.0, 1.0],
|
||||
[5.0, 2.0, 2.0],
|
||||
[5.0, 1.0, 2.0],
|
||||
[5.0, 2.0, 1.0],
|
||||
];
|
||||
for corners in [slope, bowtie] {
|
||||
solid.extend(quad(corners, layer));
|
||||
layer += 1;
|
||||
}
|
||||
let sides = sort(&mut solid);
|
||||
assert_eq!(sides.quads(), u32::from(layer));
|
||||
let eyes = [-20.0f32, -0.5, 0.0, 3.5, 8.0, 15.99, 16.0, 30.0];
|
||||
for &x in &eyes {
|
||||
for &y in &eyes {
|
||||
for &z in &eyes {
|
||||
let eye = [x, y, z];
|
||||
let mut drawn = Vec::new();
|
||||
for (first, count) in runs(&sides, eye) {
|
||||
drawn.extend(
|
||||
solid[first as usize * 4..(first + count) as usize * 4]
|
||||
.iter()
|
||||
.step_by(4)
|
||||
.map(|v| v.layer),
|
||||
);
|
||||
}
|
||||
for layer in front_facing(&solid, eye) {
|
||||
assert!(drawn.contains(&layer), "{eye:?} misses quad {layer}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_merge_across_hidden_and_empty_buckets() {
|
||||
let mut solid = cube([0.0; 3], 0);
|
||||
let sides = sort(&mut solid);
|
||||
// From above, +X and +Z: +X, +Z, (no other), +Y is one run.
|
||||
assert_eq!(runs(&sides, [8.0, 8.0, 8.0]), [(0, 3)]);
|
||||
// From below, -X and -Z: -Z, -X, -Y.
|
||||
assert_eq!(runs(&sides, [-8.0, -8.0, -8.0]), [(3, 3)]);
|
||||
// From above, -X and +Z: +Z, +Y, then -X.
|
||||
assert_eq!(runs(&sides, [-8.0, 8.0, 8.0]), [(1, 2), (4, 1)]);
|
||||
// Inside the block every face is seen from behind.
|
||||
assert!(runs(&sides, [0.5; 3]).is_empty());
|
||||
// Level with its top, the eye may see it: the margin keeps it.
|
||||
assert_eq!(runs(&sides, [0.5, 1.0, 0.5]), [(2, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsorted_meshes_draw_everything() {
|
||||
let sides = Sides::unsorted(9);
|
||||
assert_eq!(sides.quads(), 9);
|
||||
assert_eq!(runs(&sides, [-100.0; 3]), [(0, 9)]);
|
||||
assert!(runs(&Sides::unsorted(0), [0.0; 3]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_bucket_keeps_the_mesh_as_it_is() {
|
||||
let mut solid: Vec<_> = (0..3)
|
||||
.flat_map(|i| {
|
||||
quad(
|
||||
[
|
||||
[1.0, 0.0, 0.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
],
|
||||
i,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let before = solid.clone();
|
||||
let sides = sort(&mut solid);
|
||||
assert_eq!(sides.ends, [3; 7]);
|
||||
assert_eq!(
|
||||
solid.iter().map(|v| v.layer).collect::<Vec<_>>(),
|
||||
before.iter().map(|v| v.layer).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,6 @@ fn info(solid: bool) -> Info {
|
||||
legacy_solid: false,
|
||||
overlay: false,
|
||||
leaves: false,
|
||||
mangrove_roots: false,
|
||||
blocks_flow: false,
|
||||
ice: false,
|
||||
sturdy: 0,
|
||||
|
||||
@@ -736,9 +736,13 @@ pub struct Extra {
|
||||
/// The sniffer's and the ghastling's brain.
|
||||
pub animal_brain: Option<Box<animal_brain::Brain>>,
|
||||
pub axolotl: axolotl::Animation,
|
||||
/// The mob this one rides, and the mob riding it (`getVehicle`, `getFirstPassenger`).
|
||||
/// The mob or boat this one rides, and the mob riding it (`getVehicle`,
|
||||
/// `getFirstPassenger`).
|
||||
pub mount: Option<u32>,
|
||||
pub rider: Option<u32>,
|
||||
/// `Entity.boardingCooldown`: ticks after leaving a boat before a boat
|
||||
/// can pick this mob up again.
|
||||
pub boarding_cooldown: i32,
|
||||
/// A chicken carrying a zombie (`IsChickenJockey`).
|
||||
pub jockey: bool,
|
||||
/// A skeleton horse waiting to spring its trap, and how long it has waited.
|
||||
|
||||
@@ -96,7 +96,8 @@ fn xp_reward(m: &Mob) -> i32 {
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
fn is_animal(s: Species) -> bool {
|
||||
/// The mobs that extend `Animal`.
|
||||
pub fn is_animal(s: Species) -> bool {
|
||||
use Species as S;
|
||||
matches!(
|
||||
s,
|
||||
|
||||
@@ -636,11 +636,12 @@ pub fn layers(m: &Mob) -> Vec<Layer> {
|
||||
} else {
|
||||
"entity/zombie_villager"
|
||||
};
|
||||
let baby = if m.is_baby() { "baby/" } else { "" };
|
||||
// VillagerProfessionLayer: a baby's type texture is in `baby/`.
|
||||
let group = if m.is_baby() { "baby" } else { "type" };
|
||||
push(
|
||||
&mut v,
|
||||
&model,
|
||||
&format!("{folder}/type/{baby}{t}"),
|
||||
&format!("{folder}/{group}/{t}"),
|
||||
white,
|
||||
false,
|
||||
);
|
||||
@@ -1098,9 +1099,11 @@ fn render_visible(
|
||||
let mut poses = Poses::new(&part);
|
||||
anim::setup(m, &s, &mut poses, &data.animations);
|
||||
if matches!(m.species(), Species::Villager | Species::ZombieVillager)
|
||||
&& let Some((folder, variant)) = layer.texture.split_once("/type/")
|
||||
&& let Some((folder, variant)) = layer
|
||||
.texture
|
||||
.split_once("/type/")
|
||||
.or_else(|| layer.texture.split_once("/baby/"))
|
||||
{
|
||||
let variant = variant.strip_prefix("baby/").unwrap_or(variant);
|
||||
let profession = PROFESSIONS[m.extra.variant2 as usize % PROFESSIONS.len()];
|
||||
data.villager_hats
|
||||
.read()
|
||||
@@ -1275,6 +1278,44 @@ pub fn held_item_transform(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// VillagerProfessionLayer's textures (type, or baby for a baby;
|
||||
/// profession; level) of every villager and zombie villager are in the
|
||||
/// prepared resources: a missing one draws nothing.
|
||||
#[test]
|
||||
fn villager_layers_name_textures_that_exist() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("entity-models.json").exists() {
|
||||
return;
|
||||
}
|
||||
let types = super::super::MobTypes::load(&root).unwrap();
|
||||
for kind in ["minecraft:villager", "minecraft:zombie_villager"] {
|
||||
let kind = std::sync::Arc::new(types.types[kind].clone());
|
||||
for baby in [false, true] {
|
||||
for t in 0..VILLAGER_TYPES.len() {
|
||||
for p in 0..PROFESSIONS.len() {
|
||||
let mut m =
|
||||
Mob::new(1, kind.clone(), horizon_core::geometry::Vec3::ZERO, 0.0);
|
||||
m.extra.age = if baby { -24000 } else { 0 };
|
||||
(m.extra.variant, m.extra.variant2, m.extra.variant3) =
|
||||
(t as u8, p as u8, 5);
|
||||
let layers = layers(&m);
|
||||
let group = if baby { "/baby/" } else { "/type/" };
|
||||
assert!(
|
||||
layers.iter().any(|l| l.texture.contains(group)),
|
||||
"{kind:?} baby={baby}: no {group} layer"
|
||||
);
|
||||
for layer in layers {
|
||||
let png = root
|
||||
.join(format!("assets/minecraft/textures/{}.png", layer.texture));
|
||||
assert!(png.is_file(), "missing {}", png.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sulfur_cube_opaque_core_is_submitted_before_its_translucent_shell() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -41,9 +41,12 @@ pub struct BakedModel {
|
||||
}
|
||||
type VariantKey = (String, [u8; 3], bool);
|
||||
pub struct StateModels {
|
||||
choices: Vec<CompiledChoices>,
|
||||
states: BTreeMap<BlockStateId, (usize, SelectedModels)>,
|
||||
baked: BTreeMap<VariantKey, BakedModel>,
|
||||
/// Each definition's choices, a variant standing as its index in `baked`.
|
||||
choices: Vec<CompiledChoices<u32>>,
|
||||
/// By state id: its definition in `choices` and the models it selects.
|
||||
states: Vec<Option<(usize, SelectedModels)>>,
|
||||
/// Every transformed model once, with the variant it was baked from.
|
||||
baked: Vec<(Variant, BakedModel)>,
|
||||
}
|
||||
fn key(variant: &Variant) -> VariantKey {
|
||||
(variant.model.clone(), variant.rotations, variant.uv_lock)
|
||||
@@ -85,7 +88,7 @@ impl StateModels {
|
||||
models: &crate::model_library::ParsedModels,
|
||||
) -> Result<Self> {
|
||||
let mut choices = Vec::new();
|
||||
let mut states = BTreeMap::new();
|
||||
let mut states = vec![None; registry.len()];
|
||||
let mut variants = BTreeSet::new();
|
||||
let mut by_name: std::collections::HashMap<&str, Vec<BlockStateId>> =
|
||||
std::collections::HashMap::new();
|
||||
@@ -178,7 +181,7 @@ impl StateModels {
|
||||
}
|
||||
for ((id, _), selected) in native_order.into_iter().zip(bound) {
|
||||
if let Some(selected) = selected {
|
||||
states.insert(id, (choices.len(), selected));
|
||||
states[id.0 as usize] = Some((choices.len(), selected));
|
||||
}
|
||||
}
|
||||
for choice in definition
|
||||
@@ -211,7 +214,8 @@ impl StateModels {
|
||||
models.block_library_or_missing(root, names)?
|
||||
}
|
||||
};
|
||||
let mut baked = BTreeMap::new();
|
||||
let mut baked = Vec::with_capacity(variants.len());
|
||||
let mut index = BTreeMap::new();
|
||||
for (name, turns, lock) in variants {
|
||||
let model = library.resolve(&name)?;
|
||||
let particle = model
|
||||
@@ -233,16 +237,22 @@ impl StateModels {
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let cube = super::cube::cube_styles(&faces);
|
||||
baked.insert(
|
||||
(name, turns, lock),
|
||||
BakedModel {
|
||||
ambient_occlusion: model.ambient_occlusion,
|
||||
particle,
|
||||
faces,
|
||||
cube,
|
||||
},
|
||||
);
|
||||
index.insert((name.clone(), turns, lock), baked.len() as u32);
|
||||
let variant = Variant {
|
||||
model: name,
|
||||
rotations: turns,
|
||||
uv_lock: lock,
|
||||
};
|
||||
let model = BakedModel {
|
||||
ambient_occlusion: model.ambient_occlusion,
|
||||
particle,
|
||||
faces,
|
||||
cube,
|
||||
};
|
||||
baked.push((variant, model));
|
||||
}
|
||||
// Every variant of every definition was baked above.
|
||||
let choices = choices.iter().map(|c| c.map(|v| index[&key(v)])).collect();
|
||||
Ok(Self {
|
||||
choices,
|
||||
states,
|
||||
@@ -260,26 +270,47 @@ impl StateModels {
|
||||
/// `BlockStateModel.particleMaterial`: the particle sprite layer of the
|
||||
/// state's first variant (see `CompiledChoices::particle_variant`).
|
||||
pub fn particle(&self, state: BlockStateId) -> Option<u32> {
|
||||
let (choice, selected) = self.states.get(&state)?;
|
||||
let variant = self.choices[*choice].particle_variant(selected)?;
|
||||
self.baked.get(&key(variant))?.particle
|
||||
let (choice, selected) = self.state(state)?;
|
||||
let model = self.choices[*choice].particle_variant(selected)?;
|
||||
self.baked[*model as usize].1.particle
|
||||
}
|
||||
pub fn collect_seeded(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
seed: i64,
|
||||
) -> Result<Vec<(&Variant, &BakedModel)>> {
|
||||
let (choice, selected) = self
|
||||
.states
|
||||
.get(&state)
|
||||
.ok_or("unbound gallery block state")?;
|
||||
let mut models = Vec::new();
|
||||
self.each_seeded(state, seed, |(variant, model)| {
|
||||
models.push((variant, model))
|
||||
})?;
|
||||
Ok(models)
|
||||
}
|
||||
/// The models a block draws at `position`, into `out` (cleared first):
|
||||
/// `collect` for the mesher, which reuses one list for every block.
|
||||
pub fn models_at<'a>(
|
||||
&'a self,
|
||||
state: BlockStateId,
|
||||
position: BlockPos,
|
||||
out: &mut Vec<&'a BakedModel>,
|
||||
) -> Result<()> {
|
||||
out.clear();
|
||||
self.each_seeded(state, position_seed(position), |(_, model)| out.push(model))
|
||||
}
|
||||
fn each_seeded<'a>(
|
||||
&'a self,
|
||||
state: BlockStateId,
|
||||
seed: i64,
|
||||
mut f: impl FnMut(&'a (Variant, BakedModel)),
|
||||
) -> Result<()> {
|
||||
let (choice, selected) = self.state(state).ok_or("unbound gallery block state")?;
|
||||
let mut random = LegacyRandom::new(seed);
|
||||
let mut variants = Vec::new();
|
||||
self.choices[*choice].collect(selected, &mut random, &mut variants)?;
|
||||
variants
|
||||
.into_iter()
|
||||
.map(|v| Ok((v, self.baked.get(&key(v)).ok_or("unbaked block model")?)))
|
||||
.collect()
|
||||
self.choices[*choice].for_each(selected, &mut random, |&model| {
|
||||
f(&self.baked[model as usize]);
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
fn state(&self, state: BlockStateId) -> Option<&(usize, SelectedModels)> {
|
||||
self.states.get(state.0 as usize)?.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -300,6 +300,10 @@ pub struct PlayerMotion {
|
||||
/// it travels on the server: `isLocalInstanceAuthoritative` is false, so
|
||||
/// its moves check no fall damage.
|
||||
pub server_copy: bool,
|
||||
/// A client's copy of its own player (`Level.isClientSide`): only the
|
||||
/// server hurts it (`Entity.hurt` does nothing on a client) and ticks its
|
||||
/// food (`ServerPlayer.tick`). Its hurt animation comes from the server.
|
||||
pub client_side: bool,
|
||||
/// `Entity.applyEffectsFromBlocks` runs after travel (always in the game;
|
||||
/// differential fixtures turn it off where their reference does).
|
||||
pub block_effects: bool,
|
||||
@@ -465,6 +469,7 @@ impl PlayerMotion {
|
||||
freeze_immune: false,
|
||||
old_position: position,
|
||||
server_copy: false,
|
||||
client_side: false,
|
||||
block_effects: true,
|
||||
powder_snow_entered: Vec::new(),
|
||||
hurt_log: Vec::new(),
|
||||
@@ -2860,6 +2865,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A client's own fall (multiplayer, a LAN guest, single player on its
|
||||
/// server thread) plays its landing but never hurts it: only the server
|
||||
/// does (`Entity.hurt` on a client), so a server that cancels fall damage
|
||||
/// leaves the screen still.
|
||||
#[test]
|
||||
fn a_clients_own_fall_does_not_hurt_it() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
for client_side in [false, true] {
|
||||
let mut scene = World::test_scene(&assets, &render, 2).unwrap();
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 20.0, 20.5)).unwrap();
|
||||
player.client_side = client_side;
|
||||
player.fall_distance = 30.0;
|
||||
let mut w = MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
};
|
||||
w.move_self(Vec3::new(0.0, -30.0, 0.0)).unwrap();
|
||||
assert!(w.player.ground.on_ground);
|
||||
assert_eq!(w.player.fall_distance, 0.0);
|
||||
// LivingEntity.causeFallDamage's sounds play on both sides.
|
||||
assert!(w.player.events.iter().any(|e| matches!(
|
||||
e,
|
||||
MotionEvent::Fall(crate::landing::FallEvent::Sound {
|
||||
sound: crate::landing::SceneSound::BigFall,
|
||||
..
|
||||
})
|
||||
)));
|
||||
let hurt = w.player.health.health() < 20.0;
|
||||
assert_eq!(hurt, !client_side);
|
||||
assert_eq!(w.player.health.hurt_animation_time > 0, !client_side);
|
||||
// The server's health then starts the client's hurt animation.
|
||||
if client_side {
|
||||
w.player.health.flash_health_change(20.0);
|
||||
assert_eq!(w.player.health.hurt_animation_time, 0);
|
||||
w.player.health.flash_health_change(12.0);
|
||||
assert_eq!(w.player.health.hurt_animation_time, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_riptide_spin_lies_flat_and_ends_on_the_first_entity_it_touches() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -67,6 +67,9 @@ pub enum Screen {
|
||||
name: String,
|
||||
address: String,
|
||||
focus: u8,
|
||||
/// Server Resource Packs (`acceptTextures`): Enabled, Disabled, or
|
||||
/// none for Prompt.
|
||||
packs: Option<bool>,
|
||||
},
|
||||
Direct {
|
||||
address: String,
|
||||
@@ -224,6 +227,19 @@ fn entry_address(servers: &[ServerEntry], lan: &Scanner, index: usize) -> Option
|
||||
}
|
||||
}
|
||||
|
||||
/// The Server Resource Packs button's label: `CommonComponents.optionNameValue`.
|
||||
fn pack_setting_label(packs: Option<bool>) -> String {
|
||||
let (key, fallback) = match packs {
|
||||
Some(true) => ("manageServer.resourcePack.enabled", "Enabled"),
|
||||
Some(false) => ("manageServer.resourcePack.disabled", "Disabled"),
|
||||
None => ("manageServer.resourcePack.prompt", "Prompt"),
|
||||
};
|
||||
let name = crate::lang::get_or("manageServer.resourcePack", "Server Resource Packs");
|
||||
crate::lang::get_or("options.generic_value", "%s: %s")
|
||||
.replacen("%s", &name, 1)
|
||||
.replacen("%s", &crate::lang::get_or(key, fallback), 1)
|
||||
}
|
||||
|
||||
/// JoinMultiplayerScreen's `HeaderAndFooterLayout(33, 60)`.
|
||||
const HEADER: f32 = 33.0;
|
||||
const FOOTER: f32 = 60.0;
|
||||
@@ -461,7 +477,16 @@ impl Screen {
|
||||
),
|
||||
]
|
||||
}
|
||||
Self::Edit { address, .. } => vec![
|
||||
Self::Edit { address, packs, .. } => vec![
|
||||
// ManageServerScreen's CycleButton, above Done.
|
||||
b(
|
||||
w / 2.0 - 100.0,
|
||||
h / 4.0 + 72.0,
|
||||
200.0,
|
||||
&pack_setting_label(*packs),
|
||||
true,
|
||||
60,
|
||||
),
|
||||
b(
|
||||
w / 2.0 - 100.0,
|
||||
h / 4.0 + 96.0 + 18.0,
|
||||
@@ -847,6 +872,7 @@ impl Screen {
|
||||
name,
|
||||
address,
|
||||
focus,
|
||||
..
|
||||
} => {
|
||||
centered(
|
||||
batch,
|
||||
@@ -996,6 +1022,7 @@ impl Screen {
|
||||
name: "Minecraft Server".into(),
|
||||
address: String::new(),
|
||||
focus: 1,
|
||||
packs: None,
|
||||
}
|
||||
}
|
||||
(
|
||||
@@ -1011,6 +1038,7 @@ impl Screen {
|
||||
name: s.name,
|
||||
address: s.ip,
|
||||
focus: 0,
|
||||
packs: s.accept_textures,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1125,21 +1153,32 @@ impl Screen {
|
||||
*selected = Some(index);
|
||||
}
|
||||
}
|
||||
// CycleButton: Enabled, Disabled, Prompt, and round again.
|
||||
(Self::Edit { packs, .. }, Some(60)) => {
|
||||
*packs = match *packs {
|
||||
Some(true) => Some(false),
|
||||
Some(false) => None,
|
||||
None => Some(true),
|
||||
}
|
||||
}
|
||||
(
|
||||
Self::Edit {
|
||||
index,
|
||||
name,
|
||||
address,
|
||||
packs,
|
||||
..
|
||||
},
|
||||
Some(50),
|
||||
) => {
|
||||
let mut list = servers::load(&servers_file(saves));
|
||||
let entry = ServerEntry::new(name.trim(), address.trim());
|
||||
let mut entry = ServerEntry::new(name.trim(), address.trim());
|
||||
entry.accept_textures = *packs;
|
||||
match index.and_then(|i| list.get_mut(i)) {
|
||||
Some(e) => {
|
||||
e.name = entry.name;
|
||||
e.ip = entry.ip;
|
||||
e.accept_textures = entry.accept_textures;
|
||||
}
|
||||
None => list.push(entry),
|
||||
}
|
||||
@@ -1637,4 +1676,57 @@ mod tests {
|
||||
assert_eq!(names(), ["Keep"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_cycles_and_saves_the_server_resource_pack_setting() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mp-packs-{}", std::process::id()));
|
||||
let saves = dir.join("saves");
|
||||
std::fs::create_dir_all(&saves).unwrap();
|
||||
let file = servers_file(&saves);
|
||||
let mut entry = ServerEntry::new("Packs", "127.0.0.1:4");
|
||||
entry.accept_textures = Some(false);
|
||||
servers::save(&file, &[entry]).unwrap();
|
||||
let size = (427.0, 240.0);
|
||||
let mut screen = Screen::list(&saves);
|
||||
if let Screen::List { selected, .. } = &mut screen {
|
||||
*selected = Some(0);
|
||||
}
|
||||
screen.click(Some(43), 0.0, 0.0, size, &saves);
|
||||
let cycle = |screen: &Screen| {
|
||||
screen
|
||||
.buttons(size.0, size.1)
|
||||
.into_iter()
|
||||
.find(|b| b.5 == 60)
|
||||
.map(|b| (b.0, b.1, b.2, b.3))
|
||||
};
|
||||
// ManageServerScreen: 200 wide at height / 4 + 72, the saved value first.
|
||||
assert_eq!(
|
||||
cycle(&screen),
|
||||
Some((
|
||||
113.5,
|
||||
132.0,
|
||||
200.0,
|
||||
"Server Resource Packs: Disabled".into()
|
||||
))
|
||||
);
|
||||
// Disabled, Prompt, Enabled: Cancel keeps what was saved.
|
||||
screen.click(Some(60), 0.0, 0.0, size, &saves);
|
||||
assert_eq!(cycle(&screen).unwrap().3, "Server Resource Packs: Prompt");
|
||||
screen.click(Some(51), 0.0, 0.0, size, &saves);
|
||||
assert_eq!(servers::load(&file)[0].accept_textures, Some(false));
|
||||
// Done saves it.
|
||||
if let Screen::List { selected, .. } = &mut screen {
|
||||
*selected = Some(0);
|
||||
}
|
||||
screen.click(Some(43), 0.0, 0.0, size, &saves);
|
||||
screen.click(Some(60), 0.0, 0.0, size, &saves);
|
||||
screen.click(Some(60), 0.0, 0.0, size, &saves);
|
||||
assert_eq!(cycle(&screen).unwrap().3, "Server Resource Packs: Enabled");
|
||||
screen.click(Some(50), 0.0, 0.0, size, &saves);
|
||||
assert_eq!(servers::load(&file)[0].accept_textures, Some(true));
|
||||
// A new server asks.
|
||||
screen.click(Some(42), 0.0, 0.0, size, &saves);
|
||||
assert_eq!(cycle(&screen).unwrap().3, "Server Resource Packs: Prompt");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ use horizon_protocol::{
|
||||
use std::{
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc::{Receiver, Sender, channel},
|
||||
},
|
||||
thread::JoinHandle,
|
||||
@@ -43,6 +44,10 @@ pub enum Event {
|
||||
DecodeError(String),
|
||||
/// The server sent us back to configuration and finished it again.
|
||||
Reconfigured(Arc<Dynamic>),
|
||||
/// ClientboundResourcePackPushPacket, in configuration or in play.
|
||||
PackPush(cfg::ResourcePackPush),
|
||||
/// ClientboundResourcePackPopPacket: one pack, or every pack.
|
||||
PackPop(Option<u128>),
|
||||
/// The connection ended. `from_server`: the server gave the reason (or
|
||||
/// the connection was refused for good), rather than the connection
|
||||
/// failing (auto-reconnect retries only failures and shutdowns).
|
||||
@@ -55,6 +60,9 @@ pub enum Event {
|
||||
pub struct Session {
|
||||
events: Receiver<Event>,
|
||||
writer: Arc<Mutex<Option<ConnWriter>>>,
|
||||
/// The connection is in configuration (else in play). Changed only
|
||||
/// with `writer` locked, as the packet that switches is sent.
|
||||
configuring: Arc<AtomicBool>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
pub address: String,
|
||||
pub name: String,
|
||||
@@ -92,8 +100,10 @@ impl Session {
|
||||
) -> Self {
|
||||
let (send, events) = channel();
|
||||
let writer = Arc::new(Mutex::new(None));
|
||||
let configuring = Arc::new(AtomicBool::new(true));
|
||||
let counters = Arc::new(Mutex::new(None));
|
||||
let (thread_writer, thread_counters) = (writer.clone(), counters.clone());
|
||||
let thread_configuring = configuring.clone();
|
||||
let (address_owned, name_owned) = (address.to_owned(), name.to_owned());
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("horizon-net".into())
|
||||
@@ -107,6 +117,7 @@ impl Session {
|
||||
&lang,
|
||||
&send,
|
||||
&thread_writer,
|
||||
&thread_configuring,
|
||||
&thread_counters,
|
||||
);
|
||||
let from_server = result.is_ok();
|
||||
@@ -124,6 +135,7 @@ impl Session {
|
||||
Self {
|
||||
events,
|
||||
writer,
|
||||
configuring,
|
||||
thread,
|
||||
address: address.to_owned(),
|
||||
name: name.to_owned(),
|
||||
@@ -135,7 +147,12 @@ impl Session {
|
||||
pub fn poll(&self) -> Vec<Event> {
|
||||
self.events.try_iter().collect()
|
||||
}
|
||||
/// Send a play packet; silently dropped once the connection is gone.
|
||||
/// Send a play packet; silently dropped once the connection is gone,
|
||||
/// and while the server has the connection back in configuration (a
|
||||
/// proxy switching servers). Vanilla's `handleConfigurationStart` clears
|
||||
/// the level before it acknowledges, so no play packet follows the
|
||||
/// acknowledgement; one that did would reach the server as a
|
||||
/// configuration packet, and Velocity drops such a client.
|
||||
pub fn send(&self, packet: play::Serverbound) {
|
||||
if self.debug
|
||||
&& !matches!(
|
||||
@@ -147,12 +164,31 @@ impl Session {
|
||||
{
|
||||
eprintln!("[net] -> {packet:?}");
|
||||
}
|
||||
if let Some(w) = self.writer.lock().unwrap().as_mut()
|
||||
let mut writer = self.writer.lock().unwrap();
|
||||
if self.configuring.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if let Some(w) = writer.as_mut()
|
||||
&& w.send(&packet.encode()).is_err()
|
||||
{
|
||||
w.shutdown();
|
||||
}
|
||||
}
|
||||
/// ServerboundResourcePackPacket, in whichever phase the connection is.
|
||||
pub fn send_pack_status(&self, id: u128, action: i32) {
|
||||
let status = cfg::ResourcePackResponse { id, action };
|
||||
let mut writer = self.writer.lock().unwrap();
|
||||
let body = if self.configuring.load(Ordering::Relaxed) {
|
||||
cfg::Serverbound::ResourcePack(status).encode()
|
||||
} else {
|
||||
play::Serverbound::ResourcePack(status).encode()
|
||||
};
|
||||
if let Some(w) = writer.as_mut()
|
||||
&& w.send(&body).is_err()
|
||||
{
|
||||
w.shutdown();
|
||||
}
|
||||
}
|
||||
pub fn disconnect(&mut self) {
|
||||
if let Some(w) = self.writer.lock().unwrap().take() {
|
||||
w.shutdown();
|
||||
@@ -253,6 +289,7 @@ fn run(
|
||||
lang: &Language,
|
||||
send: &Sender<Event>,
|
||||
shared_writer: &Mutex<Option<ConnWriter>>,
|
||||
configuring: &AtomicBool,
|
||||
shared_counters: &Mutex<Option<Arc<Counters>>>,
|
||||
) -> Result<String, String> {
|
||||
let (host, port, mut c) = if let Some(pipe) = local {
|
||||
@@ -268,8 +305,13 @@ fn run(
|
||||
target
|
||||
}
|
||||
};
|
||||
let c =
|
||||
Connection::connect(&host, port, Duration::from_secs(10)).map_err(|e| e.to_string())?;
|
||||
let c = if address.starts_with(FRIEND) {
|
||||
// The friends relay supplies a concrete endpoint, not a Minecraft hostname.
|
||||
Connection::connect(&host, port, Duration::from_secs(10))
|
||||
} else {
|
||||
Connection::connect_server(&host, port, Duration::from_secs(10))
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
(host, port, c)
|
||||
};
|
||||
*shared_counters.lock().unwrap() = Some(c.reader.counters.clone());
|
||||
@@ -377,18 +419,19 @@ fn run(
|
||||
.map_err(e)?;
|
||||
let (mut reader, writer) = c.split();
|
||||
*shared_writer.lock().unwrap() = Some(writer);
|
||||
let registries = configure(&mut reader, shared_writer, lang, send)?;
|
||||
let registries = configure(&mut reader, shared_writer, configuring, lang, send)?;
|
||||
let _ = send.send(Event::Joined {
|
||||
registries: Arc::new(registries),
|
||||
uuid: profile.uuid,
|
||||
name: profile.name,
|
||||
});
|
||||
receive_play(&mut reader, shared_writer, lang, send)
|
||||
receive_play(&mut reader, shared_writer, configuring, lang, send)
|
||||
}
|
||||
|
||||
fn receive_play(
|
||||
reader: &mut ConnReader,
|
||||
shared_writer: &Mutex<Option<ConnWriter>>,
|
||||
configuring: &AtomicBool,
|
||||
lang: &Language,
|
||||
send: &Sender<Event>,
|
||||
) -> Result<String, String> {
|
||||
@@ -416,15 +459,17 @@ fn receive_play(
|
||||
play::Clientbound::KeepAlive(k) => reply(play::Serverbound::KeepAlive(k)),
|
||||
play::Clientbound::BundleDelimiter(_) => {}
|
||||
play::Clientbound::ResourcePackPush(p) => {
|
||||
// Declined: Horizon has no server resource packs.
|
||||
reply(play::Serverbound::ResourcePack(cfg::ResourcePackResponse {
|
||||
id: p.id,
|
||||
action: 1,
|
||||
}));
|
||||
let _ = send.send(Event::PackPush(p));
|
||||
}
|
||||
play::Clientbound::ResourcePackPop(p) => {
|
||||
let _ = send.send(Event::PackPop(p.id));
|
||||
}
|
||||
play::Clientbound::StartConfiguration(_) => {
|
||||
reply(play::Serverbound::ConfigurationAcknowledged(Empty));
|
||||
let registries = configure(reader, shared_writer, lang, send)?;
|
||||
if let Some(w) = shared_writer.lock().unwrap().as_mut() {
|
||||
let _ = w.send(&play::Serverbound::ConfigurationAcknowledged(Empty).encode());
|
||||
configuring.store(true, Ordering::Relaxed);
|
||||
}
|
||||
let registries = configure(reader, shared_writer, configuring, lang, send)?;
|
||||
let _ = send.send(Event::Reconfigured(Arc::new(registries)));
|
||||
}
|
||||
play::Clientbound::Disconnect(d) => return Ok(plain(&d.reason, lang)),
|
||||
@@ -445,6 +490,7 @@ fn receive_play(
|
||||
fn configure(
|
||||
reader: &mut ConnReader,
|
||||
writer: &Mutex<Option<ConnWriter>>,
|
||||
configuring: &AtomicBool,
|
||||
lang: &Language,
|
||||
send: &Sender<Event>,
|
||||
) -> Result<Dynamic, String> {
|
||||
@@ -471,11 +517,12 @@ fn configure(
|
||||
cfg::Clientbound::CodeOfConduct(_) => {
|
||||
reply(cfg::Serverbound::AcceptCodeOfConduct(Empty))?
|
||||
}
|
||||
// The game answers: it may prompt the player first.
|
||||
cfg::Clientbound::ResourcePackPush(p) => {
|
||||
reply(cfg::Serverbound::ResourcePack(cfg::ResourcePackResponse {
|
||||
id: p.id,
|
||||
action: 1,
|
||||
}))?
|
||||
let _ = send.send(Event::PackPush(p));
|
||||
}
|
||||
cfg::Clientbound::ResourcePackPop(p) => {
|
||||
let _ = send.send(Event::PackPop(p.id));
|
||||
}
|
||||
cfg::Clientbound::CookieRequest(q) => {
|
||||
reply(cfg::Serverbound::CookieResponse(cfg::CookieResponse {
|
||||
@@ -485,7 +532,11 @@ fn configure(
|
||||
}
|
||||
cfg::Clientbound::Disconnect(d) => return Err(plain(&d.reason, lang)),
|
||||
cfg::Clientbound::FinishConfiguration(_) => {
|
||||
reply(cfg::Serverbound::FinishConfiguration(Empty))?;
|
||||
let mut w = writer.lock().unwrap();
|
||||
let w = w.as_mut().ok_or("Disconnected")?;
|
||||
w.send(&cfg::Serverbound::FinishConfiguration(Empty).encode())
|
||||
.map_err(|e| e.to_string())?;
|
||||
configuring.store(false, Ordering::Relaxed);
|
||||
return Ok(registries);
|
||||
}
|
||||
_ => {}
|
||||
@@ -559,7 +610,7 @@ pub fn load_language(resources: &std::path::Path) -> Language {
|
||||
pub fn ping(address: &str, timeout: Duration) -> Result<(serde_json::Value, u128), String> {
|
||||
use horizon_protocol::packets::status;
|
||||
let (host, port) = parse_address(address).ok_or("Invalid address")?;
|
||||
let mut c = Connection::connect(&host, port, timeout).map_err(|e| e.to_string())?;
|
||||
let mut c = Connection::connect_server(&host, port, timeout).map_err(|e| e.to_string())?;
|
||||
c.reader
|
||||
.set_read_timeout(Some(timeout))
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -602,7 +653,13 @@ mod tests {
|
||||
let (send, events) = channel();
|
||||
let thread_writer = writer.clone();
|
||||
let thread = std::thread::spawn(move || {
|
||||
receive_play(&mut reader, &thread_writer, &Language::new(), &send)
|
||||
receive_play(
|
||||
&mut reader,
|
||||
&thread_writer,
|
||||
&AtomicBool::new(false),
|
||||
&Language::new(),
|
||||
&send,
|
||||
)
|
||||
});
|
||||
let mut server = Connection::local(server);
|
||||
let teleport = play::Clientbound::PlayerPosition(play::PlayerPosition {
|
||||
@@ -638,6 +695,60 @@ mod tests {
|
||||
assert!(thread.join().unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn play_packets_wait_out_a_reconfiguration() {
|
||||
let (client, server) = horizon_protocol::connection::Pipe::pair();
|
||||
let (mut reader, writer) = Connection::local(client).split();
|
||||
let (send, events) = channel();
|
||||
let session = Session {
|
||||
events,
|
||||
writer: Arc::new(Mutex::new(Some(writer))),
|
||||
configuring: Arc::new(AtomicBool::new(false)),
|
||||
thread: None,
|
||||
address: "local".into(),
|
||||
name: "Player".into(),
|
||||
counters: Arc::new(Mutex::new(None)),
|
||||
debug: false,
|
||||
};
|
||||
let (thread_writer, thread_configuring) =
|
||||
(session.writer.clone(), session.configuring.clone());
|
||||
let thread = std::thread::spawn(move || {
|
||||
receive_play(
|
||||
&mut reader,
|
||||
&thread_writer,
|
||||
&thread_configuring,
|
||||
&Language::new(),
|
||||
&send,
|
||||
)
|
||||
});
|
||||
let mut server = Connection::local(server);
|
||||
let tick_end = play::Serverbound::ClientTickEnd(Empty);
|
||||
server
|
||||
.send(&play::Clientbound::StartConfiguration(Empty).encode())
|
||||
.unwrap();
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(
|
||||
play::Serverbound::decode(id, &body).unwrap(),
|
||||
play::Serverbound::ConfigurationAcknowledged(Empty)
|
||||
);
|
||||
// The game thread still ticks the old level: nothing of it goes out.
|
||||
session.send(tick_end.clone());
|
||||
server
|
||||
.send(&cfg::Clientbound::FinishConfiguration(Empty).encode())
|
||||
.unwrap();
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(
|
||||
cfg::Serverbound::decode(id, &body).unwrap(),
|
||||
cfg::Serverbound::FinishConfiguration(Empty)
|
||||
);
|
||||
// Back in play, the game's packets flow again.
|
||||
session.send(tick_end.clone());
|
||||
let (id, body) = server.recv().unwrap();
|
||||
assert_eq!(play::Serverbound::decode(id, &body).unwrap(), tick_end);
|
||||
drop(session);
|
||||
assert!(thread.join().unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_brand_payload_is_decoded_and_invalid_or_unrelated_payloads_are_ignored() {
|
||||
let (send, events) = channel();
|
||||
|
||||
Loaded 100 of 396 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user