mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
315
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5115bc276b | ||
|
|
7bac73094f | ||
|
|
ef82e97d67 | ||
|
|
1920106445 | ||
|
|
aca93fa902 | ||
|
|
e99414e9d3 | ||
|
|
66a35ab2ea | ||
|
|
d41a3075aa | ||
|
|
fd154dcbba | ||
|
|
7ab91a4d2e | ||
|
|
9f761a3e3b | ||
|
|
c640561000 | ||
|
|
d18b7701da | ||
|
|
1bf957989a | ||
|
|
a230411fa3 | ||
|
|
26b94efcdf | ||
|
|
e5214f7f25 | ||
|
|
2576e80ff2 | ||
|
|
effea2cfd4 | ||
|
|
bcee0d02ec | ||
|
|
78ab03e554 | ||
|
|
ba4cf45aac | ||
|
|
a6f951e126 | ||
|
|
1a91727eee | ||
|
|
818b9638ed | ||
|
|
c462eb0b48 | ||
|
|
c1ffa17c87 | ||
|
|
4a9ca29887 | ||
|
|
bac51e3fba | ||
|
|
91148cdf74 | ||
|
|
0eb475152e | ||
|
|
e458e37249 | ||
|
|
c04b7afdb9 | ||
|
|
68234ac407 | ||
|
|
e936e412ba | ||
|
|
a95be8a7f3 | ||
|
|
bd591f9857 | ||
|
|
5d40d6a33e | ||
|
|
930a18c912 | ||
|
|
1c67541379 | ||
|
|
e28b453762 | ||
|
|
6a2512bfe7 | ||
|
|
bd7bb1dfc6 | ||
|
|
e785362aa7 | ||
|
|
6c4d1d6cbb | ||
|
|
2dbdcda89c | ||
|
|
49f62d02cb | ||
|
|
220f057647 | ||
|
|
f94b05032b | ||
|
|
923fdee42e | ||
|
|
6b97107dc7 | ||
|
|
608d1a70a7 | ||
|
|
98c8db50a3 | ||
|
|
6d418fdb3e | ||
|
|
98bebd26bd | ||
|
|
22c3f2bd0f | ||
|
|
ceff5a2110 | ||
|
|
93d05591c2 | ||
|
|
5680da9834 | ||
|
|
41e68aeb76 | ||
|
|
28ad41eb22 | ||
|
|
de195755f7 | ||
|
|
587af1e345 | ||
|
|
65fa108cba | ||
|
|
56560b467a | ||
|
|
fb1142cd71 | ||
|
|
e069750ca1 | ||
|
|
6a065fa489 | ||
|
|
e6dbc3b39f | ||
|
|
dbc66a5a41 | ||
|
|
d14b100a97 | ||
|
|
9ed21bf436 | ||
|
|
ab2006a230 | ||
|
|
b61ef8f9be | ||
|
|
bd4236f3dd | ||
|
|
8ec23a2606 | ||
|
|
1b6a55e47d | ||
|
|
c143be7fd0 | ||
|
|
da8ae88eee | ||
|
|
3a419877b0 | ||
|
|
5fefa6ce42 | ||
|
|
6e4cb6210e | ||
|
|
985ec67b83 | ||
|
|
ca05c89c6e | ||
|
|
18c74b438c | ||
|
|
ff7cfe0f10 | ||
|
|
250c148e78 | ||
|
|
40fcc91eff | ||
|
|
8b3d4a069c | ||
|
|
a839062259 | ||
|
|
6194463454 | ||
|
|
a5f574b77e | ||
|
|
c4573fb561 | ||
|
|
57fc1570b2 | ||
|
|
628e230217 | ||
|
|
6926c76492 | ||
|
|
47d1b060b2 | ||
|
|
87b3c9aa38 | ||
|
|
3740de5116 | ||
|
|
d2c96af142 | ||
|
|
0bc85843ce | ||
|
|
d33f84ef73 | ||
|
|
ad63f2e960 | ||
|
|
7d0ae56589 | ||
|
|
e3de4a4604 | ||
|
|
9ae86086dc | ||
|
|
57ee081038 | ||
|
|
3063d87199 | ||
|
|
15e46114a6 | ||
|
|
fbfec6435f | ||
|
|
e49de342ed | ||
|
|
7a3578b7d6 | ||
|
|
604ec7e2e4 | ||
|
|
21ff80eb8c | ||
|
|
f3213aa158 | ||
|
|
71f3c23dd9 | ||
|
|
574d54e6e0 | ||
|
|
c74490a003 | ||
|
|
d6cc631735 | ||
|
|
0d18ff7ca7 | ||
|
|
b7fcd446b6 | ||
|
|
f23db61ecf | ||
|
|
b470bec351 | ||
|
|
53e1191480 | ||
|
|
f28722fb15 | ||
|
|
a61c7faa23 | ||
|
|
c0bf5cf954 | ||
|
|
fb1f421117 | ||
|
|
0c5d229ad0 | ||
|
|
327a6cafc7 | ||
|
|
6a32ee68d4 | ||
|
|
0e73edceff | ||
|
|
f7bd3e381b | ||
|
|
ea0905e8a2 | ||
|
|
b149a0ec3f | ||
|
|
81b1be055c | ||
|
|
ebf76f4c03 | ||
|
|
fa093994ae | ||
|
|
273c456adc | ||
|
|
d30be3d994 | ||
|
|
c1e90297e2 | ||
|
|
9dfdff9d62 | ||
|
|
e56acaa7a9 | ||
|
|
65425da01d | ||
|
|
6b88291009 | ||
|
|
e29a71e5e0 | ||
|
|
80eafe84e9 | ||
|
|
6017830623 | ||
|
|
fb34815ce1 | ||
|
|
f49e3a2d24 | ||
|
|
cb0865e242 | ||
|
|
a27322f250 | ||
|
|
ed3243ca62 | ||
|
|
f59bb8b8d5 | ||
|
|
00705d8431 | ||
|
|
0564ab0bcd | ||
|
|
9409d2c19b | ||
|
|
eb1fdf5fb4 | ||
|
|
2ee008f8fa | ||
|
|
8a0303ed62 | ||
|
|
b15e46c3af | ||
|
|
e988b9b833 | ||
|
|
504cb4f74c | ||
|
|
659bfad86c | ||
|
|
76408b734e | ||
|
|
4e3e038c80 | ||
|
|
b0918f3be1 | ||
|
|
08491bcda3 | ||
|
|
b165506d6e | ||
|
|
09e95ae5a1 | ||
|
|
5a0e650e63 | ||
|
|
3b586ff903 | ||
|
|
6acf7fb6ab | ||
|
|
afb3ae79a2 | ||
|
|
4a675206b1 | ||
|
|
486df91b2d | ||
|
|
2c80009c70 | ||
|
|
41f8cfa3bc | ||
|
|
1ef5b82b7d | ||
|
|
5d19fcaa06 | ||
|
|
489b5a4ffa | ||
|
|
342cb6c500 | ||
|
|
eed98be9cf | ||
|
|
859236bd9d | ||
|
|
07519df062 | ||
|
|
b1eae8a5b8 | ||
|
|
cc65eeec82 | ||
|
|
792ac58f46 | ||
|
|
578f077347 | ||
|
|
b6ec56ac9b | ||
|
|
e334dd3d55 | ||
|
|
8948bf90c4 | ||
|
|
753a99b933 | ||
|
|
dc1e16f472 | ||
|
|
f2f58aca94 | ||
|
|
f9e3244281 | ||
|
|
c36e81916b | ||
|
|
87f377b29c | ||
|
|
6e86e07781 | ||
|
|
4bb7f96f81 | ||
|
|
e820e9e1af | ||
|
|
89f426c48f | ||
|
|
961af30997 | ||
|
|
c10261f008 | ||
|
|
a96dccf691 | ||
|
|
6d1f6965d2 | ||
|
|
f66c257ae5 | ||
|
|
9f1a880951 | ||
|
|
7a7bfdde79 | ||
|
|
947d65e64d | ||
|
|
fe253fe534 | ||
|
|
96b6ed4916 | ||
|
|
6240124557 | ||
|
|
e55b5b7c00 | ||
|
|
b0a5334cb5 | ||
|
|
a93c1507f9 | ||
|
|
a26297058a | ||
|
|
149864c3b4 | ||
|
|
7c27b85fbd | ||
|
|
42afd721e7 | ||
|
|
4d3f3b6391 | ||
|
|
75d70a1a74 | ||
|
|
856f0c2e40 | ||
|
|
75c009e3fc | ||
|
|
213f1bbb2e | ||
|
|
e30166f23b | ||
|
|
ba09b086a6 | ||
|
|
f07d66c7cf | ||
|
|
6d735c26d3 | ||
|
|
3dc14b6eda | ||
|
|
f500be1b5e | ||
|
|
3e3ab7a413 | ||
|
|
0253d9bc47 | ||
|
|
fab71df185 | ||
|
|
8698b4ad0c | ||
|
|
76638aaf80 | ||
|
|
eaa9f3c203 | ||
|
|
395c256d11 | ||
|
|
f31c18aa6c | ||
|
|
a2a91f98f9 | ||
|
|
75983739ec | ||
|
|
a4fa1a770d | ||
|
|
8a7831e58b | ||
|
|
3612f51812 | ||
|
|
d6e5fb5331 | ||
|
|
3b3590ea03 | ||
|
|
c41a053c15 | ||
|
|
0d1736da8f | ||
|
|
977ef7181f | ||
|
|
75b7812f68 | ||
|
|
e0344f1f79 | ||
|
|
6774b2b3e7 | ||
|
|
466b1de4c4 | ||
|
|
282ecf16a9 | ||
|
|
7072a40701 | ||
|
|
2a6347dc55 | ||
|
|
f1cfb3069f | ||
|
|
de840da0e5 | ||
|
|
8800d1878a | ||
|
|
1776d7ca5c | ||
|
|
7b56b59e7c | ||
|
|
7d76923a04 | ||
|
|
59281c5c73 | ||
|
|
303d497a67 | ||
|
|
d604409be2 | ||
|
|
2716ad41be | ||
|
|
0849283a9d | ||
|
|
40eb3e202a | ||
|
|
be34f4cd5f | ||
|
|
dbdc0389fe | ||
|
|
f602ad6fe3 | ||
|
|
b49fd602df | ||
|
|
7e513d2d5c | ||
|
|
bffb68b0c5 | ||
|
|
4ad05aa94a | ||
|
|
8c611e677b | ||
|
|
c6980fb2b7 | ||
|
|
3f1a7b28af | ||
|
|
6c6c08f4d9 | ||
|
|
7d06aca17a | ||
|
|
0e607404d1 | ||
|
|
66070b918e | ||
|
|
6b2ec5f830 | ||
|
|
d064dba945 | ||
|
|
72f88befb8 | ||
|
|
891e1c1283 | ||
|
|
d83b3e18bc | ||
|
|
fb4897ec09 | ||
|
|
37c36b548a | ||
|
|
6e8a96a734 | ||
|
|
508cb2df9f | ||
|
|
4c3abd86de | ||
|
|
a5f5d8df34 | ||
|
|
adcfe810f2 | ||
|
|
3802a97dc9 | ||
|
|
90b5e162ae | ||
|
|
b61b25f80f | ||
|
|
d742d725f4 | ||
|
|
787c3a2f19 | ||
|
|
7c1dd49810 | ||
|
|
f239955997 | ||
|
|
4c65ed2f5c | ||
|
|
352645b417 | ||
|
|
aee0b8b589 | ||
|
|
e5bfce3b37 | ||
|
|
0a1d3a7534 | ||
|
|
66235cc981 | ||
|
|
f80d00802e | ||
|
|
397d529db7 | ||
|
|
eb0a6f74e6 | ||
|
|
07e46daa59 | ||
|
|
9223b401e7 | ||
|
|
db758a622f | ||
|
|
d271176f23 | ||
|
|
7a1638a190 |
No files matched your search
+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
|
||||
@@ -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,6 +45,8 @@ 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-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
|
||||
| `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`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
|
||||
|
||||
@@ -7,6 +7,11 @@ welcome on [Discord](https://discord.gg/HcTeMZxe5E).
|
||||
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
|
||||
release them. Anything else (videos, Discord and other community material) lives elsewhere.
|
||||
|
||||
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
|
||||
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
|
||||
suggest something. A pull request for a roadmap item carries its `roadmap:<id>` label; ideas go to the suggestion
|
||||
queue (the hub, or `/suggest` on Discord) rather than to issues, which are for bugs.
|
||||
|
||||
## Build it
|
||||
|
||||
You need Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers (`libasound2-dev libudev-dev`).
|
||||
@@ -39,6 +44,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
+44
@@ -428,6 +428,8 @@ version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"termcolor",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
@@ -1266,6 +1268,7 @@ dependencies = [
|
||||
"horizon-mods",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shaders",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
@@ -1331,8 +1334,10 @@ name = "horizon-mods"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"wasmtime",
|
||||
"wit-component 0.239.0",
|
||||
@@ -1432,6 +1437,28 @@ dependencies = [
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-shader-api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"naga",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-shaders"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-core",
|
||||
"naga",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-telemetry"
|
||||
version = "0.1.0"
|
||||
@@ -1912,6 +1939,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"
|
||||
@@ -1958,6 +1991,7 @@ dependencies = [
|
||||
"naga-types",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"pp-rs",
|
||||
"rustc-hash 1.1.0",
|
||||
"spirv",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2634,6 +2668,15 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pp-rs"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb458bb7f6e250e6eb79d5026badc10a3ebb8f9a15d1fff0f13d17c71f4d6dee"
|
||||
dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "presser"
|
||||
version = "0.3.1"
|
||||
@@ -4182,6 +4225,7 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"js-sys",
|
||||
"log",
|
||||
"naga",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"profiling",
|
||||
|
||||
+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/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-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<a href="#download"><b>Download</b></a> ·
|
||||
<a href="https://playhorizon.dev/docs/"><b>Documentation</b></a> ·
|
||||
<a href="https://github.com/ludovic111/horizon/releases"><b>Release notes</b></a> ·
|
||||
<a href="ROADMAP.md"><b>Roadmap</b></a> ·
|
||||
<a href="#project-status"><b>Project status</b></a> ·
|
||||
<a href="#contributing"><b>Contributing</b></a>
|
||||
</p>
|
||||
@@ -109,6 +110,10 @@ Everything prepared lands in the gitignored `.local/`. It is derived from the ga
|
||||
|
||||
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl` and the architecture are described in [the native client guide](docs/native-client.md). The rest of the developer documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
|
||||
|
||||
## Roadmap
|
||||
|
||||
What is being done now, what comes next and where you can help, in plain words and in technical detail: [ROADMAP.md](ROADMAP.md). [playhorizon.dev/hub](https://playhorizon.dev/hub) shows it with the work board, the journal of what shipped and how to help.
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) for how we work (vanilla first, small pull requests, labels), or [the contributing guide](https://playhorizon.dev/docs/contributing) on the site. Questions and ideas: [Discord](https://discord.gg/HcTeMZxe5E). Before opening a pull request, run the self-check and paste the block it prints:
|
||||
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
# Horizon roadmap
|
||||
|
||||
<!-- Generated by tools/roadmap.py from roadmap/roadmap.toml: edit that file, then run `python3 tools/roadmap.py build`. -->
|
||||
|
||||
What is being done on Horizon, what comes next and where you can help. There are no dates: things are Now, Next or Later. Each item is said in plain words, with the technical picture folded under it. The same roadmap, with a Simple / Technical switch, the board, the journal and how to help, is on [playhorizon.dev/hub](https://playhorizon.dev/hub). Pull requests carry the `roadmap:<id>` label of their item.
|
||||
|
||||
## At a glance
|
||||
|
||||
Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it runs faster on the same computer. Single player, LAN, servers, resource packs and Horizon's own mods work today, on your own copy of the game.
|
||||
|
||||
**Being done now**
|
||||
|
||||
- **Steady frames at long render distances**: Making Horizon faster than vanilla on every computer, with smooth camera movement even at 32 chunks, by spreading the work better over the processor's cores.
|
||||
- **Modpacks' world generation, natively**: Making the world-generation mods of popular modpacks (new structures, YUNG's dungeons and mineshafts, packs built on Lithostitched) generate their worlds in Horizon, natively and fast.
|
||||
- **Playing on servers: everything shows as it should**: When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
|
||||
- **Single player as a server: the last differences**: Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
|
||||
- **Horizon's mod registry**: One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
|
||||
|
||||
**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs.
|
||||
|
||||
**How to help**: Play it and tell us what differs from vanilla (Discord or GitHub), run the benchmark on your computer, vote for what you want next, or pick one of the Help wanted items.
|
||||
|
||||
**Developers**: Read CONTRIBUTING.md, pick a Help wanted item or a To do card, check vanilla's code for the behaviour, and paste the block `python3 tools/pr_ready.py` prints in your pull request.
|
||||
|
||||
## Now
|
||||
|
||||
### Performance
|
||||
|
||||
#### Steady frames at long render distances
|
||||
|
||||
Making Horizon faster than vanilla on every computer, with smooth camera movement even at 32 chunks, by spreading the work better over the processor's cores.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Frame pacing at render distance 32 ([#678](https://github.com/ludovic111/horizon/issues/678)): long frames come from work competing with the render thread. In review: world generation workers build free structure starts instead of queueing them ([#1760](https://github.com/ludovic111/horizon/issues/1760)), the worker pool leaves one core as vanilla does ([#1778](https://github.com/ludovic111/horizon/issues/1778)), and the terrain vertex arena is read as storage where flat draws run ([#1742](https://github.com/ludovic111/horizon/issues/1742)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/ludovic111/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/ludovic111/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Faster block lookups
|
||||
|
||||
Speeding up the code that reads and writes the blocks of the world, which almost everything in the game goes through: generation, lighting, meshing and ticking.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/ludovic111/horizon/issues/1790), [#1794](https://github.com/ludovic111/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/ludovic111/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Modpacks
|
||||
|
||||
#### Modpacks' world generation, natively
|
||||
|
||||
Making the world-generation mods of popular modpacks (new structures, YUNG's dungeons and mineshafts, packs built on Lithostitched) generate their worlds in Horizon, natively and fast.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-port-pack` carries a pack's data-driven content over; structure mods that need code get a native port: YUNG's worldgen as written ([#1746](https://github.com/ludovic111/horizon/issues/1746)), Lithostitched's limited and guaranteed elements with pool and processor modifiers ([#1777](https://github.com/ludovic111/horizon/issues/1777)), its processors as the game's own types ([#1765](https://github.com/ludovic111/horizon/issues/1765)). Proved by the structure blocks and layout harnesses ([#1753](https://github.com/ludovic111/horizon/issues/1753), [#1757](https://github.com/ludovic111/horizon/issues/1757)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Servers and multiplayer
|
||||
|
||||
#### Playing on servers: everything shows as it should
|
||||
|
||||
When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/ludovic111/horizon/issues/1802), [#1798](https://github.com/ludovic111/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/ludovic111/horizon/issues/1795)), skins ([#1652](https://github.com/ludovic111/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/ludovic111/horizon/issues/1650)), firework boosts ([#1671](https://github.com/ludovic111/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/ludovic111/horizon/issues/1731)), styled item names ([#1702](https://github.com/ludovic111/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/ludovic111/horizon/issues/1710)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Server dialogs and login screens
|
||||
|
||||
Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
26.3's dialog packets and model ([#1720](https://github.com/ludovic111/horizon/issues/1720)), the dialog screen with its buttons and actions ([#1734](https://github.com/ludovic111/horizon/issues/1734)), then text and boolean inputs ([#1735](https://github.com/ludovic111/horizon/issues/1735)).
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Horizon's mod registry
|
||||
|
||||
One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Merged: the registry index (schema 1, `.hpack`, checks before install, [#1634](https://github.com/ludovic111/horizon/issues/1634)), `cargo horizon-mod check` ([#1642](https://github.com/ludovic111/horizon/issues/1642)) and the submission service (`crates/horizon-mod-registry`, [#1659](https://github.com/ludovic111/horizon/issues/1659)). In review: the website's /mods ([#1596](https://github.com/ludovic111/horizon/issues/1596)) and /mods/submit ([#1635](https://github.com/ludovic111/horizon/issues/1635)), and in the launcher the registry as a source ([#1636](https://github.com/ludovic111/horizon/issues/1636)), install, enable and takedowns ([#1637](https://github.com/ludovic111/horizon/issues/1637)), the Mods page ([#1638](https://github.com/ludovic111/horizon/issues/1638)) and modpacks as their own profiles ([#1640](https://github.com/ludovic111/horizon/issues/1640)).
|
||||
|
||||
More: [docs/mods.md](docs/mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### Single player as a server: the last differences
|
||||
|
||||
Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Since [#1514](https://github.com/ludovic111/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: a tool that changes a block ends the click ([#1797](https://github.com/ludovic111/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/ludovic111/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/ludovic111/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
#### Play in your language
|
||||
|
||||
Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/ludovic111/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/ludovic111/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/ludovic111/horizon/issues/1672)) and the main menu with zh_cn ([#1774](https://github.com/ludovic111/horizon/issues/1774)).
|
||||
|
||||
</details>
|
||||
|
||||
### Rendering
|
||||
|
||||
#### Shader packs, written again from scratch
|
||||
|
||||
Shader packs were removed and are being rebuilt from scratch. They come back, off by default, once the best-known packs draw properly.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/ludovic111/horizon/issues/1584), [#1585](https://github.com/ludovic111/horizon/issues/1585), [#1586](https://github.com/ludovic111/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/ludovic111/horizon/issues/1698) to [#1801](https://github.com/ludovic111/horizon/issues/1801)).
|
||||
|
||||
More: [#1561](https://github.com/ludovic111/horizon/issues/1561) · [#1771](https://github.com/ludovic111/horizon/issues/1771) · [#1786](https://github.com/ludovic111/horizon/issues/1786)
|
||||
|
||||
</details>
|
||||
|
||||
### Horizon extras
|
||||
|
||||
#### Discord Rich Presence
|
||||
|
||||
Your Discord profile can show that you are playing Horizon, with a switch to turn it off.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A Discord IPC client on its own thread ([#1787](https://github.com/ludovic111/horizon/issues/1787)) and the presence with its setting ([#1788](https://github.com/ludovic111/horizon/issues/1788)). Merged: it ships with the next release.
|
||||
|
||||
</details>
|
||||
|
||||
### Code and tools
|
||||
|
||||
#### A leaner codebase that builds faster
|
||||
|
||||
Cleaning up Horizon's code: removing what nothing uses and making it quicker to build, so fixes and new features land faster.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/ludovic111/horizon/issues/1432), [#1658](https://github.com/ludovic111/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/ludovic111/horizon/issues/1453), [#1454](https://github.com/ludovic111/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/ludovic111/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/ludovic111/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
|
||||
|
||||
</details>
|
||||
|
||||
## Next
|
||||
|
||||
### Performance
|
||||
|
||||
#### Fresh benchmarks against vanilla
|
||||
|
||||
New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way every time, on the website's Benchmarks page.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Redo the comparison after the current performance work ([#1073](https://github.com/ludovic111/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/ludovic111/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/ludovic111/horizon/issues/1749)). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Modpacks
|
||||
|
||||
#### CurseForge in the mod browser (coming soon)
|
||||
|
||||
Coming soon: search and download CurseForge mods and modpacks from the launcher, as you can with Modrinth today.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The mod browser lists CurseForge as coming soon ([docs/mod-browser.md](docs/mod-browser.md)): it needs CurseForge API access. Meanwhile packs import from a CurseForge zip or instance folder, and jars only CurseForge hosts come from the player's own instance ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### Pistons push players and mobs
|
||||
|
||||
Pistons and slime blocks will push players, mobs and items as in vanilla. Today moving blocks pass through them.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/ludovic111/horizon/issues/1496), [#1609](https://github.com/ludovic111/horizon/issues/1609), [#1608](https://github.com/ludovic111/horizon/issues/1608), [#1704](https://github.com/ludovic111/horizon/issues/1704), [#1758](https://github.com/ludovic111/horizon/issues/1758).
|
||||
|
||||
</details>
|
||||
|
||||
### Launcher
|
||||
|
||||
#### Microsoft sign-in (coming soon)
|
||||
|
||||
Coming soon: sign in with your Microsoft account to play on online-mode servers. The code is ready; it waits for Microsoft's approval.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Device-code sign-in in the launcher and online-mode login in the client ([docs/microsoft-account.md](docs/microsoft-account.md)). It is turned on by setting the app's client id once Minecraft API access is approved; until then the launcher shows "Coming soon".
|
||||
|
||||
More: [#1270](https://github.com/ludovic111/horizon/issues/1270)
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon on Intel Macs
|
||||
|
||||
Builds of the game and the launcher for Macs with an Intel processor.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/ludovic111/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/ludovic111/horizon/issues/1161), [#1792](https://github.com/ludovic111/horizon/issues/1792).
|
||||
|
||||
</details>
|
||||
|
||||
## Later
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### Following new Minecraft versions
|
||||
|
||||
When Mojang releases a new version of the game, Horizon follows it, and every system is checked against the new version again.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The port pipeline detects a release, diffs its classes against 26.3 and writes a port report ([docs/version-ports.md](docs/version-ports.md)); the harnesses then run against the new version.
|
||||
|
||||
</details>
|
||||
|
||||
### Horizon extras
|
||||
|
||||
#### An optional AI teammate
|
||||
|
||||
An AI teammate that plays beside you and that you can talk to. It is off unless you turn it on, and the No AI switch hides it completely.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Draft pull requests: the companion ([#1068](https://github.com/ludovic111/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/ludovic111/horizon/issues/1069)), and a route that finishes the game ([#1070](https://github.com/ludovic111/horizon/issues/1070)). Local models first; it ships only once the safety evaluation passes.
|
||||
|
||||
</details>
|
||||
|
||||
## Help wanted
|
||||
|
||||
### Performance
|
||||
|
||||
#### Benchmark your computer
|
||||
|
||||
Run Horizon's built-in benchmark and share the report: every new computer makes the performance work more accurate. No coding needed.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it ([website/docs/performance.md](website/docs/performance.md)). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### End city banners keep their pattern
|
||||
|
||||
Banners in End cities show as plain magenta instead of with their pattern.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/ludovic111/horizon/issues/1438); possibly related: [#526](https://github.com/ludovic111/horizon/issues/526).
|
||||
|
||||
</details>
|
||||
|
||||
#### Enchanted loot in End cities
|
||||
|
||||
Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/ludovic111/horizon/issues/1437), [#1660](https://github.com/ludovic111/horizon/issues/1660).
|
||||
|
||||
</details>
|
||||
|
||||
#### Cooldowns drawn on items
|
||||
|
||||
After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
|
||||
|
||||
</details>
|
||||
|
||||
#### Chat settings and team prefixes
|
||||
|
||||
The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
|
||||
|
||||
</details>
|
||||
|
||||
#### A pointing hand over buttons
|
||||
|
||||
The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
|
||||
|
||||
</details>
|
||||
|
||||
#### Ctrl+Backspace and scroll bars in menus
|
||||
|
||||
Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Chickens flap while they fall
|
||||
|
||||
Chickens should flap their wings while they fall, not stand still in the air.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
|
||||
|
||||
</details>
|
||||
|
||||
## Recently shipped
|
||||
|
||||
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/ludovic111/horizon/issues/1562) · [#1569](https://github.com/ludovic111/horizon/issues/1569) · [#1570](https://github.com/ludovic111/horizon/issues/1570)
|
||||
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/ludovic111/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
|
||||
- **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/ludovic111/horizon/issues/1434)
|
||||
- **Animated textures on the GPU** (v0.2.5): Water, lava and other animated blocks are now animated by the graphics card: 1.7 to 2.4 times better 1% lows on Intel graphics. [#1239](https://github.com/ludovic111/horizon/issues/1239)
|
||||
- **Far Detail for integrated graphics** (v0.2.5): An optional Reduced setting for far terrain, suggested on integrated graphics, that saves 2 to 4 ms of GPU time per frame. [#1199](https://github.com/ludovic111/horizon/issues/1199) · [#1029](https://github.com/ludovic111/horizon/issues/1029)
|
||||
- **The launcher's mod browser** (launcher@0.2.0): Search Modrinth for mods, modpacks and resource packs from the launcher; it says before installing what works in Horizon. [#1154](https://github.com/ludovic111/horizon/issues/1154) · [#1242](https://github.com/ludovic111/horizon/issues/1242) · [#1243](https://github.com/ludovic111/horizon/issues/1243) · [#1246](https://github.com/ludovic111/horizon/issues/1246) · [docs/mod-browser.md](docs/mod-browser.md)
|
||||
- **A blocky, Minecraft-like launcher** (launcher@0.2.0): The launcher got a new blocky look in Horizon's purple, in dark and light. [#1269](https://github.com/ludovic111/horizon/issues/1269) · [docs/design-system.md](docs/design-system.md)
|
||||
- **Dynamic lights** (v2026.10.22): An optional extra: torches you hold, drop or set on fire light up what is around them. [#1162](https://github.com/ludovic111/horizon/issues/1162)
|
||||
- **Host a server from the launcher** (launcher-v0.15.0): The launcher's Servers page creates, starts, backs up and updates a Horizon server with buttons, and invites friends. [#1026](https://github.com/ludovic111/horizon/issues/1026) · [docs/hosting.md](docs/hosting.md)
|
||||
@@ -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,278 @@
|
||||
//! `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 has code to run (a content mod may have none).
|
||||
pub code: 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,
|
||||
"code": self.code,
|
||||
"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 && !self.code {
|
||||
out += " no code: content only, nothing to run\n";
|
||||
} else 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,
|
||||
code: false,
|
||||
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. A content mod has no code to
|
||||
/// run: `inspect` already read its content as the game does.
|
||||
fn trial_run(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let package = Package::read(path)?;
|
||||
let Some(wasm) = package.wasm.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
report.code = true;
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
|
||||
//!
|
||||
//! - `new <path> [--name "Name"] [--sdk-path <dir>]`: a mod crate from the template.
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`.
|
||||
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
|
||||
//! a mod from a template: a Rust crate, or a content mod (no code).
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
|
||||
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
//! 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,
|
||||
Capability, Content, Manifest, Package,
|
||||
manifest::{MANIFEST, component_imports, componentize},
|
||||
};
|
||||
mod check;
|
||||
mod registry;
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitCode, Stdio},
|
||||
@@ -28,7 +36,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> [--template code|content] | 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,
|
||||
@@ -82,19 +92,68 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
.replace("{name}", &name)
|
||||
.replace("{sdk}", &sdk)
|
||||
};
|
||||
let files = [
|
||||
let code = [
|
||||
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
|
||||
(MANIFEST, include_str!("../template/horizon-mod.toml")),
|
||||
("src/lib.rs", include_str!("../template/src/lib.rs")),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
];
|
||||
for (file, text) in files {
|
||||
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
|
||||
let content = [
|
||||
(
|
||||
MANIFEST,
|
||||
include_str!("../template/content/horizon-mod.toml"),
|
||||
),
|
||||
(
|
||||
"data/{id}/horizon/block/glowing_amethyst.json",
|
||||
include_str!("../template/content/block.json"),
|
||||
),
|
||||
(
|
||||
"assets/{id}/blockstates/glowing_amethyst.json",
|
||||
include_str!("../template/content/blockstate.json"),
|
||||
),
|
||||
(
|
||||
"assets/{id}/models/block/glowing_amethyst.json",
|
||||
include_str!("../template/content/model.json"),
|
||||
),
|
||||
(
|
||||
"assets/{id}/items/glowing_amethyst.json",
|
||||
include_str!("../template/content/item.json"),
|
||||
),
|
||||
(
|
||||
"assets/{id}/lang/en_us.json",
|
||||
include_str!("../template/content/lang.json"),
|
||||
),
|
||||
(
|
||||
"data/{id}/loot_table/blocks/glowing_amethyst.json",
|
||||
include_str!("../template/content/loot.json"),
|
||||
),
|
||||
(
|
||||
"data/{id}/recipe/glowing_amethyst.json",
|
||||
include_str!("../template/content/recipe.json"),
|
||||
),
|
||||
(
|
||||
"data/minecraft/tags/block/mineable/pickaxe.json",
|
||||
include_str!("../template/content/pickaxe.json"),
|
||||
),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
];
|
||||
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
|
||||
"code" => &code,
|
||||
"content" => &content,
|
||||
other => return Err(format!("no template {other}: code or content")),
|
||||
};
|
||||
for &(file, text) in files {
|
||||
let file = fill(file);
|
||||
let file = file.as_str();
|
||||
let to = dir.join(file);
|
||||
std::fs::create_dir_all(to.parent().expect("a file in the mod"))
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(&to, fill(text)).map_err(|e| format!("{}: {e}", to.display()))?;
|
||||
}
|
||||
Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
|
||||
let manifest =
|
||||
Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
|
||||
Content::read(dir, &manifest)?;
|
||||
println!(
|
||||
"Created {} (mod id {id}). Next: cd {path} && cargo horizon-mod run",
|
||||
dir.display()
|
||||
@@ -102,14 +161,53 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds the crate in the current folder and packs `target/horizon-mod/<id>.hmod`.
|
||||
/// Builds the mod in the current folder and packs `target/horizon-mod/<id>.hmod`:
|
||||
/// the manifest, the crate's component (none without a `Cargo.toml`: a
|
||||
/// content mod) and the files in `assets/` and `data/`.
|
||||
fn build(release: bool) -> Result<PathBuf, String> {
|
||||
let manifest_text = std::fs::read_to_string(MANIFEST)
|
||||
build_in(Path::new("."), release)
|
||||
}
|
||||
|
||||
fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
|
||||
let manifest_text = std::fs::read_to_string(root.join(MANIFEST))
|
||||
.map_err(|e| format!("{MANIFEST}: {e} (run this in a mod's folder)"))?;
|
||||
let manifest = Manifest::parse(&manifest_text)?;
|
||||
let (wasm, target_dir) = if root.join("Cargo.toml").exists() {
|
||||
let (wasm, target) = compile(root, release)?;
|
||||
// The game refuses a component importing capabilities the manifest lacks.
|
||||
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
|
||||
(Some(wasm), target)
|
||||
} else if manifest.capabilities.contains(&Capability::Registries) {
|
||||
(None, root.join("target"))
|
||||
} else {
|
||||
return Err(format!(
|
||||
"no Cargo.toml: only a content mod (capability registries in {MANIFEST}) has no code"
|
||||
));
|
||||
};
|
||||
// The same checks as the game's, and the files in the order it lists them.
|
||||
let content = Content::read(root, &manifest)?;
|
||||
let mut reader = content.reader()?;
|
||||
let mut files = Vec::with_capacity(content.files.len());
|
||||
for name in &content.files {
|
||||
files.push((name.as_str(), reader.read(name)?));
|
||||
}
|
||||
let files: Vec<(&str, &[u8])> = files.iter().map(|(n, b)| (*n, b.as_slice())).collect();
|
||||
let dir = target_dir.join("horizon-mod");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(format!("{}.hmod", manifest.id));
|
||||
// Write then rename, so a running game never reads half a file.
|
||||
let partial = path.with_extension("hmod.partial");
|
||||
let bytes = Package::write_files(&manifest_text, wasm.as_deref(), &files)?;
|
||||
std::fs::write(&partial, bytes).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Builds the crate for WebAssembly: its component, and Cargo's target folder.
|
||||
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let mut cmd = Command::new(cargo);
|
||||
cmd.args([
|
||||
cmd.current_dir(root).args([
|
||||
"build",
|
||||
"--lib",
|
||||
"--target",
|
||||
@@ -137,17 +235,39 @@ fn build(release: bool) -> Result<PathBuf, String> {
|
||||
.rfind(|f| f.extension().is_some_and(|e| e == "wasm"))
|
||||
.ok_or("the build made no .wasm: is the crate a cdylib?")?;
|
||||
let wasm = componentize(&std::fs::read(&core).map_err(|e| e.to_string())?)?;
|
||||
// The game refuses a component importing capabilities the manifest lacks.
|
||||
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
|
||||
let target_dir = core.ancestors().nth(3).ok_or("unexpected target folder")?;
|
||||
let dir = target_dir.join("horizon-mod");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(format!("{}.hmod", manifest.id));
|
||||
// Write then rename, so a running game never reads half a file.
|
||||
let partial = path.with_extension("hmod.partial");
|
||||
std::fs::write(&partial, Package::write(&manifest_text, &wasm)?).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
let target = core.ancestors().nth(3).ok_or("unexpected target folder")?;
|
||||
Ok((wasm, target.to_path_buf()))
|
||||
}
|
||||
|
||||
/// `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> {
|
||||
@@ -198,4 +318,49 @@ mod tests {
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_content_template_builds_and_checks_with_no_code() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
|
||||
let path = dir.join("gems");
|
||||
let new_args = [
|
||||
path.display().to_string(),
|
||||
"--template".into(),
|
||||
"content".into(),
|
||||
];
|
||||
new(&new_args).unwrap();
|
||||
assert!(!path.join("Cargo.toml").exists());
|
||||
let hmod = build_in(&path, true).unwrap();
|
||||
assert_eq!(hmod, path.join("target/horizon-mod/gems.hmod"));
|
||||
let package = Package::read(&hmod).unwrap();
|
||||
assert!(package.wasm.is_none());
|
||||
let content = Content::read(&hmod, &package.manifest).unwrap();
|
||||
assert!(
|
||||
content
|
||||
.files
|
||||
.contains(&"data/gems/horizon/block/glowing_amethyst.json".into())
|
||||
);
|
||||
assert_eq!(content.files.len(), 8, "{:?}", content.files);
|
||||
let report = check::check(&hmod, check::kind_of(&hmod).unwrap(), None, None).unwrap();
|
||||
assert!(!report.code);
|
||||
assert!(
|
||||
report.text().contains("no code: content only"),
|
||||
"{}",
|
||||
report.text()
|
||||
);
|
||||
// Without registries, a mod needs code.
|
||||
let manifest = std::fs::read_to_string(path.join(MANIFEST)).unwrap();
|
||||
let manifest = manifest.replace("[\"registries\"]", "[]");
|
||||
std::fs::write(path.join(MANIFEST), manifest).unwrap();
|
||||
assert!(build_in(&path, true).unwrap_err().contains("no Cargo.toml"));
|
||||
assert!(
|
||||
new(&[
|
||||
dir.join("x").display().to_string(),
|
||||
"--template".into(),
|
||||
"lua".into()
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"template": "minecraft:amethyst_block",
|
||||
"properties": { "hardness": 2.0, "light": 7 }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"variants": {
|
||||
"": { "model": "{id}:block/glowing_amethyst" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
id = "{id}"
|
||||
name = "{name}"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = []
|
||||
# A content mod: blocks and items from data/ and assets/, no code (API 0.2).
|
||||
api = "0.2"
|
||||
side = "both"
|
||||
# registries: "May add blocks and items to your worlds".
|
||||
capabilities = ["registries"]
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"model": { "type": "minecraft:model", "model": "{id}:block/glowing_amethyst" }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"block.{id}.glowing_amethyst": "Glowing Amethyst"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"type": "minecraft:block",
|
||||
"pools": [
|
||||
{
|
||||
"condition": { "type": "minecraft:survives_explosion" },
|
||||
"entries": [{ "type": "minecraft:item", "name": "{id}:glowing_amethyst" }],
|
||||
"rolls": 1
|
||||
}
|
||||
],
|
||||
"random_sequence": "{id}:blocks/glowing_amethyst"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"parent": "minecraft:block/cube_all",
|
||||
"textures": { "all": "minecraft:block/amethyst_block" }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"values": ["{id}:glowing_amethyst"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"type": "minecraft:crafting_shapeless",
|
||||
"category": "building",
|
||||
"ingredients": ["minecraft:amethyst_block", "minecraft:glowstone_dust"],
|
||||
"result": { "id": "{id}:glowing_amethyst" }
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +173,11 @@ impl Random {
|
||||
}
|
||||
}
|
||||
|
||||
/// One bot thread that panics poisons the locks it held; the others and the report go on.
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
w.varint(id);
|
||||
@@ -329,7 +334,7 @@ fn read(
|
||||
) {
|
||||
use ids::play::clientbound as cb;
|
||||
use ids::play::serverbound as sb;
|
||||
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body);
|
||||
let send = |body: Vec<u8>| lock(&writer).send(&body);
|
||||
let mut seen = HashSet::new();
|
||||
let mut batch = BatchSize::new();
|
||||
let mut teleports = 0u64;
|
||||
@@ -338,7 +343,7 @@ fn read(
|
||||
Ok(packet) => packet,
|
||||
Err(e) => {
|
||||
if bot.online.swap(false, Ordering::SeqCst) {
|
||||
*bot.error.lock().unwrap() = Some(e.to_string());
|
||||
*lock(&bot.error) = Some(e.to_string());
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -356,7 +361,7 @@ fn read(
|
||||
cb::LEVEL_CHUNK_WITH_LIGHT => {
|
||||
bot.chunks.fetch_add(1, Ordering::Relaxed);
|
||||
if let (Ok(x), Ok(z)) = (r.i32(), r.i32()) {
|
||||
if !bot.held.lock().unwrap().insert((x, z)) {
|
||||
if !lock(&bot.held).insert((x, z)) {
|
||||
bot.resent.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
if seen.insert((x, z)) {
|
||||
@@ -368,7 +373,7 @@ fn read(
|
||||
cb::FORGET_LEVEL_CHUNK => {
|
||||
if let Ok(play::Clientbound::ForgetLevelChunk(p)) =
|
||||
play::Clientbound::decode(id, &body)
|
||||
&& bot.held.lock().unwrap().remove(&(p.pos[0], p.pos[1]))
|
||||
&& lock(&bot.held).remove(&(p.pos[0], p.pos[1]))
|
||||
{
|
||||
bot.forgotten.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
@@ -389,7 +394,7 @@ fn read(
|
||||
}
|
||||
cb::PLAYER_POSITION => match play::Clientbound::decode(id, &body) {
|
||||
Ok(play::Clientbound::PlayerPosition(p)) => {
|
||||
let mut at = bot.teleport.lock().unwrap();
|
||||
let mut at = lock(&bot.teleport);
|
||||
let previous = at.unwrap_or([0.0; 3]);
|
||||
// Relative flags: X 1, Y 2, Z 4.
|
||||
let pos: [f64; 3] = std::array::from_fn(|i| {
|
||||
@@ -420,7 +425,7 @@ fn read(
|
||||
},
|
||||
cb::DISCONNECT | cb::START_CONFIGURATION => {
|
||||
bot.online.store(false, Ordering::SeqCst);
|
||||
*bot.error.lock().unwrap() = Some(if id == cb::DISCONNECT {
|
||||
*lock(&bot.error) = Some(if id == cb::DISCONNECT {
|
||||
format!(
|
||||
"disconnected: {}",
|
||||
String::from_utf8_lossy(&body)
|
||||
@@ -455,7 +460,7 @@ fn run_bot(
|
||||
let connection = match join(&o, &name) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
*bot.error.lock().unwrap() = Some(e);
|
||||
*lock(&bot.error) = Some(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -469,12 +474,12 @@ fn run_bot(
|
||||
use ids::play::serverbound as sb;
|
||||
// Wait for the spawn position.
|
||||
let spawn = loop {
|
||||
if let Some(at) = *bot.teleport.lock().unwrap() {
|
||||
if let Some(at) = *lock(&bot.teleport) {
|
||||
break at;
|
||||
}
|
||||
if !bot.online.load(Ordering::SeqCst) || asked.elapsed() > Duration::from_secs(60) {
|
||||
if bot.error.lock().unwrap().is_none() {
|
||||
*bot.error.lock().unwrap() = Some("no spawn position within 60 s".into());
|
||||
if lock(&bot.error).is_none() {
|
||||
*lock(&bot.error) = Some("no spawn position within 60 s".into());
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -482,7 +487,7 @@ fn run_bot(
|
||||
};
|
||||
bot.joined_after_ms
|
||||
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
|
||||
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body).is_ok();
|
||||
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
|
||||
let mut pos = spawn;
|
||||
@@ -491,17 +496,15 @@ fn run_bot(
|
||||
let mut target = pos;
|
||||
let mut next = Instant::now();
|
||||
loop {
|
||||
if until.lock().unwrap().is_some_and(|u| Instant::now() >= u)
|
||||
|| !bot.online.load(Ordering::SeqCst)
|
||||
{
|
||||
if lock(&until).is_some_and(|u| Instant::now() >= u) || !bot.online.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
// A teleport from the server wins over where the bot thought it was.
|
||||
if let Some(at) = bot.teleport.lock().unwrap().take() {
|
||||
if let Some(at) = lock(&bot.teleport).take() {
|
||||
pos = at;
|
||||
}
|
||||
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
|
||||
if !bot.held.lock().unwrap().contains(&here) {
|
||||
if !lock(&bot.held).contains(&here) {
|
||||
bot.waited.fetch_add(1, Ordering::Relaxed);
|
||||
} else if pos[1] < cruise {
|
||||
pos[1] = (pos[1] + 0.5).min(cruise);
|
||||
@@ -558,7 +561,7 @@ fn run_bot(
|
||||
}
|
||||
// Offline first, so the reader doesn't count the closed connection as a failure.
|
||||
bot.online.store(false, Ordering::SeqCst);
|
||||
writer.lock().unwrap().shutdown();
|
||||
lock(&writer).shutdown();
|
||||
}
|
||||
|
||||
fn behaviour_of(mode: &str, index: usize) -> Behaviour {
|
||||
@@ -612,13 +615,10 @@ fn main() {
|
||||
let total = |f: fn(&Bot) -> &AtomicU64| -> u64 {
|
||||
bots.iter().map(|b| f(b).load(Ordering::Relaxed)).sum()
|
||||
};
|
||||
let failed = bots
|
||||
.iter()
|
||||
.filter(|b| b.error.lock().unwrap().is_some())
|
||||
.count();
|
||||
let failed = bots.iter().filter(|b| lock(&b.error).is_some()).count();
|
||||
if first.is_none() && online > 0 {
|
||||
first = Some(Instant::now());
|
||||
*until.lock().unwrap() = Some(Instant::now() + Duration::from_secs(o.seconds));
|
||||
*lock(&until) = Some(Instant::now() + Duration::from_secs(o.seconds));
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
@@ -636,10 +636,10 @@ fn main() {
|
||||
})
|
||||
);
|
||||
previous_chunks = chunks;
|
||||
let over = until.lock().unwrap().is_some_and(|u| Instant::now() >= u);
|
||||
let over = lock(&until).is_some_and(|u| Instant::now() >= u);
|
||||
let all_failed = failed == o.bots;
|
||||
if over || all_failed || (first.is_none() && start.elapsed() > Duration::from_secs(120)) {
|
||||
*until.lock().unwrap() = Some(Instant::now());
|
||||
*lock(&until) = Some(Instant::now());
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -669,7 +669,7 @@ fn main() {
|
||||
"forgotten": b.forgotten.load(Ordering::Relaxed),
|
||||
"pulled_back": b.pulled_back.load(Ordering::Relaxed),
|
||||
"waited_ticks": b.waited.load(Ordering::Relaxed),
|
||||
"error": *b.error.lock().unwrap(),
|
||||
"error": *lock(&b.error),
|
||||
})).collect::<Vec<_>>(),
|
||||
});
|
||||
if o.report.as_deref() == Some("-") {
|
||||
|
||||
@@ -16,11 +16,13 @@ horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
# server.properties, eula.txt and the user lists, shared with horizon-server.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
# Their runtime is the `mods` feature.
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl"] }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
bytemuck = { version = "1.24", features = ["derive"] }
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"horizon.options.controller": "Controller",
|
||||
"horizon.options.vibration": "Vibration",
|
||||
"horizon.options.adaptive_triggers": "Adaptive Triggers",
|
||||
"horizon.options.lightbar": "Lightbar",
|
||||
"horizon.options.look_sensitivity": "Look Sensitivity",
|
||||
"horizon.options.invert_x": "Invert Look X",
|
||||
"horizon.options.invert_y": "Invert Look Y",
|
||||
"horizon.options.sneak_button": "Sneak Button",
|
||||
"horizon.options.move_deadzone": "Move Deadzone",
|
||||
"horizon.options.look_deadzone": "Look Deadzone",
|
||||
"horizon.options.button_glyphs": "Button Glyphs",
|
||||
"horizon.options.menu_navigation": "Menu Navigation",
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"horizon.options.controller": "Геймпад",
|
||||
"horizon.options.vibration": "Вибрация",
|
||||
"horizon.options.adaptive_triggers": "Адаптивные триггеры",
|
||||
"horizon.options.lightbar": "Подсветка",
|
||||
"horizon.options.look_sensitivity": "Чувствительность обзора",
|
||||
"horizon.options.invert_x": "Инверсия обзора по X",
|
||||
"horizon.options.invert_y": "Инверсия обзора по Y",
|
||||
"horizon.options.sneak_button": "Кнопка приседания",
|
||||
"horizon.options.move_deadzone": "Мёртвая зона движения",
|
||||
"horizon.options.look_deadzone": "Мёртвая зона обзора",
|
||||
"horizon.options.button_glyphs": "Символы кнопок",
|
||||
"horizon.options.menu_navigation": "Навигация по меню",
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,623 @@
|
||||
//! Discord Rich Presence: "Playing Horizon" on the player's Discord profile, with what they are
|
||||
//! doing (docs/native-client.md). A thread of its own talks to the Discord app on this computer
|
||||
//! through its local IPC socket (Discord's RPC docs: `discord-ipc-N`, frames of opcode u32 LE +
|
||||
//! length u32 LE + JSON). The game only calls [`set`] each frame: a change wakes the thread,
|
||||
//! which connects when Discord runs, retries every 15 s when it doesn't, keeps under Discord's
|
||||
//! update limit and clears the activity on quit. Nothing is sent with the setting off,
|
||||
//! or with `HORIZON_NO_DISCORD=1`.
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Condvar, Mutex, OnceLock};
|
||||
use std::thread::Thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::command::args::GameMode;
|
||||
|
||||
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
|
||||
/// Rich Presence assets: `horizon`, the logo.
|
||||
pub const DISCORD_APP_ID: &str = "1563857609738690560";
|
||||
/// Whether the application has the game-mode images (`survival`, `creative`, `adventure`,
|
||||
/// `spectator`) beside the logo; it doesn't yet.
|
||||
const MODE_IMAGES: bool = false;
|
||||
/// Points the game at a test's fake Discord (a folder holding `discord-ipc-0`).
|
||||
const IPC_DIR_ENV: &str = "HORIZON_DISCORD_IPC_DIR";
|
||||
|
||||
/// Discord drops activity updates beyond 5 in 20 seconds.
|
||||
const BURST: usize = 5;
|
||||
const WINDOW: Duration = Duration::from_secs(20);
|
||||
/// Between two tries to reach Discord while it isn't running.
|
||||
const RETRY: Duration = Duration::from_secs(15);
|
||||
/// The activity is sent again this often, so a Discord restarted meanwhile gets it back.
|
||||
const REFRESH: Duration = Duration::from_secs(60);
|
||||
|
||||
const HANDSHAKE: u32 = 0;
|
||||
const FRAME: u32 = 1;
|
||||
const CLOSE: u32 = 2;
|
||||
const PING: u32 = 3;
|
||||
const PONG: u32 = 4;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Dimension {
|
||||
Overworld,
|
||||
Nether,
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Play {
|
||||
Singleplayer,
|
||||
/// A server or someone else's LAN world. Its address and name are never shown.
|
||||
Multiplayer,
|
||||
/// This world is open to LAN or friends; `players` counts the host.
|
||||
Hosting {
|
||||
players: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
/// The player turned Rich Presence off: the activity is cleared.
|
||||
Off,
|
||||
Menus,
|
||||
World {
|
||||
play: Play,
|
||||
dimension: Dimension,
|
||||
mode: GameMode,
|
||||
},
|
||||
}
|
||||
|
||||
/// What the game wants shown, and since when it has been in a world.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Wanted {
|
||||
status: Status,
|
||||
/// Unix seconds: when the player entered the world (dimension changes keep it).
|
||||
since: Option<u64>,
|
||||
}
|
||||
impl Wanted {
|
||||
/// A new status at `now`; false if it is the one already wanted.
|
||||
fn update(&mut self, status: Status, now: u64) -> bool {
|
||||
if self.status == status {
|
||||
return false;
|
||||
}
|
||||
let in_world = |s: Status| matches!(s, Status::World { .. });
|
||||
if !in_world(status) {
|
||||
self.since = None;
|
||||
} else if !in_world(self.status) {
|
||||
self.since = Some(now);
|
||||
}
|
||||
self.status = status;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
wanted: Wanted,
|
||||
stop: bool,
|
||||
stopped: bool,
|
||||
}
|
||||
static SHARED: Mutex<Shared> = Mutex::new(Shared {
|
||||
wanted: Wanted {
|
||||
status: Status::Menus,
|
||||
since: None,
|
||||
},
|
||||
stop: false,
|
||||
stopped: false,
|
||||
});
|
||||
static STOPPED: Condvar = Condvar::new();
|
||||
static THREAD: OnceLock<Thread> = OnceLock::new();
|
||||
|
||||
/// Starts the presence thread, once: when the game starts, never for a dedicated server.
|
||||
/// `automated` runs (offscreen, benchmarks) only talk to a test's fake Discord.
|
||||
pub fn start(enabled: bool, automated: bool) {
|
||||
let fake_discord = std::env::var_os(IPC_DIR_ENV).is_some();
|
||||
if THREAD.get().is_some()
|
||||
|| std::env::var_os("HORIZON_NO_DISCORD").is_some_and(|v| v != "0")
|
||||
|| (automated && !fake_discord)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !enabled {
|
||||
lock().wanted.status = Status::Off;
|
||||
}
|
||||
match std::thread::Builder::new()
|
||||
.name("Discord presence".into())
|
||||
.spawn(run)
|
||||
{
|
||||
Ok(handle) => {
|
||||
let _ = THREAD.set(handle.thread().clone());
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: no Discord presence: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the player does now; only a change wakes the thread. Free without [`start`].
|
||||
pub fn set(status: Status) {
|
||||
let Some(thread) = THREAD.get() else {
|
||||
return;
|
||||
};
|
||||
if lock().wanted.update(status, unix_now()) {
|
||||
thread.unpark();
|
||||
}
|
||||
}
|
||||
|
||||
/// On quit: clears the activity, waiting at most a moment for Discord.
|
||||
pub fn stop() {
|
||||
let Some(thread) = THREAD.get() else {
|
||||
return;
|
||||
};
|
||||
let mut shared = lock();
|
||||
shared.stop = true;
|
||||
thread.unpark();
|
||||
let _ = STOPPED.wait_timeout_while(shared, Duration::from_millis(300), |s| !s.stopped);
|
||||
}
|
||||
|
||||
fn lock() -> std::sync::MutexGuard<'static, Shared> {
|
||||
SHARED.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs())
|
||||
}
|
||||
|
||||
fn run() {
|
||||
let mut client: Option<Client> = None;
|
||||
let mut sent: Option<(Wanted, Instant)> = None;
|
||||
let mut last_try: Option<Instant> = None;
|
||||
let mut limit = RateLimit::default();
|
||||
loop {
|
||||
let (wanted, stop) = {
|
||||
let s = lock();
|
||||
(s.wanted, s.stop)
|
||||
};
|
||||
if stop || wanted.status == Status::Off {
|
||||
if let Some(mut c) = client.take() {
|
||||
let _ = c.set_activity(None);
|
||||
c.close();
|
||||
}
|
||||
sent = None;
|
||||
if stop {
|
||||
lock().stopped = true;
|
||||
STOPPED.notify_all();
|
||||
return;
|
||||
}
|
||||
std::thread::park();
|
||||
continue;
|
||||
}
|
||||
let now = Instant::now();
|
||||
if client.is_none() {
|
||||
if let Some(wait) = last_try.and_then(|t| RETRY.checked_sub(now - t)) {
|
||||
std::thread::park_timeout(wait);
|
||||
continue;
|
||||
}
|
||||
last_try = Some(now);
|
||||
client = Client::connect();
|
||||
if client.is_none() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let due = sent.is_none_or(|(w, at)| w != wanted || now - at >= REFRESH);
|
||||
if !due {
|
||||
let (_, at) = sent.expect("sent");
|
||||
std::thread::park_timeout(REFRESH - (now - at));
|
||||
continue;
|
||||
}
|
||||
let wait = limit.wait(now);
|
||||
if !wait.is_zero() {
|
||||
std::thread::park_timeout(wait);
|
||||
continue;
|
||||
}
|
||||
limit.record(now);
|
||||
let c = client.as_mut().expect("connected");
|
||||
match c.set_activity(activity(wanted.status, wanted.since)) {
|
||||
Ok(()) => sent = Some((wanted, now)),
|
||||
Err(e) => {
|
||||
log::info!("Horizon: Discord presence disconnected ({e})");
|
||||
client = None;
|
||||
sent = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The activity Discord shows for `status`; `None` clears it.
|
||||
fn activity(status: Status, since: Option<u64>) -> Option<Value> {
|
||||
let mut assets = json!({"large_image": "horizon", "large_text": "Horizon"});
|
||||
let (details, state, party) = match status {
|
||||
Status::Off => return None,
|
||||
Status::Menus => ("In the menus", None, None),
|
||||
Status::World {
|
||||
play,
|
||||
dimension,
|
||||
mode,
|
||||
} => {
|
||||
if MODE_IMAGES {
|
||||
let (key, text) = match mode {
|
||||
GameMode::Survival => ("survival", "Survival"),
|
||||
GameMode::Creative => ("creative", "Creative"),
|
||||
GameMode::Adventure => ("adventure", "Adventure"),
|
||||
GameMode::Spectator => ("spectator", "Spectator"),
|
||||
};
|
||||
assets["small_image"] = key.into();
|
||||
assets["small_text"] = text.into();
|
||||
}
|
||||
let place = match dimension {
|
||||
Dimension::Overworld => "In the Overworld",
|
||||
Dimension::Nether => "In the Nether",
|
||||
Dimension::End => "In the End",
|
||||
};
|
||||
let (details, party) = match play {
|
||||
Play::Singleplayer => ("Singleplayer", None),
|
||||
Play::Multiplayer => ("Multiplayer", None),
|
||||
Play::Hosting { players } => ("Hosting a world", Some(players)),
|
||||
};
|
||||
(details, Some(place), party)
|
||||
}
|
||||
};
|
||||
let mut a = json!({"details": details, "assets": assets});
|
||||
if let Some(state) = state {
|
||||
a["state"] = state.into();
|
||||
}
|
||||
if let Some(start) = since {
|
||||
a["timestamps"] = json!({"start": start});
|
||||
}
|
||||
if let Some(players) = party {
|
||||
let max = crate::lan::MAX_PLAYERS;
|
||||
a["party"] = json!({
|
||||
"id": format!("horizon-{}", std::process::id()),
|
||||
"size": [players.clamp(1, max), max],
|
||||
});
|
||||
}
|
||||
Some(a)
|
||||
}
|
||||
|
||||
/// At most [`BURST`] updates in any [`WINDOW`].
|
||||
#[derive(Default)]
|
||||
struct RateLimit {
|
||||
sent: VecDeque<Instant>,
|
||||
}
|
||||
impl RateLimit {
|
||||
/// How long until the next update may go.
|
||||
fn wait(&self, now: Instant) -> Duration {
|
||||
if self.sent.len() < BURST {
|
||||
return Duration::ZERO;
|
||||
}
|
||||
(self.sent[0] + WINDOW).saturating_duration_since(now)
|
||||
}
|
||||
fn record(&mut self, now: Instant) {
|
||||
if self.sent.len() == BURST {
|
||||
self.sent.pop_front();
|
||||
}
|
||||
self.sent.push_back(now);
|
||||
}
|
||||
}
|
||||
|
||||
fn encode(op: u32, payload: &Value) -> Vec<u8> {
|
||||
let body = payload.to_string();
|
||||
let mut frame = Vec::with_capacity(8 + body.len());
|
||||
frame.extend_from_slice(&op.to_le_bytes());
|
||||
frame.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
frame.extend_from_slice(body.as_bytes());
|
||||
frame
|
||||
}
|
||||
|
||||
fn read_frame(r: &mut impl Read) -> io::Result<(u32, Value)> {
|
||||
let mut header = [0; 8];
|
||||
r.read_exact(&mut header)?;
|
||||
let op = u32::from_le_bytes(header[..4].try_into().expect("4 bytes"));
|
||||
let len = u32::from_le_bytes(header[4..].try_into().expect("4 bytes")) as usize;
|
||||
if len > 1 << 20 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"frame too large",
|
||||
));
|
||||
}
|
||||
let mut body = vec![0; len];
|
||||
r.read_exact(&mut body)?;
|
||||
Ok((op, serde_json::from_slice(&body)?))
|
||||
}
|
||||
|
||||
trait Pipe: Read + Write + Send {}
|
||||
impl<T: Read + Write + Send> Pipe for T {}
|
||||
|
||||
struct Client {
|
||||
pipe: Box<dyn Pipe>,
|
||||
nonce: u64,
|
||||
}
|
||||
impl Client {
|
||||
/// The first Discord socket that answers the handshake with READY.
|
||||
fn connect() -> Option<Self> {
|
||||
sockets().into_iter().find_map(|path| {
|
||||
let mut client = Client {
|
||||
pipe: open(&path).ok()?,
|
||||
nonce: 0,
|
||||
};
|
||||
match client.handshake() {
|
||||
Ok(()) => {
|
||||
log::info!("Horizon: connected to Discord for Rich Presence");
|
||||
Some(client)
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("Horizon: Discord at {} refused: {e}", path.display());
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
fn handshake(&mut self) -> io::Result<()> {
|
||||
let hello = json!({"v": 1, "client_id": DISCORD_APP_ID});
|
||||
self.pipe.write_all(&encode(HANDSHAKE, &hello))?;
|
||||
self.reply(|frame| frame["evt"] == "READY")
|
||||
}
|
||||
fn set_activity(&mut self, activity: Option<Value>) -> io::Result<()> {
|
||||
self.nonce += 1;
|
||||
let nonce = self.nonce.to_string();
|
||||
let command = json!({
|
||||
"cmd": "SET_ACTIVITY",
|
||||
"args": {"pid": std::process::id(), "activity": activity},
|
||||
"nonce": nonce,
|
||||
});
|
||||
self.pipe.write_all(&encode(FRAME, &command))?;
|
||||
self.reply(|frame| frame["nonce"] == nonce.as_str())
|
||||
}
|
||||
/// Reads until the frame `ours` picks, answering pings; an ERROR is logged, not fatal.
|
||||
fn reply(&mut self, ours: impl Fn(&Value) -> bool) -> io::Result<()> {
|
||||
loop {
|
||||
match read_frame(&mut self.pipe)? {
|
||||
(FRAME, frame) if ours(&frame) => {
|
||||
if frame["evt"] == "ERROR" {
|
||||
log::info!("Horizon: Discord presence: {}", frame["data"]["message"]);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
(PING, frame) => self.pipe.write_all(&encode(PONG, &frame))?,
|
||||
(CLOSE, frame) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::ConnectionAborted,
|
||||
frame["message"].as_str().unwrap_or("closed").to_owned(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn close(mut self) {
|
||||
let _ = self.pipe.write_all(&encode(CLOSE, &json!({})));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where Discord listens: `discord-ipc-0..9` in the temporary folders (and Flatpak's and
|
||||
/// Snap's subfolders) on Linux and macOS, named pipes on Windows.
|
||||
fn sockets() -> Vec<PathBuf> {
|
||||
sockets_with(|key| std::env::var_os(key), cfg!(windows))
|
||||
}
|
||||
|
||||
fn sockets_with(var: impl Fn(&str) -> Option<std::ffi::OsString>, windows: bool) -> Vec<PathBuf> {
|
||||
let names = (0..10).map(|n| format!("discord-ipc-{n}"));
|
||||
if let Some(dir) = var(IPC_DIR_ENV) {
|
||||
return names.map(|n| PathBuf::from(&dir).join(n)).collect();
|
||||
}
|
||||
if windows {
|
||||
return names.map(|n| PathBuf::from(r"\\?\pipe\").join(n)).collect();
|
||||
}
|
||||
let mut dirs: Vec<PathBuf> = ["XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP"]
|
||||
.into_iter()
|
||||
.filter_map(&var)
|
||||
.map(PathBuf::from)
|
||||
.collect();
|
||||
dirs.push("/tmp".into());
|
||||
dirs.dedup();
|
||||
let mut paths = Vec::new();
|
||||
for dir in dirs {
|
||||
for sub in ["", "app/com.discordapp.Discord", "snap.discord"] {
|
||||
paths.extend(names.clone().map(|n| dir.join(sub).join(n)));
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn open(path: &std::path::Path) -> io::Result<Box<dyn Pipe>> {
|
||||
let stream = std::os::unix::net::UnixStream::connect(path)?;
|
||||
// A Discord that stops answering costs this thread a few seconds, never the game.
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn open(path: &std::path::Path) -> io::Result<Box<dyn Pipe>> {
|
||||
let pipe = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(path)?;
|
||||
Ok(Box::new(pipe))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn world(play: Play, dimension: Dimension) -> Status {
|
||||
Status::World {
|
||||
play,
|
||||
dimension,
|
||||
mode: GameMode::Survival,
|
||||
}
|
||||
}
|
||||
|
||||
/// The folders a real Discord listens in: `$TMPDIR` on macOS, `$XDG_RUNTIME_DIR` on Linux (and its
|
||||
/// Flatpak and Snap subfolders), named pipes on Windows.
|
||||
#[test]
|
||||
fn sockets_cover_where_discord_listens() {
|
||||
let env = |pairs: &'static [(&'static str, &'static str)]| {
|
||||
move |key: &str| {
|
||||
pairs
|
||||
.iter()
|
||||
.find(|(k, _)| *k == key)
|
||||
.map(|(_, v)| std::ffi::OsString::from(v))
|
||||
}
|
||||
};
|
||||
let macos = sockets_with(env(&[("TMPDIR", "/var/folders/xy/abc/T/")]), false);
|
||||
assert_eq!(
|
||||
macos[0],
|
||||
PathBuf::from("/var/folders/xy/abc/T/discord-ipc-0")
|
||||
);
|
||||
assert!(macos.contains(&PathBuf::from("/var/folders/xy/abc/T/discord-ipc-9")));
|
||||
assert!(macos.contains(&PathBuf::from("/tmp/discord-ipc-0")));
|
||||
|
||||
let linux = sockets_with(
|
||||
env(&[("XDG_RUNTIME_DIR", "/run/user/1000"), ("TMPDIR", "/tmp")]),
|
||||
false,
|
||||
);
|
||||
assert_eq!(linux[0], PathBuf::from("/run/user/1000/discord-ipc-0"));
|
||||
for path in [
|
||||
"/run/user/1000/app/com.discordapp.Discord/discord-ipc-0",
|
||||
"/run/user/1000/snap.discord/discord-ipc-0",
|
||||
"/tmp/discord-ipc-0",
|
||||
] {
|
||||
assert!(linux.contains(&PathBuf::from(path)), "{path}");
|
||||
}
|
||||
assert_eq!(linux.len(), 2 * 3 * 10, "/tmp is listed once");
|
||||
|
||||
let bare = sockets_with(env(&[]), false);
|
||||
assert_eq!(bare[0], PathBuf::from("/tmp/discord-ipc-0"));
|
||||
|
||||
let windows = sockets_with(env(&[("TEMP", r"C:\Temp")]), true);
|
||||
assert_eq!(windows.len(), 10);
|
||||
// `Path::join` only uses `\` as the separator on Windows itself.
|
||||
#[cfg(windows)]
|
||||
assert_eq!(windows[0], PathBuf::from(r"\\?\pipe\discord-ipc-0"));
|
||||
|
||||
let fake = sockets_with(
|
||||
env(&[("HORIZON_DISCORD_IPC_DIR", "/tmp/hz"), ("TMPDIR", "/x")]),
|
||||
false,
|
||||
);
|
||||
assert_eq!(fake.len(), 10);
|
||||
assert_eq!(fake[0], PathBuf::from("/tmp/hz/discord-ipc-0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_round_trip() {
|
||||
let frame = encode(FRAME, &json!({"cmd": "SET_ACTIVITY"}));
|
||||
assert_eq!(&frame[..4], &[1, 0, 0, 0]);
|
||||
assert_eq!(frame[4..8], (frame.len() as u32 - 8).to_le_bytes());
|
||||
let (op, value) = read_frame(&mut frame.as_slice()).unwrap();
|
||||
assert_eq!((op, value), (FRAME, json!({"cmd": "SET_ACTIVITY"})));
|
||||
assert!(read_frame(&mut &frame[..6]).is_err());
|
||||
let huge = [1, 0, 0, 0, 0, 0, 0, 2];
|
||||
assert!(read_frame(&mut huge.as_slice()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activities_say_what_the_player_does_and_nothing_private() {
|
||||
assert_eq!(activity(Status::Off, None), None);
|
||||
let menus = activity(Status::Menus, None).unwrap();
|
||||
assert_eq!(menus["details"], "In the menus");
|
||||
assert!(menus.get("state").is_none() && menus.get("timestamps").is_none());
|
||||
assert_eq!(menus["assets"]["large_image"], "horizon");
|
||||
|
||||
let single = activity(world(Play::Singleplayer, Dimension::Nether), Some(1000)).unwrap();
|
||||
assert_eq!(single["details"], "Singleplayer");
|
||||
assert_eq!(single["state"], "In the Nether");
|
||||
assert_eq!(single["timestamps"]["start"], 1000);
|
||||
assert!(single.get("party").is_none());
|
||||
assert!(single["assets"].get("small_image").is_none());
|
||||
|
||||
let multi = activity(world(Play::Multiplayer, Dimension::End), Some(5)).unwrap();
|
||||
assert_eq!(multi["details"], "Multiplayer");
|
||||
assert_eq!(multi["state"], "In the End");
|
||||
|
||||
let host = world(Play::Hosting { players: 3 }, Dimension::Overworld);
|
||||
let host = activity(host, Some(5)).unwrap();
|
||||
assert_eq!(host["details"], "Hosting a world");
|
||||
assert_eq!(host["state"], "In the Overworld");
|
||||
assert_eq!(host["party"]["size"], json!([3, 8]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_counts_from_entering_the_world() {
|
||||
let mut w = Wanted {
|
||||
status: Status::Menus,
|
||||
since: None,
|
||||
};
|
||||
assert!(!w.update(Status::Menus, 10));
|
||||
assert!(w.update(world(Play::Singleplayer, Dimension::Overworld), 20));
|
||||
assert_eq!(w.since, Some(20));
|
||||
// Another dimension, opening to LAN: the same visit.
|
||||
assert!(w.update(world(Play::Singleplayer, Dimension::Nether), 30));
|
||||
assert!(w.update(world(Play::Hosting { players: 1 }, Dimension::Nether), 40));
|
||||
assert_eq!(w.since, Some(20));
|
||||
assert!(w.update(Status::Menus, 50));
|
||||
assert_eq!(w.since, None);
|
||||
assert!(w.update(world(Play::Multiplayer, Dimension::Overworld), 60));
|
||||
assert_eq!(w.since, Some(60));
|
||||
assert!(w.update(Status::Off, 70));
|
||||
assert_eq!(w.since, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_most_five_updates_in_twenty_seconds() {
|
||||
let t = Instant::now();
|
||||
let mut limit = RateLimit::default();
|
||||
for i in 0..5 {
|
||||
let now = t + Duration::from_secs(i);
|
||||
assert_eq!(limit.wait(now), Duration::ZERO);
|
||||
limit.record(now);
|
||||
}
|
||||
assert_eq!(
|
||||
limit.wait(t + Duration::from_secs(5)),
|
||||
Duration::from_secs(15)
|
||||
);
|
||||
assert_eq!(limit.wait(t + WINDOW), Duration::ZERO);
|
||||
limit.record(t + WINDOW);
|
||||
assert_eq!(limit.wait(t + WINDOW), Duration::from_secs(1));
|
||||
}
|
||||
|
||||
/// A fake Discord: answers the handshake with READY, each command with its nonce, after a ping.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn talks_to_discord_over_its_socket() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-discord-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("discord-ipc-0");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let listener = std::os::unix::net::UnixListener::bind(&path).unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut s, _) = listener.accept().unwrap();
|
||||
let (op, hello) = read_frame(&mut s).unwrap();
|
||||
assert_eq!((op, &hello["v"]), (HANDSHAKE, &json!(1)));
|
||||
let ready = json!({"cmd": "DISPATCH", "evt": "READY", "data": {"v": 1}});
|
||||
s.write_all(&encode(FRAME, &ready)).unwrap();
|
||||
let mut got = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let (op, cmd) = read_frame(&mut s).unwrap();
|
||||
assert_eq!(op, FRAME);
|
||||
s.write_all(&encode(PING, &json!({"n": 1}))).unwrap();
|
||||
assert_eq!(read_frame(&mut s).unwrap(), (PONG, json!({"n": 1})));
|
||||
let reply = json!({"cmd": "SET_ACTIVITY", "nonce": cmd["nonce"], "data": null});
|
||||
s.write_all(&encode(FRAME, &reply)).unwrap();
|
||||
got.push(cmd);
|
||||
}
|
||||
got
|
||||
});
|
||||
let mut client = Client {
|
||||
pipe: open(&path).unwrap(),
|
||||
nonce: 0,
|
||||
};
|
||||
client.handshake().unwrap();
|
||||
let shown = activity(world(Play::Singleplayer, Dimension::End), Some(7));
|
||||
client.set_activity(shown.clone()).unwrap();
|
||||
client.set_activity(None).unwrap();
|
||||
let got = server.join().unwrap();
|
||||
assert_eq!(got[0]["cmd"], "SET_ACTIVITY");
|
||||
assert_eq!(got[0]["args"]["pid"], std::process::id());
|
||||
assert_eq!(got[0]["args"]["activity"], shown.unwrap());
|
||||
assert_eq!(got[1]["args"]["activity"], Value::Null);
|
||||
assert_ne!(got[0]["nonce"], got[1]["nonce"]);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -1155,6 +1155,72 @@ pub fn arrow_from_nbt(
|
||||
Some((p, tag.get("inBlockState").cloned()))
|
||||
}
|
||||
|
||||
/// `LargeFireball` and `SmallFireball`: `AbstractHurtingProjectile`'s
|
||||
/// `acceleration_power`, `Projectile`'s owner and flags, `ExplosionPower`.
|
||||
pub fn fireball_to_nbt(
|
||||
p: &Projectile,
|
||||
uuid: [i32; 4],
|
||||
owner: Option<[i32; 4]>,
|
||||
raw: Option<&Tag>,
|
||||
) -> Option<Tag> {
|
||||
let power = match p.kind {
|
||||
ProjectileKind::LargeFireball { power } => Some(power),
|
||||
ProjectileKind::SmallFireball => None,
|
||||
_ => return None,
|
||||
};
|
||||
let base = Base {
|
||||
id: p.kind.id(),
|
||||
uuid,
|
||||
position: p.position,
|
||||
motion: p.velocity,
|
||||
rotation: [p.yaw, p.pitch],
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
put(
|
||||
&mut tag,
|
||||
"acceleration_power",
|
||||
Tag::Double(p.acceleration.to_bits()),
|
||||
);
|
||||
// Projectile: LeftOwner only when set; HasBeenShot once it has ticked
|
||||
// (a fireball saved before its first tick keeps what it was loaded with).
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"LeftOwner".into());
|
||||
}
|
||||
if p.left_owner {
|
||||
put(&mut tag, "LeftOwner", Tag::Byte(1));
|
||||
}
|
||||
if p.age > 0 || tag.get("HasBeenShot").is_none() {
|
||||
put(&mut tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
|
||||
}
|
||||
if let Some(power) = power {
|
||||
put(&mut tag, "ExplosionPower", Tag::Byte(power as i8));
|
||||
}
|
||||
if let Some(owner) = owner {
|
||||
put(&mut tag, "Owner", Tag::IntArray(owner.to_vec()));
|
||||
}
|
||||
Some(tag)
|
||||
}
|
||||
|
||||
pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
|
||||
let kind = match kind(tag)?.as_str() {
|
||||
"minecraft:fireball" => ProjectileKind::LargeFireball {
|
||||
power: number(tag.get("ExplosionPower")).unwrap_or(1) as i32,
|
||||
},
|
||||
"minecraft:small_fireball" => ProjectileKind::SmallFireball,
|
||||
_ => return None,
|
||||
};
|
||||
let owner = uuid_from_tag(tag.get("Owner"));
|
||||
let from_player = player.is_some() && owner == player;
|
||||
let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player));
|
||||
p.velocity = motion(tag);
|
||||
[p.yaw, p.pitch] = rotation(tag);
|
||||
p.left_owner = flag(tag, "LeftOwner");
|
||||
p.acceleration = float(tag.get("acceleration_power")).unwrap_or(0.1);
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// `FallingBlockEntity`: BlockState, Time, DropItem, HurtEntities.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn falling_block_to_nbt(
|
||||
@@ -2139,3 +2205,41 @@ mod sheep_nbt_tests {
|
||||
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fireball_nbt_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn a_fireball_round_trips_its_flight() {
|
||||
let raw = Tag::compound([
|
||||
("id", Tag::String("minecraft:fireball".into())),
|
||||
("Pos", doubles(&[1.0, 70.0, -2.5])),
|
||||
("Motion", doubles(&[0.0, -0.5, 1.0])),
|
||||
("ExplosionPower", Tag::Byte(4)),
|
||||
("acceleration_power", Tag::Double(0.0f64.to_bits())),
|
||||
]);
|
||||
let p = fireball_from_nbt(&raw, None).unwrap();
|
||||
assert_eq!(p.kind, ProjectileKind::LargeFireball { power: 4 });
|
||||
assert_eq!((p.velocity.z, p.acceleration), (1.0, 0.0));
|
||||
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, None).unwrap();
|
||||
assert_eq!(number(back.get("ExplosionPower")), Some(4));
|
||||
assert_eq!(float(back.get("acceleration_power")), Some(0.0));
|
||||
// Projectile.addAdditionalSaveData: no LeftOwner unless set, HasBeenShot always.
|
||||
assert!(back.get("LeftOwner").is_none());
|
||||
assert_eq!(number(back.get("HasBeenShot")), Some(0));
|
||||
assert_eq!(
|
||||
read_doubles::<3>(back.get("Motion")),
|
||||
Some([0.0, -0.5, 1.0])
|
||||
);
|
||||
// A default fireball keeps vanilla's power and acceleration.
|
||||
let plain = Tag::compound([
|
||||
("id", Tag::String("minecraft:fireball".into())),
|
||||
("Pos", doubles(&[0.0, 0.0, 0.0])),
|
||||
]);
|
||||
let p = fireball_from_nbt(&plain, None).unwrap();
|
||||
assert_eq!(
|
||||
(p.kind, p.acceleration),
|
||||
(ProjectileKind::LargeFireball { power: 1 }, 0.1)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Horizon's optional extras (zoom, FPS counter, keystroke overlay): their
|
||||
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
|
||||
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
|
||||
//! default; see `docs/extras.md`.
|
||||
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
|
||||
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
|
||||
use std::{collections::VecDeque, fs, path::Path};
|
||||
|
||||
pub const FILE: &str = "horizon-extras.txt";
|
||||
@@ -112,6 +113,8 @@ pub struct Extras {
|
||||
/// Agents may control the game the player starts: it opens its control socket for
|
||||
/// horizon-ctl and MCP clients (off by default; an agent's own launch always opens it).
|
||||
pub agent_control: bool,
|
||||
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
|
||||
pub discord_presence: bool,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -142,6 +145,7 @@ impl Default for Extras {
|
||||
sort_key: "key.keyboard.unknown".into(),
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
discord_presence: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,6 +211,7 @@ impl Extras {
|
||||
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
"discordPresence" => e.discord_presence = flag(e.discord_presence),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -214,7 +219,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -246,6 +251,7 @@ impl Extras {
|
||||
horizon_core::no_ai::KEY,
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
bool_text(self.discord_presence),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
|
||||
@@ -466,6 +472,8 @@ pub enum Item {
|
||||
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
|
||||
NoAi,
|
||||
AgentControl,
|
||||
/// On the Horizon Settings page.
|
||||
DiscordPresence,
|
||||
}
|
||||
/// The Extras page: headers and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
@@ -546,6 +554,7 @@ impl Item {
|
||||
Item::KeystrokesCps => "Clicks per second",
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
}
|
||||
}
|
||||
/// The switch this setting belongs to: it does nothing while that is off.
|
||||
@@ -559,7 +568,7 @@ impl Item {
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl => {
|
||||
| AutoReconnect | NoAi | AgentControl | DiscordPresence => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -606,6 +615,9 @@ impl Item {
|
||||
Item::NoAi => {
|
||||
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
|
||||
}
|
||||
Item::DiscordPresence => {
|
||||
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
|
||||
}
|
||||
Item::AgentControl => {
|
||||
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
|
||||
}
|
||||
@@ -641,6 +653,7 @@ impl Item {
|
||||
Item::Sort => e.sort,
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -698,6 +711,7 @@ impl Item {
|
||||
Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back),
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
Item::ZoomKey | Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
@@ -721,6 +735,7 @@ mod tests {
|
||||
assert!(!d.fullbright && !d.dynamic_lights);
|
||||
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
|
||||
assert!(!d.mouse_tweaks && !d.sort);
|
||||
assert!(d.discord_presence);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
let mut e = d.clone();
|
||||
@@ -746,6 +761,7 @@ mod tests {
|
||||
e.mouse_tweaks_wheel = false;
|
||||
e.sort = true;
|
||||
e.sort_key = "key.keyboard.r".into();
|
||||
e.discord_presence = false;
|
||||
assert_eq!(Extras::parse(&e.serialize()), e);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -35,6 +36,7 @@ mod debug_keys;
|
||||
mod decorated_pot;
|
||||
mod dedicated;
|
||||
mod deflect;
|
||||
mod discord;
|
||||
mod dispense_fixture;
|
||||
mod dispenser;
|
||||
mod dragon;
|
||||
@@ -43,6 +45,7 @@ mod enchant_effects;
|
||||
mod end;
|
||||
mod entity_culling;
|
||||
mod events;
|
||||
mod execution_role;
|
||||
mod explosion;
|
||||
mod extras;
|
||||
mod falling_damage;
|
||||
@@ -139,6 +142,7 @@ mod sculk;
|
||||
mod sculk_fixture;
|
||||
mod server_packs;
|
||||
mod server_travel;
|
||||
mod shader_packs;
|
||||
mod shield;
|
||||
mod sign_edit;
|
||||
mod sniffers;
|
||||
@@ -153,6 +157,7 @@ mod storage;
|
||||
mod structures;
|
||||
mod subtitle_hud;
|
||||
mod sulfur_cubes;
|
||||
mod text_displays;
|
||||
mod titles;
|
||||
mod tnt;
|
||||
mod totem;
|
||||
@@ -289,8 +294,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
|
||||
@@ -347,7 +352,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);
|
||||
@@ -361,6 +366,7 @@ impl Options {
|
||||
return Ok(None);
|
||||
}
|
||||
"--dedicated" => {
|
||||
o.execution_role = execution_role::ExecutionRole::DedicatedServer;
|
||||
o.dedicated = Some(args.by_ref().collect());
|
||||
break;
|
||||
}
|
||||
@@ -1421,6 +1427,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>,
|
||||
@@ -1565,6 +1573,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,
|
||||
@@ -4045,6 +4055,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();
|
||||
@@ -4063,6 +4087,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") {
|
||||
@@ -4259,23 +4285,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),
|
||||
@@ -4413,7 +4423,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());
|
||||
@@ -4438,6 +4448,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
|
||||
@@ -4818,11 +4830,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
|
||||
@@ -5034,6 +5050,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);
|
||||
@@ -6771,6 +6788,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() {
|
||||
@@ -7501,7 +7519,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,
|
||||
@@ -7520,20 +7538,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"),
|
||||
@@ -7549,6 +7563,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)
|
||||
}
|
||||
@@ -7962,6 +7984,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;
|
||||
@@ -9540,13 +9564,13 @@ impl Game<'_> {
|
||||
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0);
|
||||
let stack = self.inventory[c].clone();
|
||||
self.draw_slot_item(batch, drag.as_ref(), c, stack.clone(), x, y);
|
||||
if let Some(stack) = stack
|
||||
&& self.cursor.0 >= x
|
||||
if self.cursor.0 >= x
|
||||
&& self.cursor.0 < x + 16.0
|
||||
&& self.cursor.1 >= y
|
||||
&& self.cursor.1 < y + 16.0
|
||||
{
|
||||
hovered = Some(stack);
|
||||
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
|
||||
hovered = stack;
|
||||
}
|
||||
}
|
||||
let scrollable = self.creative_screen.rows(&self.pack.names) > 5;
|
||||
@@ -10063,6 +10087,11 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.gather_dynamic_lights(camera, alpha);
|
||||
}
|
||||
let pack_inputs = if title {
|
||||
Default::default()
|
||||
} else {
|
||||
self.shader_pack_inputs(camera, alpha as f32)
|
||||
};
|
||||
let frame = Frame {
|
||||
weather: &weather,
|
||||
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
|
||||
@@ -10095,7 +10124,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
|
||||
{
|
||||
@@ -10146,6 +10175,7 @@ impl Game<'_> {
|
||||
hand: &hand,
|
||||
hand_fov_degrees: self.hud_fov_degrees(camera_alpha as f32, camera.to_array()),
|
||||
screen: &screen,
|
||||
pack: pack_inputs,
|
||||
border,
|
||||
game_time: self.world.game_time as f32,
|
||||
shader_game_time: ((self.world.game_time % 24000) as f32 + alpha as f32) / 24000.0,
|
||||
@@ -10166,6 +10196,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 +10589,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 {
|
||||
@@ -11393,6 +11426,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.track_toggle_screens();
|
||||
// For the "I'm playing" ping: an atomic, sent on when it changes.
|
||||
horizon_client::heartbeat::set_lan(self.is_published());
|
||||
self.update_discord_presence();
|
||||
let steps = self.clock.advance(elapsed);
|
||||
// The world ticking in the frame: once its ticks took a
|
||||
// tick's length, the rest are dropped (`FixedStep::tick_fits`).
|
||||
@@ -11883,6 +11917,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()
|
||||
@@ -11970,6 +12006,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(),
|
||||
@@ -12061,6 +12098,8 @@ enum PendingWorldEntry {
|
||||
Server {
|
||||
address: String,
|
||||
name: String,
|
||||
/// A transfer's carried-over server cookies (`TransferState`).
|
||||
cookies: Option<horizon_client::net::Cookies>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12135,8 +12174,12 @@ impl Game<'_> {
|
||||
}) => {
|
||||
self.open_world(&folder, name.as_deref(), seed, creative, create.as_deref())?;
|
||||
}
|
||||
Some(PendingWorldEntry::Server { address, name }) => {
|
||||
self.connect_server(&address, &name)?;
|
||||
Some(PendingWorldEntry::Server {
|
||||
address,
|
||||
name,
|
||||
cookies,
|
||||
}) => {
|
||||
self.connect_server_with_cookies(&address, &name, cookies)?;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
@@ -12243,7 +12286,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()
|
||||
@@ -12688,6 +12731,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
hand: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
border: None,
|
||||
game_time: 0.0,
|
||||
shader_game_time: 0.0,
|
||||
@@ -12697,6 +12741,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,
|
||||
}
|
||||
}
|
||||
@@ -12784,7 +12829,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);
|
||||
}
|
||||
@@ -12859,6 +12904,7 @@ fn write_report(game: &Game<'_>) -> Result<()> {
|
||||
|
||||
fn run_event_loop(mut game: Game<'_>) -> Result<()> {
|
||||
EventLoop::new()?.run_app(&mut game)?;
|
||||
horizon_client::discord_presence::stop();
|
||||
write_report(&game)?;
|
||||
if let Some(e) = game.error.take() {
|
||||
return Err(e);
|
||||
@@ -13061,7 +13107,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)
|
||||
@@ -13216,6 +13262,7 @@ fn run_loading(
|
||||
};
|
||||
let mut app = App::Loading(Box::new(loading));
|
||||
EventLoop::new()?.run_app(&mut app)?;
|
||||
horizon_client::discord_presence::stop();
|
||||
match app {
|
||||
App::Ready(mut game) => {
|
||||
write_report(&game)?;
|
||||
@@ -13236,13 +13283,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
|
||||
@@ -13252,7 +13301,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()
|
||||
@@ -13264,6 +13313,14 @@ pub fn run() -> Result<()> {
|
||||
horizon_client::spans::enable(options.spikes.is_some());
|
||||
let vanilla_root = options.resources.clone();
|
||||
let early_settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
|
||||
// "Playing Horizon" on Discord: never for a server; an automated run only talks to a test's fake Discord.
|
||||
if options.dedicated.is_none() {
|
||||
let automated = options.benchmark.is_some()
|
||||
|| options.offscreen
|
||||
|| options.frames.is_some()
|
||||
|| options.world_frames.is_some();
|
||||
horizon_client::discord_presence::start(early_settings.extras.discord_presence, automated);
|
||||
}
|
||||
// Snapshots of derived tables live in the game folder, never beside the
|
||||
// shared resources.
|
||||
Behaviours::set_cache_dir(std::env::current_dir()?.join(".horizon-cache"));
|
||||
@@ -13293,7 +13350,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);
|
||||
|
||||
@@ -469,9 +469,12 @@ impl Game<'_> {
|
||||
"flying": p.flying,
|
||||
"fall_flying": p.fall_flying,
|
||||
"sneaking": self.keys.contains(&KeyCode::ShiftLeft),
|
||||
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
|
||||
"sprinting": p.sprinting,
|
||||
"fov_modifier": self.view.fov_modifier,
|
||||
"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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -42,7 +42,8 @@ pub(super) enum Target {
|
||||
Mob(u32),
|
||||
Item(u32),
|
||||
Orb(usize),
|
||||
Arrow(usize),
|
||||
/// By index in `creatures.projectiles`.
|
||||
Projectile(usize),
|
||||
Stand(u32),
|
||||
Frame(u32),
|
||||
Painting(u32),
|
||||
@@ -327,7 +328,7 @@ impl Game<'_> {
|
||||
.orbs
|
||||
.get(i)
|
||||
.map(|o| [o.position.x, o.position.y, o.position.z]),
|
||||
Target::Arrow(i) => self
|
||||
Target::Projectile(i) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get(i)
|
||||
@@ -382,7 +383,11 @@ impl Game<'_> {
|
||||
}),
|
||||
Target::Item(_) => "minecraft:item".into(),
|
||||
Target::Orb(_) => "minecraft:experience_orb".into(),
|
||||
Target::Arrow(_) => "minecraft:arrow".into(),
|
||||
Target::Projectile(i) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get(i)
|
||||
.map_or_else(String::new, |p| p.kind.id().into()),
|
||||
Target::Stand(_) => "minecraft:armor_stand".into(),
|
||||
Target::Frame(id) => {
|
||||
if self.item_frames.iter().any(|f| f.id == id && f.glow) {
|
||||
@@ -503,6 +508,7 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
if s.kind != 's' {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
candidates.extend(self.player_candidates(s));
|
||||
candidates.extend(
|
||||
self.mobs
|
||||
@@ -517,8 +523,12 @@ impl Game<'_> {
|
||||
.projectiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| p.arrow.is_some())
|
||||
.map(|(i, _)| Target::Arrow(i)),
|
||||
// The kinds /data can write (entity_nbt).
|
||||
.filter(|(_, p)| {
|
||||
p.arrow.is_some()
|
||||
|| matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
|
||||
})
|
||||
.map(|(i, _)| Target::Projectile(i)),
|
||||
);
|
||||
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
|
||||
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
|
||||
@@ -1082,6 +1092,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);
|
||||
@@ -1098,9 +1110,15 @@ impl Game<'_> {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.position = v;
|
||||
m.previous = v;
|
||||
if let Some([yaw, _]) = rotation {
|
||||
// Entity.teleportSetPosition: setYRot, setYHeadRot, setXRot
|
||||
// (clamped to -90..90), then setOldRot.
|
||||
if let Some([yaw, pitch]) = rotation {
|
||||
m.yaw = yaw;
|
||||
m.body_yaw = yaw;
|
||||
m.head_yaw = yaw;
|
||||
m.head_pitch = pitch.clamp(-90.0, 90.0);
|
||||
m.previous_yaw = m.yaw;
|
||||
m.previous_head_pitch = m.head_pitch;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1116,7 +1134,7 @@ impl Game<'_> {
|
||||
o.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Arrow(i) => {
|
||||
Target::Projectile(i) => {
|
||||
if let Some(a) = self.creatures.projectiles.get_mut(i) {
|
||||
a.position = v;
|
||||
a.previous = v;
|
||||
@@ -1540,14 +1558,10 @@ impl Game<'_> {
|
||||
self.orbs[i].age = u32::MAX / 2;
|
||||
}
|
||||
}
|
||||
Target::Arrow(i) => {
|
||||
if let Some(a) = self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get_mut(i)
|
||||
.and_then(|p| p.arrow.as_mut())
|
||||
{
|
||||
a.removed = true;
|
||||
// cmd_kill goes from the highest index down.
|
||||
Target::Projectile(i) => {
|
||||
if i < self.creatures.projectiles.len() {
|
||||
self.creatures.projectiles.remove(i);
|
||||
}
|
||||
}
|
||||
Target::Stand(id) => self.armor_stands.retain(|a| a.id != id),
|
||||
@@ -1569,7 +1583,7 @@ impl Game<'_> {
|
||||
let mut ordered = targets.clone();
|
||||
ordered.sort_by_key(|t| {
|
||||
std::cmp::Reverse(match t {
|
||||
Target::Orb(i) | Target::Arrow(i) => *i,
|
||||
Target::Orb(i) | Target::Projectile(i) => *i,
|
||||
_ => 0,
|
||||
})
|
||||
});
|
||||
@@ -1577,9 +1591,6 @@ impl Game<'_> {
|
||||
self.kill_target(t);
|
||||
}
|
||||
self.orbs.retain(|o| o.age < u32::MAX / 2);
|
||||
self.creatures
|
||||
.projectiles
|
||||
.retain(|p| !p.arrow.as_ref().is_some_and(|a| a.removed));
|
||||
let message = if targets.len() == 1 {
|
||||
Text::translate("commands.kill.success.single", &[names[0].clone()])
|
||||
} else {
|
||||
|
||||
@@ -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);
|
||||
@@ -113,7 +114,7 @@ impl Game<'_> {
|
||||
self.other_to_nbt(&o, uuid)
|
||||
}
|
||||
Target::Orb(i) => entity_nbt::orb_to_nbt(self.orbs.get(i)?, random_uuid()),
|
||||
Target::Arrow(i) => {
|
||||
Target::Projectile(i) => {
|
||||
let p = self.creatures.projectiles.get(i)?;
|
||||
let owner = if p.owner == Some(horizon_client::mobs::Who::Player) {
|
||||
player_uuid
|
||||
@@ -125,7 +126,8 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.and_then(|a| a.last_state)
|
||||
.map(|s| self.state_to_nbt(s));
|
||||
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)?
|
||||
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)
|
||||
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)?)),
|
||||
@@ -503,7 +513,10 @@ impl Game<'_> {
|
||||
if self.pending_screenshot.is_some() {
|
||||
return Err("a screenshot is already pending".into());
|
||||
}
|
||||
renderer.screenshot = Some(arg(1)?.into());
|
||||
arg(1)?;
|
||||
// The rest of the line: a path may hold spaces.
|
||||
let path = line.trim_start().strip_prefix("screenshot").unwrap_or("");
|
||||
renderer.screenshot = Some(path.trim().into());
|
||||
self.pending_screenshot = Some(reply.clone());
|
||||
Ok(None)
|
||||
}
|
||||
@@ -981,6 +994,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);
|
||||
|
||||
@@ -185,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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! What the player is doing, for Discord Rich Presence (`horizon_client::discord_presence`).
|
||||
use super::*;
|
||||
use horizon_client::discord_presence::{self as presence, Dimension, Play, Status};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Each frame: only a change reaches the presence thread.
|
||||
pub(super) fn update_discord_presence(&self) {
|
||||
presence::set(self.discord_status());
|
||||
}
|
||||
fn discord_status(&self) -> Status {
|
||||
if !self.settings.extras.discord_presence {
|
||||
return Status::Off;
|
||||
}
|
||||
if self.title.is_some() {
|
||||
return Status::Menus;
|
||||
}
|
||||
let play = if let Some(host) = &self.lan {
|
||||
Play::Hosting {
|
||||
players: 1 + host.hub.guests.len(),
|
||||
}
|
||||
} else if let Some(server) = &self.integrated {
|
||||
match (server.published(), &self.remote) {
|
||||
(Some(_), Some(remote)) => Play::Hosting {
|
||||
players: remote.player_count(),
|
||||
},
|
||||
_ => Play::Singleplayer,
|
||||
}
|
||||
} else if self.remote.is_some() {
|
||||
Play::Multiplayer
|
||||
} else {
|
||||
Play::Singleplayer
|
||||
};
|
||||
Status::World {
|
||||
play,
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => Dimension::Overworld,
|
||||
Dim::Nether => Dimension::Nether,
|
||||
Dim::End => Dimension::End,
|
||||
},
|
||||
mode: self.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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
/// The fluid at the camera, as `Camera.getFluidInCamera` reports it.
|
||||
fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid {
|
||||
pub(super) fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid {
|
||||
match fog_type(&self.world, self.res.facts(), camera, self.forward()) {
|
||||
FogType::Water => CameraFluid::Water,
|
||||
FogType::Lava => CameraFluid::Lava,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -321,20 +321,7 @@ impl Game<'_> {
|
||||
}
|
||||
for p in self.creatures.projectiles.iter().filter(|p| p.id != 0) {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
let kind = match &p.kind {
|
||||
K::Arrow { .. } => "minecraft:arrow",
|
||||
K::Snowball => "minecraft:snowball",
|
||||
K::Egg => "minecraft:egg",
|
||||
K::EnderPearl => "minecraft:ender_pearl",
|
||||
K::SmallFireball => "minecraft:small_fireball",
|
||||
K::LargeFireball { .. } => "minecraft:fireball",
|
||||
K::LlamaSpit => "minecraft:llama_spit",
|
||||
K::WindCharge => "minecraft:wind_charge",
|
||||
K::BreezeWindCharge => "minecraft:breeze_wind_charge",
|
||||
K::ShulkerBullet { .. } => "minecraft:shulker_bullet",
|
||||
K::WitherSkull { .. } => "minecraft:wither_skull",
|
||||
K::Potion { .. } => "minecraft:splash_potion",
|
||||
};
|
||||
let kind = p.kind.id();
|
||||
let mut e = state(p.id, kind, at(p.position), p.yaw, false);
|
||||
e.pitch = p.pitch;
|
||||
e.velocity = at(p.velocity);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -156,6 +156,8 @@ impl Game<'_> {
|
||||
kind.as_str(),
|
||||
"minecraft:experience_orb"
|
||||
| "minecraft:arrow"
|
||||
| "minecraft:fireball"
|
||||
| "minecraft:small_fireball"
|
||||
| "minecraft:falling_block"
|
||||
| "minecraft:end_crystal"
|
||||
| "minecraft:ender_dragon"
|
||||
@@ -195,6 +197,11 @@ impl Game<'_> {
|
||||
self.creatures.projectiles.push(p);
|
||||
}
|
||||
}
|
||||
"minecraft:fireball" | "minecraft:small_fireball" => {
|
||||
if let Some(p) = entity_nbt::fireball_from_nbt(&tag, player_uuid) {
|
||||
self.creatures.projectiles.push(p);
|
||||
}
|
||||
}
|
||||
"minecraft:falling_block" => {
|
||||
if let Some((state, position, velocity, age, fall_distance)) =
|
||||
entity_nbt::falling_block_from_nbt(&tag)
|
||||
@@ -243,6 +250,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 +272,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 +353,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 +398,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 +516,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 +594,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()
|
||||
})
|
||||
@@ -619,13 +649,15 @@ impl Game<'_> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (uuid, raw) = anon(&self.persist, "minecraft:arrow", p.position);
|
||||
let (uuid, raw) = anon(&self.persist, p.kind.id(), p.position);
|
||||
let in_block = p
|
||||
.arrow
|
||||
.as_ref()
|
||||
.and_then(|a| a.last_state)
|
||||
.map(|s| self.state_to_nbt(s));
|
||||
if let Some(tag) = entity_nbt::arrow_to_nbt(p, uuid, owner, in_block, raw.as_ref()) {
|
||||
let tag = entity_nbt::arrow_to_nbt(p, uuid, owner, in_block, raw.as_ref())
|
||||
.or_else(|| entity_nbt::fireball_to_nbt(p, uuid, owner, raw.as_ref()));
|
||||
if let Some(tag) = tag {
|
||||
out.entry(chunk_of(p.position)).or_default().push(tag);
|
||||
}
|
||||
}
|
||||
@@ -650,25 +682,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 +869,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.
|
||||
@@ -189,6 +193,9 @@ pub(super) struct Remote {
|
||||
/// the oldest of them first waited (`poll_remote`).
|
||||
backlog: VecDeque<Event>,
|
||||
backlog_since: Option<Instant>,
|
||||
/// `ClientboundTransferPacket`'s target: when the connection ends the
|
||||
/// game connects here, the stored cookies carried over (`TransferState`).
|
||||
transfer: Option<String>,
|
||||
}
|
||||
|
||||
/// The time a frame spends on chunk packets before it leaves the rest of
|
||||
@@ -253,14 +260,25 @@ fn number_format(f: &play::NumberFormat) -> horizon_client::scoreboard::NumberFo
|
||||
impl Game<'_> {
|
||||
/// Leave the title for a server (ConnectScreen): an empty world fed by the connection.
|
||||
pub(super) fn connect_server(&mut self, address: &str, name: &str) -> Result<()> {
|
||||
self.connect_server_with_cookies(address, name, None)
|
||||
}
|
||||
/// `connect_server` carrying a transfer's stored server cookies
|
||||
/// (`TransferState`); None on an ordinary join.
|
||||
pub(super) fn connect_server_with_cookies(
|
||||
&mut self,
|
||||
address: &str,
|
||||
name: &str,
|
||||
cookies: Option<horizon_client::net::Cookies>,
|
||||
) -> Result<()> {
|
||||
if self.defer_world_entry(super::PendingWorldEntry::Server {
|
||||
address: address.to_owned(),
|
||||
name: name.to_owned(),
|
||||
cookies: cookies.clone(),
|
||||
})? {
|
||||
return Ok(());
|
||||
}
|
||||
self.join_world_rest()?;
|
||||
if let Err(error) = self.join_server(address, name, None) {
|
||||
if let Err(error) = self.join_server(address, name, None, cookies) {
|
||||
log::warn!("Horizon: could not join server: {error}");
|
||||
if self.title.is_none() {
|
||||
self.title = Some(horizon_client::title::Title::new(
|
||||
@@ -281,13 +299,14 @@ impl Game<'_> {
|
||||
pipe: horizon_protocol::connection::Pipe,
|
||||
name: &str,
|
||||
) -> Result<()> {
|
||||
self.join_server("local", name, Some(pipe))
|
||||
self.join_server("local", name, Some(pipe), None)
|
||||
}
|
||||
fn join_server(
|
||||
&mut self,
|
||||
address: &str,
|
||||
name: &str,
|
||||
local: Option<horizon_protocol::connection::Pipe>,
|
||||
cookies: Option<horizon_client::net::Cookies>,
|
||||
) -> Result<()> {
|
||||
self.perf.world_opening();
|
||||
let root = self.options.resources.clone();
|
||||
@@ -319,7 +338,17 @@ impl Game<'_> {
|
||||
Some(pipe) => Session::connect_local(pipe, name, view, lang.clone()),
|
||||
None => {
|
||||
self.extras_connecting(address, name);
|
||||
Session::connect(address, name, auth.clone(), view, lang.clone())
|
||||
match cookies {
|
||||
Some(cookies) => Session::connect_transferred(
|
||||
address,
|
||||
name,
|
||||
auth.clone(),
|
||||
view,
|
||||
lang.clone(),
|
||||
cookies,
|
||||
),
|
||||
None => Session::connect(address, name, auth.clone(), view, lang.clone()),
|
||||
}
|
||||
}
|
||||
};
|
||||
let player_type = super::perspective::player_type(&root);
|
||||
@@ -379,6 +408,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.apply_settings();
|
||||
self.clear_waypoints();
|
||||
self.boss_overlay.reset();
|
||||
self.remote = Some(Remote {
|
||||
session,
|
||||
brand: "unknown".into(),
|
||||
@@ -411,6 +441,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,
|
||||
@@ -440,6 +471,7 @@ impl Game<'_> {
|
||||
chunk_center: (0, 0),
|
||||
backlog: VecDeque::new(),
|
||||
backlog_since: None,
|
||||
transfer: None,
|
||||
});
|
||||
// The level's TickRateManager follows the server's ticking packets.
|
||||
self.clock = horizon_client::clock::FixedStep::client();
|
||||
@@ -460,6 +492,7 @@ impl Game<'_> {
|
||||
self.extras_left();
|
||||
}
|
||||
self.clear_waypoints();
|
||||
self.boss_overlay.reset();
|
||||
if let Some(mut r) = self.remote.take() {
|
||||
r.session.disconnect();
|
||||
}
|
||||
@@ -607,6 +640,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())
|
||||
@@ -733,6 +781,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;
|
||||
@@ -829,7 +880,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) => {
|
||||
@@ -838,6 +893,15 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Event::Reconfigured(registries) => self.set_registries(registries),
|
||||
// handleTransfer: "Cannot transfer to server from
|
||||
// singleplayer" (no ServerData), so the world just closes.
|
||||
Event::Transfer { host, port } => {
|
||||
if let Some(r) = self.remote.as_mut()
|
||||
&& r.session.address != "local"
|
||||
{
|
||||
r.transfer = Some(format!("{host}:{port}"));
|
||||
}
|
||||
}
|
||||
Event::PackPush(p) => self.handle_pack_push(p),
|
||||
Event::PackPop(id) => self.handle_pack_pop(id),
|
||||
Event::DecodeError(e) => {
|
||||
@@ -853,6 +917,20 @@ impl Game<'_> {
|
||||
from_server,
|
||||
} => {
|
||||
log::info!("Horizon: disconnected: {reason}");
|
||||
// ClientboundTransferPacket left a target: connect there
|
||||
// instead of showing the disconnect, the stored cookies
|
||||
// carried over (ClientCommonPacketListenerImpl.
|
||||
// handleTransfer → ConnectScreen.startConnecting).
|
||||
let transfer = self.remote.as_mut().and_then(|r| {
|
||||
r.transfer.take().map(|address| {
|
||||
(address, r.session.name.clone(), r.session.cookies.clone())
|
||||
})
|
||||
});
|
||||
if let Some((address, name, cookies)) = transfer {
|
||||
self.leave_server(None)?;
|
||||
self.connect_server_with_cookies(&address, &name, Some(cookies))?;
|
||||
return Ok(());
|
||||
}
|
||||
let retry = self.reconnect_after(&reason, from_server);
|
||||
self.leave_server(Some(reason))?;
|
||||
self.offer_reconnect(retry);
|
||||
@@ -986,6 +1064,9 @@ impl Game<'_> {
|
||||
r.stepped.clear();
|
||||
r.head_lerps.clear();
|
||||
r.pending_items.clear();
|
||||
// The entity ids are the old world's; the new dimension's
|
||||
// player entities re-map to their profiles on AddEntity.
|
||||
r.level_players.clear();
|
||||
// A new ClientLevel: a new chunk storage, centred on 0, 0.
|
||||
r.chunk_center = (0, 0);
|
||||
}
|
||||
@@ -1382,7 +1463,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,
|
||||
@@ -1461,6 +1542,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));
|
||||
@@ -1472,11 +1570,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) {
|
||||
@@ -1582,6 +1678,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;
|
||||
@@ -1786,6 +1888,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
|
||||
@@ -1807,6 +1912,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,
|
||||
@@ -1817,6 +1924,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 {
|
||||
@@ -2125,17 +2233,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;
|
||||
}
|
||||
}
|
||||
@@ -2201,9 +2321,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() {
|
||||
@@ -2653,6 +2778,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)),
|
||||
@@ -2933,7 +3059,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 {
|
||||
@@ -2944,7 +3072,7 @@ impl Game<'_> {
|
||||
stack,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
velocity,
|
||||
on_ground: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
@@ -2952,6 +3080,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;
|
||||
@@ -3007,6 +3152,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();
|
||||
@@ -3041,6 +3188,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) {
|
||||
@@ -3066,6 +3215,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();
|
||||
@@ -3149,6 +3299,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(());
|
||||
};
|
||||
@@ -3176,6 +3329,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,
|
||||
@@ -3219,7 +3373,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).
|
||||
@@ -3259,9 +3413,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;
|
||||
@@ -3320,11 +3475,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;
|
||||
};
|
||||
@@ -3427,20 +3625,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 {
|
||||
@@ -3789,7 +3979,7 @@ impl Game<'_> {
|
||||
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
// A boat, minecart, item frame, armor stand or painting nearer than
|
||||
// any mob: the server uses it (AbstractBoat.interact boards it).
|
||||
if let Some(hit) = self.remote_object_hit()
|
||||
if let Some(hit) = self.remote_object_hit(false)
|
||||
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
|
||||
&& self.before_block(hit.distance)
|
||||
{
|
||||
@@ -3875,11 +4065,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:
|
||||
@@ -3939,18 +4135,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 {
|
||||
@@ -4010,12 +4195,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]),
|
||||
@@ -4030,14 +4248,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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4139,11 +4359,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
|
||||
@@ -4157,7 +4379,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;
|
||||
}
|
||||
@@ -4266,12 +4488,12 @@ impl Game<'_> {
|
||||
id: self.creatures.projectiles[i].id as i32,
|
||||
}));
|
||||
self.reset_attack_strength();
|
||||
} else if let Some(hit) = self.remote_object_hit()
|
||||
} else if let Some(hit) = self.remote_object_hit(true)
|
||||
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
|
||||
&& self.before_block(hit.distance)
|
||||
{
|
||||
// VehicleEntity.hurtServer, ItemFrame and ArmorStand hurtServer,
|
||||
// a painting broken: the server decides.
|
||||
// a painting broken or an EndCrystal hurtServer: the server decides.
|
||||
self.send(S::Attack(play::Attack { id: hit.id }));
|
||||
self.reset_attack_strength();
|
||||
} else if let Some((i, distance)) = self.mob_hit()
|
||||
@@ -4478,12 +4700,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));
|
||||
}
|
||||
@@ -4552,7 +4772,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);
|
||||
@@ -4628,6 +4848,11 @@ impl Game<'_> {
|
||||
self.mount_menu_reset();
|
||||
// The client's menu has no level access: what it holds is the server's.
|
||||
self.station = None;
|
||||
// AbstractContainerMenu.removed: the server gives the grid and the
|
||||
// carried stack back to the inventory and syncs it; the copies here
|
||||
// are stale the moment the window closes.
|
||||
self.craft_grid.fill(None);
|
||||
self.carried = None;
|
||||
self.craft_size = 2;
|
||||
if let Some(key) = self.container.take() {
|
||||
// Server containers stay renderable but lose their copied slots.
|
||||
@@ -4987,6 +5212,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,
|
||||
@@ -5086,11 +5313,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
|
||||
@@ -5279,6 +5507,10 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// Players in the server's player list, this one included.
|
||||
pub(super) fn player_count(&self) -> usize {
|
||||
self.players.len()
|
||||
}
|
||||
/// The local player's profile UUID.
|
||||
pub(super) fn profile(&self) -> u128 {
|
||||
self.profile
|
||||
@@ -5411,4 +5643,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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
//! server (`LocalPlayer.tick`: `ServerboundMoveVehiclePacket`,
|
||||
//! `AbstractBoat.tick`: `ServerboundPaddleBoatPacket`). A minecart moves on
|
||||
//! the server with the rider's input, and the rider sits in it.
|
||||
use super::{Game, Keys, view_vector};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use super::{Game, Keys, ray_box, view_vector};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
|
||||
/// An object under the crosshair: its entity id, how far along the view it
|
||||
@@ -24,8 +24,8 @@ pub(super) struct ObjectHit {
|
||||
|
||||
impl Game<'_> {
|
||||
/// The nearest boat, minecart, item frame, armor stand or painting the
|
||||
/// view hits within reach.
|
||||
pub(super) fn remote_object_hit(&self) -> Option<ObjectHit> {
|
||||
/// view hits within reach; also End crystals when attacking.
|
||||
pub(super) fn remote_object_hit(&self, attacking: bool) -> Option<ObjectHit> {
|
||||
let eye = self.eye_position()?;
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let at = |d: f64| {
|
||||
@@ -64,6 +64,22 @@ impl Game<'_> {
|
||||
let p = self.carts[i].position;
|
||||
hits.push((self.carts[i].id as i32, d, [p.x, p.y, p.z]));
|
||||
}
|
||||
if attacking {
|
||||
// EndCrystal.isPickable: its 2 x 2 box, with the server's entity
|
||||
// id, not special_targets' local projectile target id.
|
||||
// Entity.interact passes on a crystal, so right clicks use the item.
|
||||
let reach = if self.creative { 5.0 } else { 3.0 };
|
||||
for c in self.crystals.iter().filter(|c| c.id != 0) {
|
||||
let p = c.position;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(p.x - 1.0, p.y, p.z - 1.0),
|
||||
Vec3::new(p.x + 1.0, p.y + 2.0, p.z + 1.0),
|
||||
);
|
||||
if let Some(d) = ray_box(eye, dir, reach, bounds) {
|
||||
hits.push((c.id as i32, d, [p.x, p.y, p.z]));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (id, distance, origin) = hits.into_iter().min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
let point = at(distance);
|
||||
Some(ObjectHit {
|
||||
@@ -88,8 +104,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 +147,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(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ impl Game<'_> {
|
||||
| Target::Cart(id)
|
||||
| Target::Other(id) => self.persist.raw.get(&id),
|
||||
Target::Player => self.level.player_extra.as_ref(),
|
||||
Target::Guest(_) | Target::Orb(_) | Target::Arrow(_) => None,
|
||||
Target::Guest(_) | Target::Orb(_) | Target::Projectile(_) => None,
|
||||
};
|
||||
match raw.and_then(|r| r.get("Tags")) {
|
||||
Some(Tag::List(l)) => l
|
||||
|
||||
@@ -8,14 +8,19 @@ use horizon_client::{
|
||||
Result,
|
||||
confirm_screen::Confirm,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
multiplayer, packs,
|
||||
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;
|
||||
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.
|
||||
@@ -75,6 +80,111 @@ pub(super) struct ServerPackState {
|
||||
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 {
|
||||
@@ -89,6 +199,7 @@ impl Game<'_> {
|
||||
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();
|
||||
@@ -103,6 +214,7 @@ impl Game<'_> {
|
||||
/// `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();
|
||||
}
|
||||
@@ -197,6 +309,7 @@ impl Game<'_> {
|
||||
/// 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;
|
||||
@@ -238,6 +351,15 @@ impl Game<'_> {
|
||||
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(),
|
||||
@@ -248,8 +370,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
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);
|
||||
queue.download(requests, headers, progress);
|
||||
}
|
||||
}
|
||||
/// `DownloadedPackSource.startReload`: the packs on top of the player's
|
||||
@@ -297,6 +423,29 @@ impl Game<'_> {
|
||||
.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 {
|
||||
@@ -314,3 +463,97 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Shader packs, for development until the Shader Packs page exists: `HORIZON_SHADER_PACK=<name>`
|
||||
//! runs `shaderpacks/<name>` (a folder or a zip beside `options.txt`) over the world. The
|
||||
//! programs of the current dimension's folder are loaded when the dimension changes (spec 1.2).
|
||||
|
||||
use horizon_client::camera::CameraFluid;
|
||||
use horizon_client::gfx::pack_uniforms::PackInputs;
|
||||
use horizon_shaders::{Macros, Pack, Properties, dimension_folder, load_post};
|
||||
|
||||
use super::{Dim, Game};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Loads the pack for the current dimension when it changed, and returns this frame's
|
||||
/// inputs to the pack's uniforms.
|
||||
pub(super) fn shader_pack_inputs(&mut self, camera: glam::DVec3, partial: f32) -> PackInputs {
|
||||
let Some(name) = std::env::var_os("HORIZON_SHADER_PACK") else {
|
||||
return PackInputs::default();
|
||||
};
|
||||
let dimension = match self.dim {
|
||||
Dim::Overworld => 0,
|
||||
Dim::Nether => -1,
|
||||
Dim::End => 1,
|
||||
};
|
||||
if let Some(renderer) = &mut self.renderer
|
||||
&& renderer.shader_pack_dimension() != Some(dimension)
|
||||
{
|
||||
let name = name.to_string_lossy();
|
||||
let started = std::time::Instant::now();
|
||||
let path = std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join("shaderpacks")
|
||||
.join(&*name);
|
||||
match Pack::open(&path) {
|
||||
Ok(pack) => {
|
||||
let macros = Macros::standard();
|
||||
let text = pack.read_text("shaders.properties").unwrap_or_default();
|
||||
let properties = Properties::parse(&text, ¯os);
|
||||
for note in &properties.notes {
|
||||
log::warn!(
|
||||
"shader pack {name}: shaders.properties:{}: {}",
|
||||
note.line,
|
||||
note.message
|
||||
);
|
||||
}
|
||||
let folder = dimension_folder(&pack, dimension);
|
||||
let post = load_post(&pack, &folder, &properties, ¯os, None);
|
||||
if let Some((programs, notes)) = renderer.set_shader_pack(dimension, Some(post))
|
||||
{
|
||||
for note in notes {
|
||||
log::warn!("shader pack {name}: {note}");
|
||||
}
|
||||
log::info!(
|
||||
"shader pack {name}: folder `{folder}`, {} programs ({}) in {} ms",
|
||||
programs.len(),
|
||||
programs.join(" "),
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("shader pack {name}: {e}");
|
||||
renderer.set_shader_pack(dimension, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
let lerp = |(old, new): (f32, f32)| old + (new - old) * partial;
|
||||
let at = camera.floor().as_ivec3();
|
||||
let light = self.world.light(at.x, at.y, at.z);
|
||||
PackInputs {
|
||||
day_time: self.world.day_time,
|
||||
rain: lerp(self.world.weather.rain_level),
|
||||
thunder: lerp(self.world.weather.thunder_level),
|
||||
eye_in: match self.camera_fluid(camera.to_array()) {
|
||||
CameraFluid::None => 0,
|
||||
CameraFluid::Water => 1,
|
||||
CameraFluid::Lava => 2,
|
||||
CameraFluid::PowderSnow => 3,
|
||||
},
|
||||
blindness: f32::from(u8::from(self.potions.effects.get("blindness").is_some())),
|
||||
eye_light: [i32::from(light & 15), i32::from(light >> 4)],
|
||||
hide_gui: self.ui.hide_gui,
|
||||
brightness: self.settings.gamma,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ impl StartupMenu {
|
||||
Some(PendingWorldEntry::Server {
|
||||
address: address.clone(),
|
||||
name: options.player_name(),
|
||||
cookies: None,
|
||||
})
|
||||
} else {
|
||||
options
|
||||
@@ -125,6 +126,7 @@ impl StartupMenu {
|
||||
Action::Connect { address } => PendingWorldEntry::Server {
|
||||
address,
|
||||
name: player_name,
|
||||
cookies: None,
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
@@ -762,7 +764,7 @@ mod tests {
|
||||
"Guest".into(),
|
||||
);
|
||||
assert!(
|
||||
matches!(menu.pending.take(), Some(PendingWorldEntry::Server { address, name })
|
||||
matches!(menu.pending.take(), Some(PendingWorldEntry::Server { address, name, .. })
|
||||
if address == "localhost:25565" && name == "Guest")
|
||||
);
|
||||
assert!(menu.pending.take().is_none(), "an entry is consumed once");
|
||||
|
||||
@@ -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,
|
||||
@@ -250,7 +267,7 @@ pub(super) fn make_streaming(
|
||||
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};
|
||||
@@ -68,6 +68,9 @@ mod flat;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
mod occlusion;
|
||||
mod pack_atlas;
|
||||
mod pack_post;
|
||||
pub mod pack_uniforms;
|
||||
mod page_runs;
|
||||
pub mod panorama;
|
||||
mod resident;
|
||||
@@ -87,6 +90,7 @@ fn world_pass_after_culling<'e>(
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
view: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
keep_depth: bool,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon world, after culling"),
|
||||
@@ -103,7 +107,12 @@ fn world_pass_after_culling<'e>(
|
||||
view: depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Discard,
|
||||
// A shader pack reads it as depthtex0.
|
||||
store: if keep_depth {
|
||||
wgpu::StoreOp::Store
|
||||
} else {
|
||||
wgpu::StoreOp::Discard
|
||||
},
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
@@ -156,9 +165,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.
|
||||
@@ -249,6 +258,8 @@ pub struct Frame<'a> {
|
||||
pub hand_fov_degrees: f32,
|
||||
/// Full-screen camera effects drawn over the world and hand, under the HUD.
|
||||
pub screen: &'a [crate::screen_effects::ScreenQuad],
|
||||
/// What a shader pack's uniforms need from the game.
|
||||
pub pack: pack_uniforms::PackInputs,
|
||||
/// The world border wall, when near enough to be drawn.
|
||||
pub border: Option<BorderDraw>,
|
||||
pub game_time: f32,
|
||||
@@ -268,6 +279,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 +338,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,
|
||||
@@ -456,6 +471,10 @@ pub struct Renderer {
|
||||
occlusion: occlusion::Occlusion,
|
||||
/// Resolution scale and FXAA (Horizon settings).
|
||||
world_post: world_post::Post,
|
||||
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
|
||||
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
|
||||
/// The block sprites as one atlas, while a shader pack is loaded.
|
||||
pack_atlas: Option<pack_atlas::PackAtlas>,
|
||||
pub hud: HudRenderer,
|
||||
/// Vanilla's menu background blur, made on first use.
|
||||
menu_blur: Option<crate::menu_blur::MenuBlur>,
|
||||
@@ -1304,6 +1323,8 @@ impl Renderer {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_DST
|
||||
// Shader packs' atlas copies them (pack_atlas.rs).
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::STORAGE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
@@ -2157,7 +2178,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 +2225,7 @@ impl Renderer {
|
||||
translucent,
|
||||
flat_on: flat.is_some(),
|
||||
cull_on: cull.is_some(),
|
||||
sides_on: true,
|
||||
cull,
|
||||
flat,
|
||||
sky,
|
||||
@@ -2300,6 +2322,8 @@ impl Renderer {
|
||||
block_entity_revision: next_block_entity_revision(),
|
||||
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
|
||||
world_post,
|
||||
pack_post: None,
|
||||
pack_atlas: None,
|
||||
hud,
|
||||
menu_blur: None,
|
||||
info,
|
||||
@@ -2453,6 +2477,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 +2607,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>()
|
||||
@@ -2718,6 +2754,51 @@ impl Renderer {
|
||||
self.world_post.scale = scale.clamp(0.25, 2.0);
|
||||
self.world_post.antialias = antialias;
|
||||
}
|
||||
/// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`.
|
||||
/// Returns the programs that run and the notes of loading them.
|
||||
pub fn set_shader_pack(
|
||||
&mut self,
|
||||
dimension: i32,
|
||||
pack: Option<horizon_shaders::Post>,
|
||||
) -> Option<(Vec<String>, Vec<String>)> {
|
||||
let post = pack.map(|post| {
|
||||
pack_post::PackPost::new(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
post,
|
||||
self.config.format.remove_srgb_suffix(),
|
||||
)
|
||||
});
|
||||
if post.is_none() {
|
||||
self.pack_atlas = None;
|
||||
} else if self.pack_atlas.is_none() {
|
||||
let mut encoder = self.device.create_command_encoder(&Default::default());
|
||||
let layers = self
|
||||
.sprite_textures
|
||||
.iter()
|
||||
.map(|t| t.depth_or_array_layers())
|
||||
.sum();
|
||||
self.pack_atlas = pack_atlas::PackAtlas::new(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
&self.sprite_textures,
|
||||
sprite_split(),
|
||||
layers,
|
||||
self.sprite_textures[0].width(),
|
||||
);
|
||||
self.queue.submit([encoder.finish()]);
|
||||
}
|
||||
let report = post.as_ref().map(|p| {
|
||||
let programs = p.programs().into_iter().map(str::to_owned).collect();
|
||||
(programs, p.notes.clone())
|
||||
});
|
||||
self.pack_post = Some((dimension, post));
|
||||
report
|
||||
}
|
||||
/// The dimension the shader pack was last set for.
|
||||
pub fn shader_pack_dimension(&self) -> Option<i32> {
|
||||
self.pack_post.as_ref().map(|p| p.0)
|
||||
}
|
||||
/// The Mipmap Levels option: block textures sample at most `levels`
|
||||
/// reduced copies (0: none).
|
||||
pub fn set_mipmap_levels(&mut self, levels: u32) {
|
||||
@@ -2778,6 +2859,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 +2877,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);
|
||||
@@ -2834,6 +2918,13 @@ impl Renderer {
|
||||
}
|
||||
fn animate(&mut self, encoder: &mut wgpu::CommandEncoder, tick: u64) {
|
||||
self.animations.animate(encoder, &mut self.uploads, tick);
|
||||
if let Some(atlas) = &self.pack_atlas {
|
||||
atlas.copy(
|
||||
encoder,
|
||||
&self.sprite_textures,
|
||||
self.animations.changed().iter().copied(),
|
||||
);
|
||||
}
|
||||
}
|
||||
pub fn render(&mut self, frame: &Frame) -> Result<bool> {
|
||||
let rendered = self.draw_frame(frame);
|
||||
@@ -2909,6 +3000,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 +3134,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 +3201,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 +3208,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 +3226,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 +3268,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 +3610,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;
|
||||
@@ -3579,9 +3671,12 @@ impl Renderer {
|
||||
timer.begin(&mut encoder);
|
||||
}
|
||||
// The world's targets: the screen, or the scaled target of `post`.
|
||||
let scaled = self
|
||||
.world_post
|
||||
.prepare(&self.device, [self.config.width, self.config.height]);
|
||||
let pack_post = !frame.title && self.pack_post.as_ref().is_some_and(|p| p.1.is_some());
|
||||
let scaled = self.world_post.prepare(
|
||||
&self.device,
|
||||
[self.config.width, self.config.height],
|
||||
pack_post,
|
||||
);
|
||||
let (world_view, world_depth) = match &scaled {
|
||||
Some((color, depth)) => (color, depth),
|
||||
None => (&view_target, &self.depth),
|
||||
@@ -3618,8 +3713,8 @@ impl Renderer {
|
||||
view: world_depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
// Phase 2 of occlusion culling draws over it.
|
||||
store: if cull.is_some() {
|
||||
// Phase 2 of occlusion culling draws over it; a shader pack reads it.
|
||||
store: if cull.is_some() || pack_post {
|
||||
wgpu::StoreOp::Store
|
||||
} else {
|
||||
wgpu::StoreOp::Discard
|
||||
@@ -3678,7 +3773,12 @@ impl Renderer {
|
||||
drop(pass);
|
||||
cull.test_pass(&mut encoder);
|
||||
flat.flatten_phase2(&mut encoder);
|
||||
pass = world_pass_after_culling(&mut encoder, world_view, world_depth);
|
||||
pass = world_pass_after_culling(
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
pack_post,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_pipeline(&flat.solid);
|
||||
flat.draw_phase2(&mut pass, vertices);
|
||||
@@ -3702,7 +3802,12 @@ impl Renderer {
|
||||
cull.test(&mut pass);
|
||||
drop(pass);
|
||||
cull.test_pass(&mut encoder);
|
||||
pass = world_pass_after_culling(&mut encoder, world_view, world_depth);
|
||||
pass = world_pass_after_culling(
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
pack_post,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_pipeline(&self.solid);
|
||||
pass.set_index_buffer(
|
||||
@@ -3861,7 +3966,24 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
}
|
||||
if scaled.is_some() {
|
||||
if let Some((color, depth)) = &scaled
|
||||
&& pack_post
|
||||
&& let Some((_, Some(post))) = &mut self.pack_post
|
||||
{
|
||||
let size = self.world_post.size().unwrap_or([1, 1]);
|
||||
post.run(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&mut encoder,
|
||||
color,
|
||||
depth,
|
||||
&view_target,
|
||||
frame,
|
||||
&frame.pack,
|
||||
size,
|
||||
[self.config.width, self.config.height],
|
||||
);
|
||||
} else if scaled.is_some() {
|
||||
self.world_post.finish(&mut encoder, &view_target);
|
||||
}
|
||||
if hand_quads > 0 {
|
||||
@@ -4202,6 +4324,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 +4579,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)
|
||||
|
||||
@@ -51,6 +51,8 @@ pub(super) struct SpriteAnimator {
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
/// Edge of the arrays' layers.
|
||||
size: u32,
|
||||
/// The layers the last `animate` rewrote, counted over all arrays.
|
||||
changed: Vec<u32>,
|
||||
}
|
||||
|
||||
impl SpriteAnimator {
|
||||
@@ -205,9 +207,15 @@ impl SpriteAnimator {
|
||||
jobs,
|
||||
pipeline,
|
||||
size,
|
||||
changed: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The layers the last `animate` rewrote (for the shader pack atlas).
|
||||
pub fn changed(&self) -> &[u32] {
|
||||
&self.changed
|
||||
}
|
||||
|
||||
/// Show every sprite's frame at `tick`, rewriting only the sprites whose
|
||||
/// frame or mix changed (`SpriteContents.AnimationState.tick`).
|
||||
pub fn animate(
|
||||
@@ -245,12 +253,14 @@ impl SpriteAnimator {
|
||||
/// its layer shows; the number of jobs.
|
||||
fn collect(&mut self, tick: u64) -> usize {
|
||||
let mut count = 0;
|
||||
self.changed.clear();
|
||||
for sprite in &mut self.sprites {
|
||||
let blend = sprite.animation.blend(tick);
|
||||
if sprite.shown == Some(blend) {
|
||||
continue;
|
||||
}
|
||||
sprite.shown = Some(blend);
|
||||
self.changed.push(sprite.animation.layer);
|
||||
let job = [
|
||||
sprite.layer,
|
||||
sprite.frames[blend.frame],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! The block atlas of shader packs (spec 5.1, `atlasSize` in 5.2): packs sample one 2D atlas
|
||||
//! with atlas coordinates, Horizon keeps block sprites in texture arrays. With a pack, every
|
||||
//! layer is copied into a grid, row by row from the top left, and an animated layer again
|
||||
//! whenever its frame changes.
|
||||
|
||||
#[expect(dead_code, reason = "the gbuffers pass binds it (next slice)")]
|
||||
pub(super) struct PackAtlas {
|
||||
texture: wgpu::Texture,
|
||||
pub view: wgpu::TextureView,
|
||||
/// Sprites per row.
|
||||
pub columns: u32,
|
||||
/// A sprite's edge in pixels, at the first level.
|
||||
pub sprite: u32,
|
||||
/// Layers per sprite array.
|
||||
split: u32,
|
||||
}
|
||||
|
||||
impl PackAtlas {
|
||||
/// Copies `layers` layers of `arrays` (`split` per array, `sprite` pixels wide, `levels`
|
||||
/// mip levels); `None` when the grid is larger than the device allows.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
split: u32,
|
||||
layers: u32,
|
||||
sprite: u32,
|
||||
) -> Option<Self> {
|
||||
let layers = layers.max(1);
|
||||
let columns = (f64::from(layers).sqrt().ceil() as u32).max(1);
|
||||
let rows = layers.div_ceil(columns);
|
||||
let max = device.limits().max_texture_dimension_2d;
|
||||
if columns * sprite > max || rows * sprite > max {
|
||||
return None;
|
||||
}
|
||||
// Levels past a sprite's single pixel would mix neighbours: stop there.
|
||||
let levels = arrays[0].mip_level_count().min(sprite.ilog2() + 1);
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack block atlas"),
|
||||
size: wgpu::Extent3d {
|
||||
width: columns * sprite,
|
||||
height: rows * sprite,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: levels,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_DST
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
let atlas = Self {
|
||||
texture,
|
||||
view,
|
||||
columns,
|
||||
sprite,
|
||||
split,
|
||||
};
|
||||
atlas.copy(encoder, arrays, 0..layers);
|
||||
Some(atlas)
|
||||
}
|
||||
|
||||
/// The atlas's size in pixels (`atlasSize`).
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "the gbuffers pass reads it (next slice)")
|
||||
)]
|
||||
pub fn size(&self) -> [u32; 2] {
|
||||
[self.texture.width(), self.texture.height()]
|
||||
}
|
||||
|
||||
/// Copies `layers` from the sprite arrays again, every level.
|
||||
pub fn copy(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
layers: impl IntoIterator<Item = u32>,
|
||||
) {
|
||||
for layer in layers {
|
||||
let Some(array) = arrays.get((layer / self.split) as usize) else {
|
||||
continue;
|
||||
};
|
||||
let (column, row) = (layer % self.columns, layer / self.columns);
|
||||
for level in 0..self.texture.mip_level_count() {
|
||||
let edge = self.sprite >> level;
|
||||
encoder.copy_texture_to_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: array,
|
||||
mip_level: level,
|
||||
origin: wgpu::Origin3d {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: layer % self.split,
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &self.texture,
|
||||
mip_level: level,
|
||||
origin: wgpu::Origin3d {
|
||||
x: column * edge,
|
||||
y: row * edge,
|
||||
z: 0,
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: edge,
|
||||
height: edge,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Two arrays of 2x2 sprites, one layer split; each layer a colour of its own.
|
||||
#[test]
|
||||
fn layers_land_in_their_cells() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let (split, layers, sprite) = (3u32, 5u32, 2u32);
|
||||
let arrays: Vec<wgpu::Texture> = (0..2)
|
||||
.map(|a| {
|
||||
let count = (layers - a * split).min(split);
|
||||
let pixels: Vec<u8> = (0..count)
|
||||
.flat_map(|l| [(a * split + l) as u8 * 40, 0, 0, 255].repeat(4))
|
||||
.collect();
|
||||
wgpu::util::DeviceExt::create_texture_with_data(
|
||||
&device,
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: sprite,
|
||||
height: sprite,
|
||||
depth_or_array_layers: count,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&pixels,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let atlas = PackAtlas::new(&device, &mut encoder, &arrays, split, layers, sprite).unwrap();
|
||||
assert_eq!((atlas.columns, atlas.size()), (3, [6, 4]));
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256 * 4,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
atlas.texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: 6,
|
||||
height: 4,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = read.slice(..).get_mapped_range().unwrap();
|
||||
let red = |x: usize, y: usize| data[y * 256 + x * 4];
|
||||
// Layer 4 (second array, its second layer) is in row 1, column 1.
|
||||
assert_eq!([red(0, 0), red(2, 0), red(4, 0)], [0, 40, 80]);
|
||||
assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,929 @@
|
||||
//! A shader pack's post-processing over Horizon's own world (spec 2.1 rows 4, 6, 8, 9; 6.1; 6.2;
|
||||
//! 6.4): the world renders into its own target as with a resolution scale, its colour becomes
|
||||
//! colortex0 and its depth `depthtex0`, then the pack's `prepare`, `deferred`, `composite` and
|
||||
//! `final` programs run in order, and `final` draws onto the screen under the hand and HUD.
|
||||
//! Gbuffers programs come later; until then the pack's composites read Horizon's picture.
|
||||
//!
|
||||
//! Every colortex buffer a program reads or writes has two textures, A and B. Programs read
|
||||
//! "main" and write "alt", and each buffer a program wrote flips afterwards (6.1.6). The
|
||||
//! mapping carries over to the next frame, so a buffer that is not cleared reads back what was
|
||||
//! written last (OQ-8). Buffers are cleared at the start of the frame, "main" only.
|
||||
//! Pack buffers store rows as GL does (row 0 at the bottom: the translator negates clip y), so
|
||||
//! the world is copied in upside down, and its reverse-Z depth becomes GL window depth.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use horizon_shaders::{Format, Group, Kind, Post};
|
||||
|
||||
use super::pack_uniforms::{self, FrameUniforms, PackInputs};
|
||||
|
||||
const BUFFERS: usize = horizon_shaders::BUFFERS;
|
||||
|
||||
/// The texture behind a pack sampler name.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Source {
|
||||
Color(usize),
|
||||
Depth,
|
||||
Noise,
|
||||
/// A 1×1 stand-in for names this slice does not provide yet (custom textures...).
|
||||
Dummy,
|
||||
/// A 1×1 depth of 1.0 behind a comparison sampler, until shadow maps exist: no shadow.
|
||||
Shadow,
|
||||
}
|
||||
|
||||
struct Binding {
|
||||
source: Source,
|
||||
binding: u32,
|
||||
/// A filtering sampler (the texture is filterable).
|
||||
filtering: bool,
|
||||
}
|
||||
|
||||
struct Pass {
|
||||
name: String,
|
||||
group: Group,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
ubo: wgpu::Buffer,
|
||||
block: Vec<u8>,
|
||||
/// (index into `pack_uniforms::NAMES`, declared type, offset).
|
||||
uniforms: Vec<(Option<usize>, String, u32)>,
|
||||
samplers: Vec<Binding>,
|
||||
/// (location, buffer) of each output that reaches a buffer.
|
||||
writes: Vec<(u32, usize)>,
|
||||
/// Buffers flipped after the pass.
|
||||
flip: u16,
|
||||
/// Buffers sampled, for the bind group cache key.
|
||||
reads: u16,
|
||||
/// Buffers written.
|
||||
written: u16,
|
||||
groups: HashMap<u16, wgpu::BindGroup>,
|
||||
}
|
||||
|
||||
struct Targets {
|
||||
size: [u32; 2],
|
||||
/// A and B of each buffer used.
|
||||
color: [Option<[wgpu::TextureView; 2]>; BUFFERS],
|
||||
depth: wgpu::TextureView,
|
||||
}
|
||||
|
||||
pub struct PackPost {
|
||||
pub notes: Vec<String>,
|
||||
passes: Vec<Pass>,
|
||||
deferred_pre: u16,
|
||||
composite_pre: u16,
|
||||
formats: [wgpu::TextureFormat; BUFFERS],
|
||||
clears: [Option<[f32; 4]>; BUFFERS],
|
||||
used: u16,
|
||||
/// Bit n set: B is buffer n's "main".
|
||||
main: u16,
|
||||
targets: Option<Targets>,
|
||||
noise: wgpu::TextureView,
|
||||
dummy: wgpu::TextureView,
|
||||
/// The stand-in shadow map and its comparison sampler; cleared on the first frame.
|
||||
shadow: (wgpu::TextureView, wgpu::Sampler),
|
||||
shadow_cleared: bool,
|
||||
linear: wgpu::Sampler,
|
||||
nearest: wgpu::Sampler,
|
||||
repeat: wgpu::Sampler,
|
||||
blit: (wgpu::RenderPipeline, wgpu::BindGroupLayout),
|
||||
/// The blit also writes depthtex0 (some program reads it) and clears these buffers.
|
||||
blit_depth: bool,
|
||||
blit_clears: Vec<usize>,
|
||||
/// Buffers cleared in passes of their own (more than the blit's eight attachments).
|
||||
other_clears: Vec<usize>,
|
||||
/// The blit's bindings, for the world target of this size.
|
||||
blit_group: Option<([u32; 2], wgpu::BindGroup)>,
|
||||
/// Shows colortex0 when the pack has no `final` (OQ-5).
|
||||
copy: (wgpu::RenderPipeline, wgpu::BindGroupLayout),
|
||||
uniforms: FrameUniforms,
|
||||
}
|
||||
|
||||
/// The wgpu format of a pack format (6.7), the nearest one wgpu can render to.
|
||||
fn texture_format(f: Format, features: wgpu::Features) -> wgpu::TextureFormat {
|
||||
use wgpu::TextureFormat as T;
|
||||
let pick = |r, rg, rgba| match f.channels {
|
||||
1 => r,
|
||||
2 => rg,
|
||||
_ => rgba,
|
||||
};
|
||||
let half = pick(T::R16Float, T::Rg16Float, T::Rgba16Float);
|
||||
match (f.kind, f.bits) {
|
||||
(Kind::Unorm, 10) => T::Rgb10a2Unorm,
|
||||
(Kind::Unorm, 16) if features.contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM) => {
|
||||
pick(T::R16Unorm, T::Rg16Unorm, T::Rgba16Unorm)
|
||||
}
|
||||
(Kind::Unorm, b) if b <= 8 => pick(T::R8Unorm, T::Rg8Unorm, T::Rgba8Unorm),
|
||||
(Kind::Float, 32) => pick(T::R32Float, T::Rg32Float, T::Rgba32Float),
|
||||
(Kind::Float, 11) if features.contains(wgpu::Features::RG11B10UFLOAT_RENDERABLE) => {
|
||||
T::Rg11b10Ufloat
|
||||
}
|
||||
(Kind::Int, 8) => pick(T::R8Sint, T::Rg8Sint, T::Rgba8Sint),
|
||||
(Kind::Int, 16) => pick(T::R16Sint, T::Rg16Sint, T::Rgba16Sint),
|
||||
(Kind::Int, _) => pick(T::R32Sint, T::Rg32Sint, T::Rgba32Sint),
|
||||
(Kind::Uint, 8) => pick(T::R8Uint, T::Rg8Uint, T::Rgba8Uint),
|
||||
(Kind::Uint, 10) => T::Rgb10a2Uint,
|
||||
(Kind::Uint, 16) => pick(T::R16Uint, T::Rg16Uint, T::Rgba16Uint),
|
||||
(Kind::Uint, _) => pick(T::R32Uint, T::Rg32Uint, T::Rgba32Uint),
|
||||
// Snorm, 16-bit norm without the feature, RGB9_E5: half floats hold them.
|
||||
_ => half,
|
||||
}
|
||||
}
|
||||
|
||||
fn source(name: &str) -> Source {
|
||||
match name {
|
||||
"depthtex0" | "gdepthtex" | "depthtex1" | "depthtex2" => Source::Depth,
|
||||
"noisetex" => Source::Noise,
|
||||
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
|
||||
}
|
||||
}
|
||||
|
||||
/// 256×256 RGB white noise (6.4), from a fixed seed so every run looks the same.
|
||||
fn noise(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
let mut state = 0x2545_f491_4f6c_dd1du64;
|
||||
let texels: Vec<u8> = (0..256 * 256 * 4)
|
||||
.map(|i| {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
if i % 4 == 3 { 255 } else { (state >> 56) as u8 }
|
||||
})
|
||||
.collect();
|
||||
texture(device, queue, "Shader pack noisetex", 256, &texels)
|
||||
}
|
||||
|
||||
fn texture(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
label: &str,
|
||||
side: u32,
|
||||
texels: &[u8],
|
||||
) -> wgpu::TextureView {
|
||||
let size = wgpu::Extent3d {
|
||||
width: side,
|
||||
height: side,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let tex = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
tex.as_image_copy(),
|
||||
texels,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(side * 4),
|
||||
rows_per_image: None,
|
||||
},
|
||||
size,
|
||||
);
|
||||
tex.create_view(&Default::default())
|
||||
}
|
||||
|
||||
fn sampler(
|
||||
device: &wgpu::Device,
|
||||
filter: wgpu::FilterMode,
|
||||
address: wgpu::AddressMode,
|
||||
) -> wgpu::Sampler {
|
||||
device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Shader pack"),
|
||||
address_mode_u: address,
|
||||
address_mode_v: address,
|
||||
address_mode_w: address,
|
||||
mag_filter: filter,
|
||||
min_filter: filter,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `create` and returns its validation error, if any.
|
||||
fn checked<T>(device: &wgpu::Device, create: impl FnOnce() -> T) -> Result<T, String> {
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let value = create();
|
||||
match pollster::block_on(scope.pop()) {
|
||||
Some(e) => {
|
||||
// The details are in the error's sources.
|
||||
let mut message = e.to_string();
|
||||
let mut source = std::error::Error::source(&e);
|
||||
while let Some(s) = source {
|
||||
message = format!("{message}: {s}");
|
||||
source = s.source();
|
||||
}
|
||||
Err(message)
|
||||
}
|
||||
None => Ok(value),
|
||||
}
|
||||
}
|
||||
|
||||
/// A full-screen helper pipeline of ours (`pack_post.wgsl`).
|
||||
fn helper(
|
||||
device: &wgpu::Device,
|
||||
vertex: &str,
|
||||
entry: &str,
|
||||
entries: &[wgpu::BindGroupLayoutEntry],
|
||||
targets: &[Option<wgpu::ColorTargetState>],
|
||||
) -> (wgpu::RenderPipeline, wgpu::BindGroupLayout) {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Shader pack helpers"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("pack_post.wgsl").into()),
|
||||
});
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some(entry),
|
||||
entries,
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some(entry),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(entry),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some(vertex),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
(pipeline, layout)
|
||||
}
|
||||
|
||||
fn entry(binding: u32, ty: wgpu::BindingType) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty,
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry {
|
||||
entry(
|
||||
binding,
|
||||
wgpu::BindingType::Texture {
|
||||
sample_type,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn target(format: wgpu::TextureFormat) -> Option<wgpu::ColorTargetState> {
|
||||
Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})
|
||||
}
|
||||
|
||||
fn attach(
|
||||
view: &wgpu::TextureView,
|
||||
ops: wgpu::Operations<wgpu::Color>,
|
||||
) -> Option<wgpu::RenderPassColorAttachment<'_>> {
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops,
|
||||
})
|
||||
}
|
||||
|
||||
impl PackPost {
|
||||
/// Builds the pipelines of `post` for a screen of `screen` format.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
post: Post,
|
||||
screen: wgpu::TextureFormat,
|
||||
) -> Self {
|
||||
let features = device.features();
|
||||
let formats: [wgpu::TextureFormat; BUFFERS] =
|
||||
std::array::from_fn(|i| texture_format(post.config.buffers[i].format, features));
|
||||
let clears = std::array::from_fn(|i| {
|
||||
let b = &post.config.buffers[i];
|
||||
b.clear.then(|| {
|
||||
b.clear_color
|
||||
.unwrap_or(if i == 1 { [1.0; 4] } else { [0.0; 4] })
|
||||
})
|
||||
});
|
||||
let mut notes = post.notes;
|
||||
notes.extend(post.config.notes);
|
||||
let mask =
|
||||
|flips: &[(u32, bool)]| flips.iter().filter(|f| f.1).fold(0u16, |m, f| m | 1 << f.0);
|
||||
let mut used = 1u16;
|
||||
let mut passes = Vec::new();
|
||||
for p in post.programs {
|
||||
let interface = &p.program.interface;
|
||||
let mut entries = vec![entry(
|
||||
0,
|
||||
wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
)];
|
||||
let mut samplers = Vec::new();
|
||||
let mut reads = 0u16;
|
||||
for s in &interface.samplers {
|
||||
let mut src = source(&s.name);
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let mut sample_type = match src {
|
||||
Source::Color(n) => formats[n]
|
||||
.sample_type(None, Some(features))
|
||||
.unwrap_or(float),
|
||||
Source::Depth => wgpu::TextureSampleType::Float { filterable: false },
|
||||
_ => float,
|
||||
};
|
||||
let int = !matches!(sample_type, wgpu::TextureSampleType::Float { .. });
|
||||
if s.ty == "sampler2DShadow" {
|
||||
(src, sample_type) = (Source::Shadow, wgpu::TextureSampleType::Depth);
|
||||
}
|
||||
let wanted = match s.ty.as_str() {
|
||||
"sampler2DShadow" => true,
|
||||
"sampler2D" => !int,
|
||||
"isampler2D" => sample_type == wgpu::TextureSampleType::Sint,
|
||||
"usampler2D" => sample_type == wgpu::TextureSampleType::Uint,
|
||||
_ => false,
|
||||
};
|
||||
if !wanted {
|
||||
notes.push(format!(
|
||||
"{}: `{} {}` is not provided yet",
|
||||
p.name, s.ty, s.name
|
||||
));
|
||||
(src, sample_type) = (Source::Dummy, float);
|
||||
}
|
||||
let filtering = sample_type == float;
|
||||
if let Source::Color(n) = src {
|
||||
reads |= 1 << n;
|
||||
}
|
||||
entries.push(texture_entry(s.binding, sample_type));
|
||||
entries.push(entry(
|
||||
s.binding + 1,
|
||||
wgpu::BindingType::Sampler(if src == Source::Shadow {
|
||||
wgpu::SamplerBindingType::Comparison
|
||||
} else if filtering {
|
||||
wgpu::SamplerBindingType::Filtering
|
||||
} else {
|
||||
wgpu::SamplerBindingType::NonFiltering
|
||||
}),
|
||||
));
|
||||
samplers.push(Binding {
|
||||
source: src,
|
||||
binding: s.binding,
|
||||
filtering,
|
||||
});
|
||||
}
|
||||
let final_pass = p.group == Group::Final;
|
||||
let mut targets = Vec::new();
|
||||
let mut writes = Vec::new();
|
||||
for &location in &interface.outputs {
|
||||
let l = location as usize;
|
||||
if targets.len() <= l {
|
||||
targets.resize(l + 1, None);
|
||||
}
|
||||
if final_pass {
|
||||
if location == 0 {
|
||||
targets[0] = target(screen);
|
||||
}
|
||||
} else if let Some(&buffer) = p.buffers.get(l) {
|
||||
targets[l] = target(formats[buffer as usize]);
|
||||
writes.push((location, buffer as usize));
|
||||
}
|
||||
}
|
||||
if !final_pass && writes.is_empty() {
|
||||
notes.push(format!("{}: writes no buffer; skipped", p.name));
|
||||
continue;
|
||||
}
|
||||
let written = writes.iter().fold(0u16, |m, w| m | 1 << w.1);
|
||||
let kept = p
|
||||
.flips
|
||||
.iter()
|
||||
.filter(|f| !f.1)
|
||||
.fold(0u16, |m, f| m | 1 << f.0);
|
||||
let flip = if final_pass {
|
||||
0
|
||||
} else {
|
||||
(written & !kept) | mask(&p.flips)
|
||||
};
|
||||
used |= reads | written | flip;
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some(&p.name),
|
||||
entries: &entries,
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some(&p.name),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let created = checked(device, || {
|
||||
let module = |m: naga::Module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&p.name),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(m)),
|
||||
})
|
||||
};
|
||||
let (vs, fs) = (module(p.program.vertex), module(p.program.fragment));
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&p.name),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
});
|
||||
let pipeline = match created {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
notes.push(format!("{}: skipped: {e}", p.name));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let interface = &p.program.interface;
|
||||
passes.push(Pass {
|
||||
ubo: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(&p.name),
|
||||
size: u64::from(interface.uniform_size.max(16)),
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
block: vec![0; interface.uniform_size as usize],
|
||||
uniforms: interface
|
||||
.uniforms
|
||||
.iter()
|
||||
.map(|u| (pack_uniforms::index(&u.name), u.ty.clone(), u.offset))
|
||||
.collect(),
|
||||
name: p.name,
|
||||
group: p.group,
|
||||
pipeline,
|
||||
layout,
|
||||
samplers,
|
||||
writes,
|
||||
flip,
|
||||
reads,
|
||||
written,
|
||||
groups: HashMap::new(),
|
||||
});
|
||||
}
|
||||
// A clear only matters when a program reads the buffer before one writes all of it
|
||||
// (composite passes cover their whole target); the others are skipped.
|
||||
let mut cleared = Vec::new();
|
||||
for b in 1..BUFFERS {
|
||||
let bit = 1u16 << b;
|
||||
let first = passes
|
||||
.iter()
|
||||
.find(|p| (p.reads | p.flip | p.written) & bit != 0);
|
||||
if clears[b].is_some()
|
||||
&& first.is_some_and(|p| p.reads & bit != 0 || p.written & bit == 0)
|
||||
{
|
||||
cleared.push(b);
|
||||
}
|
||||
}
|
||||
let blit_depth = passes
|
||||
.iter()
|
||||
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth));
|
||||
let room = 8 - 1 - usize::from(blit_depth);
|
||||
let other_clears = cleared.split_off(room.min(cleared.len()));
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let mut blit_targets = vec![target(formats[0])];
|
||||
if blit_depth {
|
||||
blit_targets.push(target(wgpu::TextureFormat::R32Float));
|
||||
}
|
||||
// Cleared by the pass's load operation; the shader writes nothing there.
|
||||
blit_targets.extend(cleared.iter().map(|&b| {
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: formats[b],
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})
|
||||
}));
|
||||
let blit = helper(
|
||||
device,
|
||||
"vs_full",
|
||||
if blit_depth {
|
||||
"fs_blit"
|
||||
} else {
|
||||
"fs_blit_color"
|
||||
},
|
||||
&[
|
||||
texture_entry(0, float),
|
||||
texture_entry(1, wgpu::TextureSampleType::Depth),
|
||||
],
|
||||
&blit_targets,
|
||||
);
|
||||
let copy = helper(
|
||||
device,
|
||||
"vs_uv",
|
||||
"fs_copy",
|
||||
&[
|
||||
texture_entry(0, float),
|
||||
entry(
|
||||
2,
|
||||
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
),
|
||||
],
|
||||
&[target(screen)],
|
||||
);
|
||||
let white = [255u8; 4];
|
||||
Self {
|
||||
notes,
|
||||
passes,
|
||||
deferred_pre: mask(&post.deferred_pre),
|
||||
composite_pre: mask(&post.composite_pre),
|
||||
formats,
|
||||
clears,
|
||||
used,
|
||||
blit_depth,
|
||||
blit_clears: cleared,
|
||||
other_clears,
|
||||
blit_group: None,
|
||||
main: 0,
|
||||
targets: None,
|
||||
noise: noise(device, queue),
|
||||
dummy: texture(device, queue, "Shader pack stand-in", 1, &white),
|
||||
shadow: (
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack shadow stand-in"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default()),
|
||||
device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Shader pack shadow"),
|
||||
compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
shadow_cleared: false,
|
||||
linear: sampler(
|
||||
device,
|
||||
wgpu::FilterMode::Linear,
|
||||
wgpu::AddressMode::ClampToEdge,
|
||||
),
|
||||
nearest: sampler(
|
||||
device,
|
||||
wgpu::FilterMode::Nearest,
|
||||
wgpu::AddressMode::ClampToEdge,
|
||||
),
|
||||
repeat: sampler(device, wgpu::FilterMode::Linear, wgpu::AddressMode::Repeat),
|
||||
blit,
|
||||
copy,
|
||||
uniforms: FrameUniforms::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The names of the programs that run, in order.
|
||||
pub fn programs(&self) -> Vec<&str> {
|
||||
self.passes.iter().map(|p| p.name.as_str()).collect()
|
||||
}
|
||||
|
||||
fn resize(&mut self, device: &wgpu::Device, size: [u32; 2]) {
|
||||
if self.targets.as_ref().is_some_and(|t| t.size == size) {
|
||||
return;
|
||||
}
|
||||
let make = |label: &str, format| {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
let color = std::array::from_fn(|i| {
|
||||
(self.used & 1 << i != 0).then(|| {
|
||||
let label = format!("colortex{i}");
|
||||
[make(&label, self.formats[i]), make(&label, self.formats[i])]
|
||||
})
|
||||
});
|
||||
self.targets = Some(Targets {
|
||||
size,
|
||||
color,
|
||||
depth: make("depthtex0", wgpu::TextureFormat::R32Float),
|
||||
});
|
||||
for pass in &mut self.passes {
|
||||
pass.groups.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the pack over the world drawn into `world` (colour) and `world_depth`, then draws
|
||||
/// the result onto `screen`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
world: &wgpu::TextureView,
|
||||
world_depth: &wgpu::TextureView,
|
||||
screen: &wgpu::TextureView,
|
||||
frame: &super::Frame,
|
||||
inputs: &PackInputs,
|
||||
size: [u32; 2],
|
||||
window: [u32; 2],
|
||||
) {
|
||||
self.resize(device, size);
|
||||
self.uniforms.update(frame, inputs, size);
|
||||
let Some(targets) = &self.targets else { return };
|
||||
let main = |m: u16, i: usize| usize::from(m & 1 << i != 0);
|
||||
let view = |m: u16, i: usize, alt: bool| {
|
||||
targets.color[i]
|
||||
.as_ref()
|
||||
.map(|t| &t[main(m, i) ^ usize::from(alt)])
|
||||
};
|
||||
|
||||
if !self.shadow_cleared {
|
||||
self.shadow_cleared = true;
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack shadow stand-in"),
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.shadow.0,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(1.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let clear_op = |b: usize| {
|
||||
let c = self.clears[b].unwrap_or_default();
|
||||
wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color {
|
||||
r: c[0].into(),
|
||||
g: c[1].into(),
|
||||
b: c[2].into(),
|
||||
a: c[3].into(),
|
||||
}),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}
|
||||
};
|
||||
// Overwritten whole: clearing lets a tiled GPU skip loading the old content.
|
||||
let overwrite = wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
};
|
||||
for chunk in self.other_clears.chunks(8) {
|
||||
let attachments: Vec<_> = chunk
|
||||
.iter()
|
||||
.map(|&b| attach(view(self.main, b, false).expect("used buffer"), clear_op(b)))
|
||||
.collect();
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack clear"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// The world into colortex0 and depthtex0, flipped to GL's rows, and the clears.
|
||||
if self.blit_group.as_ref().is_none_or(|g| g.0 != size) {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack world"),
|
||||
layout: &self.blit.1,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(world),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(world_depth),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.blit_group = Some((size, group));
|
||||
}
|
||||
{
|
||||
let mut attachments = vec![attach(
|
||||
view(self.main, 0, false).expect("colortex0"),
|
||||
overwrite,
|
||||
)];
|
||||
if self.blit_depth {
|
||||
attachments.push(attach(&targets.depth, overwrite));
|
||||
}
|
||||
for &b in &self.blit_clears {
|
||||
attachments.push(attach(
|
||||
view(self.main, b, false).expect("used buffer"),
|
||||
clear_op(b),
|
||||
));
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack world"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.blit.0);
|
||||
pass.set_bind_group(0, self.blit_group.as_ref().map(|g| &g.1), &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
let fog = self.uniforms.fog();
|
||||
let mut seen = (false, false);
|
||||
let mut shown = false;
|
||||
for pass in &mut self.passes {
|
||||
if pass.group == Group::Deferred && !seen.0 {
|
||||
seen.0 = true;
|
||||
self.main ^= self.deferred_pre;
|
||||
}
|
||||
if pass.group == Group::Composite && !seen.1 {
|
||||
seen.1 = true;
|
||||
self.main ^= self.composite_pre;
|
||||
}
|
||||
let final_pass = pass.group == Group::Final;
|
||||
let out = if final_pass { window } else { size };
|
||||
// Our block members first (`hz_target`, `hz_fog_color`, `hz_fog`), then the pack's.
|
||||
let ours = [
|
||||
[
|
||||
out[0] as f32,
|
||||
out[1] as f32,
|
||||
1.0 / out[0] as f32,
|
||||
1.0 / out[1] as f32,
|
||||
],
|
||||
fog[0].to_array(),
|
||||
fog[1].to_array(),
|
||||
];
|
||||
let head: &[u8] = bytemuck::cast_slice(&ours);
|
||||
let n = head.len().min(pass.block.len());
|
||||
pass.block[..n].copy_from_slice(&head[..n]);
|
||||
for (index, ty, offset) in &pass.uniforms {
|
||||
if let Some(v) = index.and_then(|i| self.uniforms.values.get(i)) {
|
||||
v.write(ty, &mut pass.block, *offset as usize);
|
||||
}
|
||||
}
|
||||
if !pass.block.is_empty() {
|
||||
queue.write_buffer(&pass.ubo, 0, &pass.block);
|
||||
}
|
||||
let key = self.main & pass.reads;
|
||||
let group = pass.groups.entry(key).or_insert_with(|| {
|
||||
let mut entries = vec![wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: pass.ubo.as_entire_binding(),
|
||||
}];
|
||||
for s in &pass.samplers {
|
||||
let filter = if s.filtering {
|
||||
&self.linear
|
||||
} else {
|
||||
&self.nearest
|
||||
};
|
||||
let (texture, sampler) = match s.source {
|
||||
Source::Color(i) => (view(key, i, false).unwrap_or(&self.dummy), filter),
|
||||
Source::Depth => (&targets.depth, &self.nearest),
|
||||
Source::Noise => (&self.noise, &self.repeat),
|
||||
Source::Dummy => (&self.dummy, &self.linear),
|
||||
Source::Shadow => (&self.shadow.0, &self.shadow.1),
|
||||
};
|
||||
entries.push(wgpu::BindGroupEntry {
|
||||
binding: s.binding,
|
||||
resource: wgpu::BindingResource::TextureView(texture),
|
||||
});
|
||||
entries.push(wgpu::BindGroupEntry {
|
||||
binding: s.binding + 1,
|
||||
resource: wgpu::BindingResource::Sampler(sampler),
|
||||
});
|
||||
}
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some(&pass.name),
|
||||
layout: &pass.layout,
|
||||
entries: &entries,
|
||||
})
|
||||
});
|
||||
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
|
||||
if final_pass {
|
||||
attachments.push(attach(screen, overwrite));
|
||||
shown = true;
|
||||
} else {
|
||||
for &(location, buffer) in &pass.writes {
|
||||
let l = location as usize;
|
||||
if attachments.len() <= l {
|
||||
attachments.resize(l + 1, None);
|
||||
}
|
||||
attachments[l] =
|
||||
view(self.main, buffer, true).and_then(|v| attach(v, overwrite));
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(&pass.name),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
rp.set_pipeline(&pass.pipeline);
|
||||
rp.set_bind_group(0, &*group, &[]);
|
||||
rp.draw(0..6, 0..1);
|
||||
}
|
||||
self.main ^= pass.flip;
|
||||
}
|
||||
if !shown {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack colortex0"),
|
||||
layout: &self.copy.1,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(
|
||||
view(self.main, 0, false).expect("colortex0"),
|
||||
),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&self.linear),
|
||||
},
|
||||
],
|
||||
});
|
||||
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack colortex0"),
|
||||
color_attachments: &[attach(screen, overwrite)],
|
||||
..Default::default()
|
||||
});
|
||||
rp.set_pipeline(&self.copy.0);
|
||||
rp.set_bind_group(0, &group, &[]);
|
||||
rp.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
#[test]
|
||||
fn pack_formats_map_to_renderable_ones() {
|
||||
let f = |name| texture_format(Format::parse(name).unwrap(), wgpu::Features::empty());
|
||||
assert_eq!(f("RGBA"), T::Rgba8Unorm);
|
||||
assert_eq!(f("RGB8"), T::Rgba8Unorm, "no three-channel formats in wgpu");
|
||||
assert_eq!(f("RG16F"), T::Rg16Float);
|
||||
assert_eq!(f("R32F"), T::R32Float);
|
||||
assert_eq!(f("RGB10_A2"), T::Rgb10a2Unorm);
|
||||
assert_eq!(f("R32UI"), T::R32Uint);
|
||||
assert_eq!(f("RGBA16"), T::Rgba16Float, "16-bit norm needs a feature");
|
||||
assert_eq!(f("RGBA8_SNORM"), T::Rgba16Float, "snorm is not renderable");
|
||||
assert_eq!(f("R11F_G11F_B10F"), T::Rgba16Float);
|
||||
let all =
|
||||
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM | wgpu::Features::RG11B10UFLOAT_RENDERABLE;
|
||||
let g = |name| texture_format(Format::parse(name).unwrap(), all);
|
||||
assert_eq!(g("RGBA16"), T::Rgba16Unorm);
|
||||
assert_eq!(g("R11F_G11F_B10F"), T::Rg11b10Ufloat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampler_names() {
|
||||
assert!(source("gaux1") == Source::Color(4));
|
||||
assert!(source("gdepthtex") == Source::Depth);
|
||||
assert!(source("noisetex") == Source::Noise);
|
||||
assert!(source("shadowcolor0") == Source::Dummy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Helpers of a shader pack's post-processing (pack_post.rs): the world into colortex0 and
|
||||
// depthtex0, and colortex0 onto the screen for packs without `final`.
|
||||
|
||||
@vertex
|
||||
fn vs_full(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return vec4<f32>(uv * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
@group(0) @binding(1) var world_depth: texture_depth_2d;
|
||||
|
||||
struct World {
|
||||
@location(0) color: vec4<f32>,
|
||||
@location(1) depth: vec4<f32>,
|
||||
}
|
||||
|
||||
// Pack buffers keep GL's rows (row 0 at the bottom), and GL's window depth: Horizon's
|
||||
// reverse-Z depth is 1 - d for the same near and far planes.
|
||||
@fragment
|
||||
fn fs_blit(@builtin(position) p: vec4<f32>) -> World {
|
||||
let size = vec2<i32>(textureDimensions(source));
|
||||
let at = vec2<i32>(i32(p.x), size.y - 1 - i32(p.y));
|
||||
var out: World;
|
||||
out.color = textureLoad(source, at, 0);
|
||||
out.depth = vec4<f32>(1.0 - textureLoad(world_depth, at, 0), 0.0, 0.0, 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
// The same without depthtex0, for packs that never read it.
|
||||
@fragment
|
||||
fn fs_blit_color(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let size = vec2<i32>(textureDimensions(source));
|
||||
return textureLoad(source, vec2<i32>(i32(p.x), size.y - 1 - i32(p.y)), 0);
|
||||
}
|
||||
|
||||
@group(0) @binding(2) var linear: sampler;
|
||||
|
||||
struct Full {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_uv(@builtin(vertex_index) i: u32) -> Full {
|
||||
let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
// Screen top (uv.y = 0 in wgpu) shows the buffer's top row, GL's last.
|
||||
return Full(vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0), vec2<f32>(uv.x, 1.0 - uv.y));
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_copy(v: Full) -> @location(0) vec4<f32> {
|
||||
return textureSample(source, linear, v.uv);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//! The uniforms a shader pack's post-processing programs read once per frame (spec 5.2, class
|
||||
//! F), from the renderer's `Frame` and the few values only the game knows (`PackInputs`).
|
||||
//! Matrices follow GL's conventions (5.5): `gbufferProjection` maps view depth to clip z in
|
||||
//! [-w, w], and the depth buffers packs read hold GL window depth.
|
||||
|
||||
use glam::{Mat4, Vec3, Vec4};
|
||||
|
||||
use super::Frame;
|
||||
|
||||
/// What the game passes for the pack's uniforms, beyond what `Frame` carries.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct PackInputs {
|
||||
/// `Level.getDayTime`.
|
||||
pub day_time: i64,
|
||||
/// The interpolated rain and thunder levels, 0..1.
|
||||
pub rain: f32,
|
||||
pub thunder: f32,
|
||||
/// The fluid at the camera: 0 none, 1 water, 2 lava, 3 powder snow.
|
||||
pub eye_in: i32,
|
||||
pub blindness: f32,
|
||||
/// Block and sky light at the camera, 0..15.
|
||||
pub eye_light: [i32; 2],
|
||||
pub hide_gui: bool,
|
||||
/// The brightness option, 0..1.
|
||||
pub brightness: f32,
|
||||
}
|
||||
|
||||
/// One uniform's value: up to 16 floats or 3 integers.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Value {
|
||||
F([f32; 16]),
|
||||
I([i32; 3]),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
fn f(v: &[f32]) -> Self {
|
||||
let mut a = [0.0; 16];
|
||||
a[..v.len()].copy_from_slice(v);
|
||||
Value::F(a)
|
||||
}
|
||||
fn i(v: &[i32]) -> Self {
|
||||
let mut a = [0; 3];
|
||||
a[..v.len()].copy_from_slice(v);
|
||||
Value::I(a)
|
||||
}
|
||||
|
||||
/// Writes the value as the type the pack declared, at `offset` of a std140 block.
|
||||
pub fn write(&self, ty: &str, out: &mut [u8], offset: usize) {
|
||||
let ints = matches!(ty, "int" | "bool" | "uint") || ty.starts_with(['i', 'u']);
|
||||
let (floats, integers): ([f32; 16], [i32; 16]) = match *self {
|
||||
Value::F(f) => (f, f.map(|v| v as i32)),
|
||||
Value::I(i) => {
|
||||
let mut f = [0.0; 16];
|
||||
let mut v = [0; 16];
|
||||
for k in 0..3 {
|
||||
f[k] = i[k] as f32;
|
||||
v[k] = i[k];
|
||||
}
|
||||
(f, v)
|
||||
}
|
||||
};
|
||||
let count = match ty {
|
||||
"mat4" => 16,
|
||||
"mat3" => {
|
||||
// std140: three columns, each padded to a vec4, from the mat4's upper left.
|
||||
for c in 0..3 {
|
||||
let column: [f32; 3] = std::array::from_fn(|r| floats[c * 4 + r]);
|
||||
put(out, offset + c * 16, bytemuck::cast_slice(&column));
|
||||
}
|
||||
return;
|
||||
}
|
||||
t => match t.as_bytes().last() {
|
||||
Some(n @ b'2'..=b'4') if t.contains("vec") => (n - b'0') as usize,
|
||||
_ => 1,
|
||||
},
|
||||
};
|
||||
if ints {
|
||||
put(out, offset, bytemuck::cast_slice(&integers[..count]));
|
||||
} else {
|
||||
put(out, offset, bytemuck::cast_slice(&floats[..count]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn put(out: &mut [u8], offset: usize, bytes: &[u8]) {
|
||||
if let Some(dst) = out.get_mut(offset..offset + bytes.len()) {
|
||||
dst.copy_from_slice(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// The uniforms provided, in the order of [`FrameUniforms::values`].
|
||||
pub const NAMES: &[&str] = &[
|
||||
"cameraPosition",
|
||||
"previousCameraPosition",
|
||||
"eyeAltitude",
|
||||
"upPosition",
|
||||
"sunPosition",
|
||||
"moonPosition",
|
||||
"shadowLightPosition",
|
||||
"sunAngle",
|
||||
"shadowAngle",
|
||||
"worldTime",
|
||||
"worldDay",
|
||||
"moonPhase",
|
||||
"frameCounter",
|
||||
"frameTime",
|
||||
"frameTimeCounter",
|
||||
"viewWidth",
|
||||
"viewHeight",
|
||||
"aspectRatio",
|
||||
"near",
|
||||
"far",
|
||||
"rainStrength",
|
||||
"wetness",
|
||||
"thunderStrength",
|
||||
"fogColor",
|
||||
"skyColor",
|
||||
"fogMode",
|
||||
"fogStart",
|
||||
"fogEnd",
|
||||
"fogDensity",
|
||||
"fogShape",
|
||||
"isEyeInWater",
|
||||
"blindness",
|
||||
"nightVision",
|
||||
"darknessFactor",
|
||||
"darknessLightFactor",
|
||||
"screenBrightness",
|
||||
"hideGUI",
|
||||
"eyeBrightness",
|
||||
"eyeBrightnessSmooth",
|
||||
"heldItemId",
|
||||
"heldItemId2",
|
||||
"heldBlockLightValue",
|
||||
"heldBlockLightValue2",
|
||||
"renderStage",
|
||||
"gbufferModelView",
|
||||
"gbufferModelViewInverse",
|
||||
"gbufferProjection",
|
||||
"gbufferProjectionInverse",
|
||||
"gbufferPreviousModelView",
|
||||
"gbufferPreviousProjection",
|
||||
];
|
||||
|
||||
/// Values carried from frame to frame.
|
||||
pub struct FrameUniforms {
|
||||
pub values: Vec<Value>,
|
||||
start: std::time::Instant,
|
||||
last: Option<std::time::Instant>,
|
||||
frame: u32,
|
||||
previous: Option<(Vec3, Mat4, Mat4)>,
|
||||
wetness: f32,
|
||||
eye_smooth: [f32; 2],
|
||||
}
|
||||
|
||||
impl Default for FrameUniforms {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
values: Vec::new(),
|
||||
start: std::time::Instant::now(),
|
||||
last: None,
|
||||
frame: 0,
|
||||
previous: None,
|
||||
wetness: 0.0,
|
||||
eye_smooth: [0.0; 2],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `(0, 100, 0)` on the frame the sky draws a body on: Y -90°, then X by its angle in degrees.
|
||||
fn body(angle: f32) -> Vec3 {
|
||||
(Mat4::from_rotation_y((-90.0f32).to_radians()) * Mat4::from_rotation_x(angle.to_radians()))
|
||||
.transform_vector3(Vec3::new(0.0, 100.0, 0.0))
|
||||
}
|
||||
|
||||
/// `value` moved toward `target` with a half-life of `half_life` seconds over `dt` (5.6).
|
||||
fn smooth(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
|
||||
target + (value - target) * 0.5f32.powf(dt / half_life)
|
||||
}
|
||||
|
||||
impl FrameUniforms {
|
||||
/// This frame's values for a render size of `size` pixels.
|
||||
pub fn update(&mut self, frame: &Frame, inputs: &PackInputs, size: [u32; 2]) {
|
||||
let now = std::time::Instant::now();
|
||||
let dt = self.last.map_or(0.0, |l| (now - l).as_secs_f32());
|
||||
self.last = Some(now);
|
||||
let aspect = size[0] as f32 / size[1].max(1) as f32;
|
||||
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
|
||||
let projection = Mat4::perspective_rh_gl(
|
||||
frame.fov_degrees.to_radians(),
|
||||
aspect,
|
||||
0.05,
|
||||
frame.depth_far,
|
||||
) * frame.bob;
|
||||
let camera = frame.camera.as_vec3();
|
||||
let (previous_camera, previous_view, previous_projection) =
|
||||
self.previous
|
||||
.unwrap_or((Vec3::ZERO, Mat4::ZERO, Mat4::ZERO));
|
||||
self.previous = Some((camera, view, projection));
|
||||
|
||||
let to_view = |v: Vec3| view.transform_vector3(v);
|
||||
let sun_angle = (frame.sky.sun_angle / 360.0 + 0.25).rem_euclid(1.0);
|
||||
let day = sun_angle < 0.5;
|
||||
let sun = to_view(body(frame.sky.sun_angle));
|
||||
let moon = to_view(body(frame.sky.moon_angle));
|
||||
// Half-lives are given in ticks (7.2); smoothing runs per frame in seconds (OQ-26).
|
||||
let half_life = if inputs.rain > self.wetness {
|
||||
600.0
|
||||
} else {
|
||||
200.0
|
||||
};
|
||||
self.wetness = smooth(self.wetness, inputs.rain, half_life / 20.0, dt);
|
||||
let eye = inputs.eye_light.map(|l| (l * 16) as f32);
|
||||
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
|
||||
*smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt);
|
||||
}
|
||||
let (fog_color, fog_start, fog_end) = match &frame.fog {
|
||||
Some(f) => (
|
||||
[f.color[0], f.color[1], f.color[2]],
|
||||
f.environmental_start.min(f.render_distance_start),
|
||||
f.environmental_end.min(f.render_distance_end),
|
||||
),
|
||||
None => (frame.sky.fog_color, frame.distance * 0.9, frame.distance),
|
||||
};
|
||||
let day_time = inputs.day_time.rem_euclid(24000) as i32;
|
||||
let world_day = inputs.day_time.div_euclid(24000);
|
||||
let seconds = (now - self.start).as_secs_f32() % 3600.0;
|
||||
|
||||
let value = |name: &str| -> Value {
|
||||
let v3 = |v: Vec3| Value::f(&v.to_array());
|
||||
let m4 = |m: Mat4| Value::f(&m.to_cols_array());
|
||||
match name {
|
||||
"cameraPosition" => v3(camera),
|
||||
"previousCameraPosition" => v3(previous_camera),
|
||||
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
|
||||
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
|
||||
"sunPosition" => v3(sun),
|
||||
"moonPosition" => v3(moon),
|
||||
"shadowLightPosition" => v3(if day { sun } else { moon }),
|
||||
"sunAngle" => Value::f(&[sun_angle]),
|
||||
"shadowAngle" => Value::f(&[if day { sun_angle } else { sun_angle - 0.5 }]),
|
||||
"worldTime" => Value::i(&[day_time]),
|
||||
"worldDay" => Value::i(&[world_day as i32]),
|
||||
"moonPhase" => Value::i(&[world_day.rem_euclid(8) as i32]),
|
||||
"frameCounter" => Value::i(&[self.frame as i32]),
|
||||
"frameTime" => Value::f(&[dt]),
|
||||
"frameTimeCounter" => Value::f(&[seconds]),
|
||||
"viewWidth" => Value::f(&[size[0] as f32]),
|
||||
"viewHeight" => Value::f(&[size[1] as f32]),
|
||||
"aspectRatio" => Value::f(&[aspect]),
|
||||
"near" => Value::f(&[0.05]),
|
||||
"far" => Value::f(&[frame.distance]),
|
||||
"rainStrength" => Value::f(&[inputs.rain]),
|
||||
"wetness" => Value::f(&[self.wetness]),
|
||||
"thunderStrength" => Value::f(&[inputs.thunder]),
|
||||
"fogColor" => Value::f(&fog_color),
|
||||
"skyColor" => Value::f(&frame.sky.sky_color),
|
||||
// GL_LINEAR: vanilla's fog is linear.
|
||||
"fogMode" => Value::i(&[9729]),
|
||||
"fogStart" => Value::f(&[fog_start]),
|
||||
"fogEnd" => Value::f(&[fog_end]),
|
||||
"fogDensity" => Value::f(&[1.0]),
|
||||
"fogShape" => Value::i(&[0]),
|
||||
"isEyeInWater" => Value::i(&[inputs.eye_in]),
|
||||
"blindness" => Value::f(&[inputs.blindness]),
|
||||
"nightVision" => Value::f(&[frame.night_vision]),
|
||||
"darknessFactor" => Value::f(&[frame.darkness.0]),
|
||||
"darknessLightFactor" => Value::f(&[frame.darkness.1]),
|
||||
"screenBrightness" => Value::f(&[inputs.brightness]),
|
||||
"hideGUI" => Value::i(&[inputs.hide_gui as i32]),
|
||||
"eyeBrightness" => Value::i(&eye.map(|v| v as i32)),
|
||||
"eyeBrightnessSmooth" => Value::i(&self.eye_smooth.map(|v| v.round() as i32)),
|
||||
"gbufferModelView" => m4(view),
|
||||
"gbufferModelViewInverse" => m4(view.inverse()),
|
||||
"gbufferProjection" => m4(projection),
|
||||
"gbufferProjectionInverse" => m4(projection.inverse()),
|
||||
"gbufferPreviousModelView" => m4(previous_view),
|
||||
"gbufferPreviousProjection" => m4(previous_projection),
|
||||
// Held items wait for `item.properties` (5.3: 0 without the file); renderStage
|
||||
// is NONE outside gbuffers draws.
|
||||
_ => Value::i(&[0]),
|
||||
}
|
||||
};
|
||||
self.values = NAMES.iter().map(|n| value(n)).collect();
|
||||
self.frame = (self.frame + 1) % 720_720;
|
||||
}
|
||||
|
||||
/// `hz_fog_color` and `hz_fog` of the translator's block (the compatibility `gl_Fog`).
|
||||
pub fn fog(&self) -> [Vec4; 2] {
|
||||
let get = |name| match index(name).and_then(|i| self.values.get(i)) {
|
||||
Some(Value::F(f)) => *f,
|
||||
_ => [0.0; 16],
|
||||
};
|
||||
let (color, start, end) = (get("fogColor"), get("fogStart")[0], get("fogEnd")[0]);
|
||||
[
|
||||
Vec4::new(color[0], color[1], color[2], 1.0),
|
||||
Vec4::new(1.0, start, end, 1.0 / (end - start).max(1e-3)),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// The position of `name` in [`NAMES`].
|
||||
pub fn index(name: &str) -> Option<usize> {
|
||||
NAMES.iter().position(|n| *n == name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn values_follow_the_declared_type() {
|
||||
let mut out = vec![0u8; 64];
|
||||
Value::i(&[7]).write("float", &mut out, 0);
|
||||
assert_eq!(f32::from_ne_bytes(out[0..4].try_into().unwrap()), 7.0);
|
||||
Value::f(&[1.5, 2.5]).write("ivec2", &mut out, 4);
|
||||
assert_eq!(i32::from_ne_bytes(out[8..12].try_into().unwrap()), 2);
|
||||
Value::i(&[1]).write("bool", &mut out, 16);
|
||||
assert_eq!(i32::from_ne_bytes(out[16..20].try_into().unwrap()), 1);
|
||||
Value::f(&[1.0; 16]).write("mat4", &mut out, 60);
|
||||
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noon_is_straight_up_and_the_sun_sets_in_the_west() {
|
||||
assert!(body(0.0).abs_diff_eq(Vec3::new(0.0, 100.0, 0.0), 1e-3));
|
||||
assert!(body(90.0).abs_diff_eq(Vec3::new(-100.0, 0.0, 0.0), 1e-3));
|
||||
}
|
||||
}
|
||||
@@ -101,12 +101,14 @@ impl Post {
|
||||
|
||||
/// The world's colour and depth targets this frame, or `None` to draw
|
||||
/// it straight to the screen.
|
||||
/// `always`: a target even at scale 1 without FXAA (a shader pack reads it).
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
window: [u32; 2],
|
||||
always: bool,
|
||||
) -> Option<(wgpu::TextureView, wgpu::TextureView)> {
|
||||
if self.scale == 1.0 && !self.antialias {
|
||||
if self.scale == 1.0 && !self.antialias && !always {
|
||||
self.target = None;
|
||||
return None;
|
||||
}
|
||||
@@ -155,6 +157,11 @@ impl Post {
|
||||
.map(|t| (t.color.clone(), t.depth.clone()))
|
||||
}
|
||||
|
||||
/// The size of the world's own target, when it has one.
|
||||
pub fn size(&self) -> Option<[u32; 2]> {
|
||||
self.target.as_ref().map(|t| t.size)
|
||||
}
|
||||
|
||||
/// Bindings that copy (or anti-alias) `view` onto the screen in `finish_with`.
|
||||
pub fn bind(&self, device: &wgpu::Device, view: &wgpu::TextureView) -> wgpu::BindGroup {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -78,7 +78,10 @@ pub const MAP_SLOTS: usize = 256;
|
||||
/// The atlas slot the minimap extra draws into.
|
||||
pub const MINIMAP_SPRITE: &str = "horizon/minimap";
|
||||
/// Atlas slots for downloaded player skins (`skins/<sha1>`), 64x64 each.
|
||||
pub const SKIN_SLOTS: usize = 32;
|
||||
/// Vanilla registers a texture per skin. Here a skin evicted from its slot
|
||||
/// is not stored again, and its player falls back to the default skin, so
|
||||
/// the slots must cover a busy server's player list.
|
||||
pub const SKIN_SLOTS: usize = 128;
|
||||
/// 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1982,7 +1984,9 @@ impl Hub {
|
||||
S::PlayerInput(p) => {
|
||||
self.guests[i].input = p.flags;
|
||||
self.guests[i].sneaking = p.flags & 32 != 0;
|
||||
self.guests[i].sprinting = p.flags & 64 != 0 && p.flags & 1 != 0;
|
||||
// handlePlayerInput only records keys and updates crouching.
|
||||
// Sprinting persists after the sprint key is released (or
|
||||
// starts with a double tap); PlayerCommand changes it.
|
||||
}
|
||||
// OPEN_INVENTORY (the ridden mount's inventory) and
|
||||
// START_FALL_FLYING (checked against the guest's chest slot
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -37,6 +37,21 @@ pub trait ResourceSource {
|
||||
vec!["minecraft".into()]
|
||||
}
|
||||
}
|
||||
/// Horizon's own strings, compiled in so they need no prepared resources.
|
||||
struct Bundled;
|
||||
impl ResourceSource for Bundled {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
let body = match path {
|
||||
"assets/horizon/lang/en_us.json" => include_str!("../assets/horizon/lang/en_us.json"),
|
||||
"assets/horizon/lang/ru_ru.json" => include_str!("../assets/horizon/lang/ru_ru.json"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(body.as_bytes().to_vec())
|
||||
}
|
||||
fn namespaces(&self) -> Vec<String> {
|
||||
vec!["horizon".into()]
|
||||
}
|
||||
}
|
||||
impl ResourceSource for std::path::PathBuf {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
std::fs::read(self.join(path)).ok()
|
||||
@@ -86,6 +101,12 @@ impl Language {
|
||||
if code != DEFAULT {
|
||||
codes.push(code);
|
||||
}
|
||||
// Horizon's own strings first, so a pack or the prepared root can still
|
||||
// override them. Vanilla has no key for "Controller vibration".
|
||||
let bundled = Bundled;
|
||||
let mut all: Vec<&dyn ResourceSource> = vec![&bundled];
|
||||
all.extend_from_slice(sources);
|
||||
let sources = all.as_slice();
|
||||
let namespaces: std::collections::BTreeSet<String> =
|
||||
sources.iter().flat_map(|s| s.namespaces()).collect();
|
||||
for c in codes {
|
||||
@@ -204,6 +225,16 @@ mod tests {
|
||||
self.0.get(path).cloned()
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn bundled_horizon_strings_follow_the_language() {
|
||||
// No prepared resources at all: the namespace is compiled in.
|
||||
let empty = Mem(HashMap::new());
|
||||
let en = Language::load(&[&empty], "en_us");
|
||||
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
|
||||
let ru = Language::load(&[&empty], "ru_ru");
|
||||
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_selected_language_over_english() {
|
||||
let base = Mem(HashMap::from([
|
||||
|
||||
@@ -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;
|
||||
@@ -53,6 +54,7 @@ pub mod datapack;
|
||||
pub mod debug_chart;
|
||||
pub mod debug_keys;
|
||||
pub mod debug_overlay;
|
||||
pub mod discord_presence;
|
||||
pub mod dynamic_light;
|
||||
pub mod effects;
|
||||
pub mod enchant;
|
||||
@@ -177,6 +179,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 };
|
||||
|
||||
Loaded 100 of 385 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user