mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
174
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
643f331f8c | ||
|
|
4e7c25bb55 | ||
|
|
e423e570c9 | ||
|
|
87aa88de54 | ||
|
|
535f2d2a04 | ||
|
|
7eb7791240 | ||
|
|
ca90a20992 | ||
|
|
923368f4df | ||
|
|
25bdaf5321 | ||
|
|
8a04e18b91 | ||
|
|
5d54f28c42 | ||
|
|
818d1515c6 | ||
|
|
e61e6345bc | ||
|
|
02039fed5b | ||
|
|
976956fc3e | ||
|
|
dfae12cfe2 | ||
|
|
8a8348c93f | ||
|
|
0a3965a7b1 | ||
|
|
704931ed14 | ||
|
|
a713dbd46d | ||
|
|
1868fc39ae | ||
|
|
50f2894ca8 | ||
|
|
4b44399242 | ||
|
|
43e6bfcf98 | ||
|
|
f688726b34 | ||
|
|
826cf8ffe6 | ||
|
|
e2dd6b67df | ||
|
|
ebd25abd0e | ||
|
|
9624e99801 | ||
|
|
6a5c749f7c | ||
|
|
c33acfdd05 | ||
|
|
7c83fbb6df | ||
|
|
c26ced533e | ||
|
|
e0d29dc233 | ||
|
|
6eb34eb868 | ||
|
|
b21423ae01 | ||
|
|
a6e9374f88 | ||
|
|
50aee22996 | ||
|
|
01133fac82 | ||
|
|
4507ded0e4 | ||
|
|
46304cc1a7 | ||
|
|
377d81a329 | ||
|
|
905acfe7cd | ||
|
|
c18f3dbfde | ||
|
|
9c843819b7 | ||
|
|
c466c5ddad | ||
|
|
d5bd35025e | ||
|
|
2cd8b88a5b | ||
|
|
0b581f9ffe | ||
|
|
f67b2b3204 | ||
|
|
d139f9bee6 | ||
|
|
03d93d79b2 | ||
|
|
1b14e0d171 | ||
|
|
f8e4e31b12 | ||
|
|
67e07f3faa | ||
|
|
2bbbc70620 | ||
|
|
9c134f54ac | ||
|
|
a00dc868ec | ||
|
|
08306d1586 | ||
|
|
3b70dc9c16 | ||
|
|
13c14563c4 | ||
|
|
99b058442c | ||
|
|
8cce2e2f8a | ||
|
|
582856aac2 | ||
|
|
0a95a78e03 | ||
|
|
5205b909f7 | ||
|
|
5ec40c8004 | ||
|
|
82bba24f43 | ||
|
|
7b0316e6a4 | ||
|
|
a29e10ef9b | ||
|
|
92050712fa | ||
|
|
122e3bf287 | ||
|
|
65031a7739 | ||
|
|
82218db860 | ||
|
|
040ac1bec4 | ||
|
|
59f018eabe | ||
|
|
969bcec6c9 | ||
|
|
ca0637da80 | ||
|
|
47459b0b19 | ||
|
|
9b16d37e80 | ||
|
|
03be14bb59 | ||
|
|
45e5b64520 | ||
|
|
d65be4183f | ||
|
|
e307f03684 | ||
|
|
26a91e3a08 | ||
|
|
8c81cc63f7 | ||
|
|
221545e92b | ||
|
|
88b27070bd | ||
|
|
499f8bddfc | ||
|
|
5904922865 | ||
|
|
49e706e8be | ||
|
|
5e63b0512d | ||
|
|
d66466db81 | ||
|
|
6b55abd66a | ||
|
|
22b2711cf9 | ||
|
|
6ea7167cbb | ||
|
|
51de8afe95 | ||
|
|
d8dd2023dd | ||
|
|
3e04df41c8 | ||
|
|
7371b2cccd | ||
|
|
d84d7a7e61 | ||
|
|
077c4c9214 | ||
|
|
1cd0dd8c5d | ||
|
|
8f081ae460 | ||
|
|
14461c1d96 | ||
|
|
7f60a55ca3 | ||
|
|
ea424ebd52 | ||
|
|
16db2f44ae | ||
|
|
0b56d62273 | ||
|
|
574e3ca567 | ||
|
|
2a2b4acb5c | ||
|
|
52bc81ca5b | ||
|
|
e1fff77a48 | ||
|
|
265461f2eb | ||
|
|
fc9822b241 | ||
|
|
d6dc08098a | ||
|
|
4b1e298a44 | ||
|
|
a4a101ca9c | ||
|
|
c6ed3112e7 | ||
|
|
f96101cd47 | ||
|
|
fa4ea80d32 | ||
|
|
0bbe50c6e8 | ||
|
|
c924894624 | ||
|
|
ba8bdc7179 | ||
|
|
455a9996d2 | ||
|
|
cbc469249e | ||
|
|
287d7a7eb5 | ||
|
|
c86996c234 | ||
|
|
d7b5cd8c85 | ||
|
|
4daa91cf52 | ||
|
|
d3de3cd0a9 | ||
|
|
5595db9077 | ||
|
|
c89695dd93 | ||
|
|
fe70ff90b4 | ||
|
|
4d9baf60d4 | ||
|
|
0a36a6d8c6 | ||
|
|
0b0e4a8aae | ||
|
|
4a442f7250 | ||
|
|
f47f6ee472 | ||
|
|
85c6103d45 | ||
|
|
c19418b55d | ||
|
|
8acb46756b | ||
|
|
ee79b8f0cd | ||
|
|
8667cef3a1 | ||
|
|
fe1d544d02 | ||
|
|
0cc3b4a057 | ||
|
|
81fb4c7371 | ||
|
|
60611ed5a3 | ||
|
|
4fa5966338 | ||
|
|
eb79cfdb01 | ||
|
|
7dac46ec3c | ||
|
|
717f54bf28 | ||
|
|
8ef418e223 | ||
|
|
ad76f911bd | ||
|
|
5ed8181adc | ||
|
|
5617d702cc | ||
|
|
52d8ed6721 | ||
|
|
de358cd721 | ||
|
|
9acc68f09f | ||
|
|
a8c21c27df | ||
|
|
55264459ce | ||
|
|
0f3a92ac2b | ||
|
|
a79077b30e | ||
|
|
ac811d4708 | ||
|
|
b15e5e893e | ||
|
|
63baec3ac8 | ||
|
|
0b7fa14030 | ||
|
|
5d2f1804e7 | ||
|
|
c7abc8c718 | ||
|
|
337d7d7a7a | ||
|
|
5858cb113e | ||
|
|
fe6ae937ef | ||
|
|
c344a26e80 | ||
|
|
6a58fc20da |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# Generated files that tests compare byte for byte with what they generate: LF on every OS, also
|
||||
# where Git for Windows' core.autocrlf would check them out with CRLF.
|
||||
docs/launcher-commands.md text eol=lf
|
||||
docs/horizon-ctl-commands.md text eol=lf
|
||||
website/src/tokens.css text eol=lf
|
||||
@@ -61,7 +61,8 @@ jobs:
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable fmt --all -- --check
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# With the game's code mods (on in every release build, off by default).
|
||||
cargo +stable clippy --workspace --all-targets --features horizon-client/mods -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
|
||||
@@ -69,8 +70,8 @@ jobs:
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests
|
||||
cargo +stable test --workspace --doc
|
||||
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
|
||||
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
|
||||
parity:
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: ./.github/workflows/parity.yml
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: release
|
||||
# Automatic game releases (docs/release-flow.md). After every green `checks` run on main, every 10 minutes (so a
|
||||
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides:
|
||||
# a release when pull requests merged since the last one changed what players run and the newest merged 5 minutes
|
||||
# ago (the oldest 20 minutes ago, if merges never pause), or at once for a pull request labelled release-now.
|
||||
# a release when 5 pull requests merged since the last one changed what players run and the newest merged 5 minutes
|
||||
# ago, or when the oldest has waited 6 hours, or at once for a pull request labelled release-now.
|
||||
# RELEASES_PAUSED=1 (repository variable) stops them; so does an open issue labelled release-blocker, forced runs too.
|
||||
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
|
||||
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then
|
||||
@@ -133,7 +133,8 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
|
||||
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
@@ -255,8 +256,9 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
|
||||
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them).
|
||||
cargo build --release --locked -p horizon-client --no-default-features --bin horizon-client
|
||||
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
|
||||
# and with code mods like the client.
|
||||
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
|
||||
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
|
||||
folder="dist/${{ matrix.server_dir }}"
|
||||
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
|
||||
|
||||
@@ -12,17 +12,20 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
|
||||
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Never write to
|
||||
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
|
||||
- **Mark what an agent posts.** A PR body starts with
|
||||
`> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.` (issues: "Filed by", reviews:
|
||||
"Review by"); every comment starts with `🤖 Agent:`. Never write as Ludovic.
|
||||
- **Attribution.** Commits end with `Co-Authored-By: Claude <model> <[email protected]>`; PR bodies end
|
||||
with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
||||
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
|
||||
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
|
||||
contributor's agent names its own person and tool (issues: "Filed by", reviews: "Review by"). Every
|
||||
comment starts with `🤖 Agent:`. Never write as Ludovic or as anyone else.
|
||||
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
|
||||
with that agent's own "Generated with" line. Ludovic's agents use
|
||||
`Co-Authored-By: Claude <model> <[email protected]>` and
|
||||
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
||||
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
|
||||
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
|
||||
they would change the vanilla look.
|
||||
- Small PRs (< ~500 lines). Labels: `feature` (players get something new), `skip-release` (they see no
|
||||
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Every merge to main ships
|
||||
([docs/release-flow.md](docs/release-flow.md)).
|
||||
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
|
||||
6 hours ([docs/release-flow.md](docs/release-flow.md)).
|
||||
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
|
||||
|
||||
## Crates
|
||||
@@ -40,7 +43,6 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
|
||||
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-shaderpack` | OptiFine/Iris packs: preprocessor, GLSL → WGSL ([docs/shader-packs.md](docs/shader-packs.md)) |
|
||||
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
@@ -123,7 +125,6 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Shader packs | L: `shader_pack.rs` (Horizon WGSL packs), `java_pack.rs` + crate `horizon-shaderpack` (Iris/OptiFine), `gfx/pack_stages.rs`, `gfx/java_stages.rs`; G: `shader_packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
@@ -148,19 +149,19 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
|
||||
## The two single-player paths
|
||||
|
||||
1. **In-frame (default since #1428):** the window's `Game` ticks the world itself, one level that follows
|
||||
the player (`fn enter_dimension`). This is what Stable players run and what harnesses test.
|
||||
2. **Server thread (`--server-thread` or `HORIZON_SERVER_THREAD=1`):** `game/integrated.rs` runs a second
|
||||
`Game` (`serving`) on a "Server thread" with one level per dimension; the window joins it as a client
|
||||
through memory (`remote`), exactly as a LAN guest or a dedicated server's player does. Not at parity yet:
|
||||
open `[server-thread]` issues list the gaps.
|
||||
1. **Server thread (default since #1514):** `game/integrated.rs` runs a second `Game` (`serving`) on a
|
||||
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
|
||||
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
|
||||
`[server-thread]` issues list the gaps left.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
|
||||
the world itself, one level that follows the player (`fn enter_dimension`). Most harnesses test this path.
|
||||
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
|
||||
when you touch player-facing gameplay:
|
||||
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # in-frame
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --server-thread # server thread
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
|
||||
python3 tools/lan_e2e.py --check <name> # host in-frame, guest is a server client
|
||||
python3 tools/lan_e2e.py --server-thread --check <name> # host on its server thread (portals, levels, distance, lava need it)
|
||||
python3 tools/parity.py dedicated_server server_entities server_player_damage
|
||||
|
||||
Generated
+7
-399
@@ -178,15 +178,6 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
|
||||
|
||||
[[package]]
|
||||
name = "ascii-canvas"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891"
|
||||
dependencies = [
|
||||
"term",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ash"
|
||||
version = "0.38.0+1.3.281"
|
||||
@@ -214,30 +205,15 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
|
||||
dependencies = [
|
||||
"bit-vec 0.9.1",
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
@@ -283,16 +259,6 @@ dependencies = [
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -484,15 +450,6 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coords-hud"
|
||||
version = "0.1.0"
|
||||
@@ -797,41 +754,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
@@ -849,29 +771,6 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn 2.0.119",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -958,15 +857,6 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||
|
||||
[[package]]
|
||||
name = "ena"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.42"
|
||||
@@ -1230,58 +1120,6 @@ version = "0.30.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
|
||||
|
||||
[[package]]
|
||||
name = "glsl-lang"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "983a18d0c2ecad18ac9ff3c94b069d6bd03b47405a200836d9d8d63def8e34de"
|
||||
dependencies = [
|
||||
"glsl-lang-lexer",
|
||||
"glsl-lang-types",
|
||||
"lalrpop",
|
||||
"lalrpop-util",
|
||||
"lang-util",
|
||||
"once_cell",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glsl-lang-lexer"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abdbc0bdfbc81d557f4865e695457eb23d68031bfb061fda3444f56511b26eee"
|
||||
dependencies = [
|
||||
"glsl-lang-pp",
|
||||
"glsl-lang-types",
|
||||
"lalrpop-util",
|
||||
"lang-util",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glsl-lang-pp"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88677876fb27f116015d0ecebda6d77bdf65f3a6fa3f5dfeed10b8c8f04cb2c5"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"derive_more",
|
||||
"lang-util",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glsl-lang-types"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd8b1532b424d40df634cf2dc2be56d59f994b9ac07fa7c1e3183fd33db9cb07"
|
||||
dependencies = [
|
||||
"lang-util",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpu-allocator"
|
||||
version = "0.28.0"
|
||||
@@ -1428,7 +1266,6 @@ dependencies = [
|
||||
"horizon-mods",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shaderpack",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
@@ -1443,7 +1280,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"symphonia",
|
||||
"toml",
|
||||
"ureq",
|
||||
"wgpu",
|
||||
"winit",
|
||||
@@ -1493,7 +1329,6 @@ dependencies = [
|
||||
name = "horizon-mods"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-mods",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1595,16 +1430,6 @@ dependencies = [
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-shaderpack"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"glsl-lang",
|
||||
"naga",
|
||||
"pp-rs",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-telemetry"
|
||||
version = "0.1.0"
|
||||
@@ -1756,12 +1581,6 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -1944,73 +1763,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lalrpop"
|
||||
version = "0.22.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501"
|
||||
dependencies = [
|
||||
"ascii-canvas",
|
||||
"bit-set 0.8.0",
|
||||
"ena",
|
||||
"itertools",
|
||||
"lalrpop-util",
|
||||
"petgraph 0.7.1",
|
||||
"pico-args",
|
||||
"regex",
|
||||
"regex-syntax",
|
||||
"sha3",
|
||||
"string_cache",
|
||||
"term",
|
||||
"unicode-xid",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lalrpop-util"
|
||||
version = "0.22.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lang-util"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1715a1e56998f119cd6d49ac8a69802dcbe4fca96d8291243d6dae87abeb913"
|
||||
dependencies = [
|
||||
"derive_more",
|
||||
"lalrpop-util",
|
||||
"lang-util-derive",
|
||||
"line-span",
|
||||
"smol_str 0.3.6",
|
||||
"text-size",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lang-util-derive"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f2acc66aedf0e445534e0a6b3f0f6ee20ef6ebb9647dbe10735e52ea8d6a096"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
@@ -2067,12 +1819,6 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "line-span"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29fc123b2f6600099ca18248f69e3ee02b09c4188c0d98e9a690d90dec3e408a"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
@@ -2184,7 +1930,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set 0.10.0",
|
||||
"bit-set",
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
@@ -2197,7 +1943,6 @@ dependencies = [
|
||||
"naga-types",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"pp-rs",
|
||||
"rustc-hash 1.1.0",
|
||||
"spirv",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2246,12 +1991,6 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
@@ -2751,16 +2490,6 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.8.3"
|
||||
@@ -2772,31 +2501,6 @@ dependencies = [
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pico-args"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
@@ -2915,21 +2619,6 @@ 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 = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "presser"
|
||||
version = "0.3.1"
|
||||
@@ -3023,15 +2712,6 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
@@ -3370,16 +3050,6 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha3"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -3408,12 +3078,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -3463,16 +3127,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smol_str"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523"
|
||||
dependencies = [
|
||||
"borsh",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
@@ -3522,37 +3176,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared",
|
||||
"precomputed-hash",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -3669,15 +3292,6 @@ version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "term"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
@@ -3687,12 +3301,6 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "text-size"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233"
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -3853,7 +3461,7 @@ checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom",
|
||||
"petgraph 0.8.3",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4574,8 +4182,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set 0.10.0",
|
||||
"bit-vec 0.9.1",
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
@@ -4625,7 +4233,7 @@ dependencies = [
|
||||
"android_system_properties",
|
||||
"arrayvec",
|
||||
"ash",
|
||||
"bit-set 0.10.0",
|
||||
"bit-set",
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
@@ -5123,7 +4731,7 @@ dependencies = [
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit",
|
||||
"smol_str 0.2.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
"unicode-segmentation",
|
||||
"wasm-bindgen",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-shaderpack", "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"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -45,7 +45,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
|
||||
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs, shader packs and data packs:** resource packs work, Java shader packs run their full-screen effects (experimental), and data packs load functions, predicates and item modifiers.
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
@@ -57,7 +57,7 @@ Screenshots, captured in the Rust client itself with vanilla assets and no mods
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/survival.webp" alt="Survival at dusk: a zombie walks up, with the hotbar, health, hunger and experience"></td>
|
||||
<td width="50%"><img src="docs/media/settings.webp" alt="Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics, Advanced Graphics and Shader Packs"></td>
|
||||
<td width="50%"><img src="docs/media/settings.webp" alt="Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics and Advanced Graphics"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/village.webp" alt="A plains village among savanna hills, with farms, villagers and an iron golem"></td>
|
||||
|
||||
@@ -18,7 +18,8 @@ horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
horizon-mods = { path = "../horizon-mods" }
|
||||
# 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"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
@@ -28,13 +29,9 @@ png = "0.18"
|
||||
sha2 = "0.10.9"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
# Shader packs: pack.toml, and WGSL checked before it reaches wgpu.
|
||||
toml = "1.1"
|
||||
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
|
||||
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
# Java (OptiFine/Iris) shader packs, translated to WGSL.
|
||||
horizon-shaderpack = { path = "../horizon-shaderpack" }
|
||||
# Sound (ALSA on Linux); the `audio` feature.
|
||||
cpal = { version = "0.16", optional = true }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
|
||||
@@ -78,8 +75,20 @@ default = ["audio", "controllers"]
|
||||
audio = ["dep:cpal"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense"]
|
||||
# Runs code mods (wasmtime and cranelift: a quarter of a clean build). On in every release build
|
||||
# (release.yml); the tests and probes don't need it. Content mods load without it.
|
||||
mods = ["horizon-mods/runtime"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
|
||||
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
|
||||
[[example]]
|
||||
name = "client_probe"
|
||||
path = "examples/probe/main.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
|
||||
@@ -41,7 +41,7 @@ pub fn main(names: &[&str], run: fn(&str) -> Option<ExitCode>) -> ExitCode {
|
||||
let name = std::env::args().nth(1).unwrap_or_default();
|
||||
run(&name).unwrap_or_else(|| {
|
||||
eprintln!(
|
||||
"usage: probe <name> [arguments...]\nprobes: {}",
|
||||
"usage: client_probe <name> [arguments...]\nprobes: {}",
|
||||
names.join(" ")
|
||||
);
|
||||
ExitCode::from(2)
|
||||
|
||||
@@ -306,6 +306,7 @@ fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
height_focused: option("chatHeightFocused"),
|
||||
height_unfocused: option("chatHeightUnfocused"),
|
||||
line_spacing: option("chatLineSpacing"),
|
||||
colors: c["chatColors"].as_bool().unwrap_or(true),
|
||||
};
|
||||
for m in c["messages"].as_array().into_iter().flatten() {
|
||||
// GuiMessageTag indicator colours: systemSinglePlayer and chatError.
|
||||
|
||||
@@ -58,11 +58,12 @@ fn state(menu: &Menu) -> String {
|
||||
.map(|(slot, stack)| format!("{slot}:{}", canon_stack(stack.as_ref())))
|
||||
.collect();
|
||||
let line = format!(
|
||||
"{} | {} |{data} | {} | {}",
|
||||
"{} | {} |{data} | {} | {} | {}",
|
||||
slots.join(" "),
|
||||
canon_stack(menu.carried.as_ref()),
|
||||
menu.drops.join(";"),
|
||||
equipped.join(";")
|
||||
equipped.join(";"),
|
||||
menu.player_sounds.join(";")
|
||||
);
|
||||
if menu.kind() != Kind::Lectern {
|
||||
return line;
|
||||
@@ -171,6 +172,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
});
|
||||
menu.drops.clear();
|
||||
menu.equipped.clear();
|
||||
menu.player_sounds.clear();
|
||||
out += &format!("init {}\n", state(&menu));
|
||||
for op in case["ops"].as_array().ok_or("ops")? {
|
||||
let slot = op[0].as_i64().unwrap_or(0) as i32;
|
||||
|
||||
@@ -293,6 +293,47 @@ fn spawn_seed(index: usize) -> i64 {
|
||||
1000 + 7919 * index as i64
|
||||
}
|
||||
|
||||
/// `Villager.spawnGolemIfNeeded` for villager `a`, the game's way: the golem
|
||||
/// draws from the level's random and joins the scenario's mobs next tick,
|
||||
/// seeded as the oracle seeds what a tick spawned.
|
||||
fn summon_golem(
|
||||
mobs: &mut [Mob],
|
||||
a: usize,
|
||||
needed: usize,
|
||||
w: &MobWorld,
|
||||
types: &MobTypes,
|
||||
born: &mut Vec<Mob>,
|
||||
level_random: &mut LegacyRandom,
|
||||
) {
|
||||
use horizon_client::mobs::{villager, villager_brain};
|
||||
let Some(near) = villager::golem_summoners(mobs, a, w.game_time, needed) else {
|
||||
return;
|
||||
};
|
||||
let kind = Arc::new(
|
||||
types
|
||||
.types
|
||||
.get("minecraft:iron_golem")
|
||||
.expect("iron golem")
|
||||
.clone(),
|
||||
);
|
||||
let index = mobs.len() + born.len();
|
||||
let p = mobs[a].position;
|
||||
let start = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
|
||||
let golem = villager::summon_golem(w, start, level_random, |random, at, yaw| {
|
||||
let mut m = Mob::new(100_000 + index as u32, kind.clone(), at, yaw);
|
||||
villager::finalize_summoned_golem(&mut m, random);
|
||||
m
|
||||
});
|
||||
let Some(mut golem) = golem else {
|
||||
return;
|
||||
};
|
||||
golem.random = LegacyRandom::new(spawn_seed(index));
|
||||
born.push(golem);
|
||||
for j in near {
|
||||
villager_brain::golem_detected(&mut mobs[j], w.game_time);
|
||||
}
|
||||
}
|
||||
|
||||
/// Villager events that act at once in vanilla (`TradeWithVillager` and
|
||||
/// `VillagerMakeLove` write the other villager directly).
|
||||
fn villager_events(
|
||||
@@ -304,6 +345,7 @@ fn villager_events(
|
||||
items: &Items,
|
||||
born: &mut Vec<Mob>,
|
||||
day_time: i64,
|
||||
level_random: &mut LegacyRandom,
|
||||
) {
|
||||
use horizon_client::mobs::villager;
|
||||
let index = |mobs: &[Mob], id: u32| {
|
||||
@@ -314,6 +356,7 @@ fn villager_events(
|
||||
while i < events.len() {
|
||||
match events[i] {
|
||||
MobEvent::VillagerMeet { mob, other } => {
|
||||
events.remove(i);
|
||||
if let (Some(a), Some(b)) = (index(mobs, mob), index(mobs, other)) {
|
||||
let meeting = villager::meet(mobs, a, b, w.game_time, |item| {
|
||||
items.get(item).map_or(64, |i| i.max_stack)
|
||||
@@ -322,8 +365,16 @@ fn villager_events(
|
||||
meeting.thrown.is_empty(),
|
||||
"villagers toss items: not modelled here"
|
||||
);
|
||||
if meeting.gossiped {
|
||||
summon_golem(mobs, a, 5, w, types, born, level_random);
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::GolemCheck { mob, needed } => {
|
||||
events.remove(i);
|
||||
if let Some(a) = index(mobs, mob) {
|
||||
summon_golem(mobs, a, needed, w, types, born, level_random);
|
||||
}
|
||||
}
|
||||
MobEvent::VillagerBirth {
|
||||
parent,
|
||||
@@ -1167,6 +1218,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
&items,
|
||||
&mut born,
|
||||
s.time,
|
||||
&mut scratch,
|
||||
);
|
||||
horizon_client::mobs::warden::hear_mob_events(&mut mobs, &events, from, &w);
|
||||
// Melee hits on other mobs land during the attacker's tick
|
||||
@@ -1323,6 +1375,31 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
world.set(at[0], at[1], at[2], registry.resolve(&st)?)?;
|
||||
}
|
||||
}
|
||||
// Doors villagers opened or closed (DoorBlock.setOpen), both halves.
|
||||
let doors: Vec<([i32; 3], bool)> = events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
MobEvent::SetDoorOpen { pos, open } => Some((*pos, *open)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (at, open) in doors {
|
||||
let Some(half) = registry
|
||||
.state(world.block(at[0], at[1], at[2]))
|
||||
.and_then(|s| s.properties.get("half").cloned())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let other = if half == "lower" { 1 } else { -1 };
|
||||
for y in [at[1], at[1] + other] {
|
||||
if let Some(mut st) = registry.state(world.block(at[0], y, at[2])).cloned()
|
||||
&& st.properties.contains_key("open")
|
||||
{
|
||||
st.properties.insert("open".to_owned(), open.to_string());
|
||||
world.set(at[0], y, at[2], registry.resolve(&st)?)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
mobs.append(&mut born);
|
||||
mobs.append(&mut added);
|
||||
for e in &heart_events {
|
||||
|
||||
@@ -147,7 +147,6 @@ const PAGES: Ty = Ty::Enum(&[
|
||||
"graphics-advanced",
|
||||
"extras",
|
||||
"horizon",
|
||||
"shader-packs",
|
||||
"agent",
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! `BundleContents.Mutable`: what a bundle takes in and gives back, by
|
||||
//! weight. The menus (`menu`, `lan::menu`) run `BundleItem`'s click
|
||||
//! overrides on it.
|
||||
use crate::items::{ItemStack, Items};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// A bundle weight: vanilla's `Fraction`, exact and kept reduced.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Weight {
|
||||
n: i128,
|
||||
d: i128,
|
||||
}
|
||||
|
||||
fn gcd(a: i128, b: i128) -> i128 {
|
||||
let (mut a, mut b) = (a.abs(), b.abs());
|
||||
while b != 0 {
|
||||
(a, b) = (b, a % b);
|
||||
}
|
||||
a.max(1)
|
||||
}
|
||||
|
||||
impl Weight {
|
||||
const ZERO: Self = Self { n: 0, d: 1 };
|
||||
const ONE: Self = Self { n: 1, d: 1 };
|
||||
|
||||
fn new(n: i128, d: i128) -> Self {
|
||||
let g = gcd(n, d);
|
||||
Self { n: n / g, d: d / g }
|
||||
}
|
||||
|
||||
fn add(self, o: Self) -> Self {
|
||||
Self::new(self.n * o.d + o.n * self.d, self.d * o.d)
|
||||
}
|
||||
|
||||
fn sub(self, o: Self) -> Self {
|
||||
self.add(Self { n: -o.n, d: o.d })
|
||||
}
|
||||
|
||||
fn times(self, count: u32) -> Self {
|
||||
Self::new(self.n * i128::from(count), self.d)
|
||||
}
|
||||
|
||||
/// `this.divideBy(item).intValue()` (truncated), at least 0.
|
||||
fn how_many(self, item: Self) -> u32 {
|
||||
let q = (self.n * item.d) / (self.d * item.n);
|
||||
u32::try_from(q.max(0)).unwrap_or(u32::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
/// The sounds of `BundleItem` (`Entity.playSound` at the player): insert
|
||||
/// and remove one at volume 0.8 and pitch `0.8 + random * 0.4`, a failed
|
||||
/// insert at 1, 1.
|
||||
pub const INSERT: &str = "minecraft:item.bundle.insert";
|
||||
pub const INSERT_FAIL: &str = "minecraft:item.bundle.insert_fail";
|
||||
pub const REMOVE_ONE: &str = "minecraft:item.bundle.remove_one";
|
||||
|
||||
fn max_stack(items: &Items, stack: &ItemStack) -> u32 {
|
||||
items.get(&stack.item).map_or(64, |i| i.max_stack).max(1)
|
||||
}
|
||||
|
||||
/// The stack's `bundle_contents`, when it has the component.
|
||||
fn contents_of<'a>(items: &Items, stack: &'a ItemStack) -> Option<&'a [ItemStack]> {
|
||||
let inside = stack.data().map_or(&[][..], |d| &d.bundle_contents[..]);
|
||||
let bundle = items.get(&stack.item).is_some_and(|i| i.bundle);
|
||||
(bundle || !inside.is_empty()).then_some(inside)
|
||||
}
|
||||
|
||||
/// `BundleContents.getWeight`: a nested bundle weighs its contents plus
|
||||
/// 1/16, a beehive with bees 1, the rest 1 / max stack size.
|
||||
fn item_weight(items: &Items, stack: &ItemStack) -> Weight {
|
||||
if let Some(inside) = contents_of(items, stack) {
|
||||
return contents_weight(items, inside).add(Weight::new(1, 16));
|
||||
}
|
||||
let bees = stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:bees"))
|
||||
.is_some_and(|b| matches!(b, Tag::List(l) if !l.is_empty()));
|
||||
if bees {
|
||||
Weight::ONE
|
||||
} else {
|
||||
Weight::new(1, i128::from(max_stack(items, stack)))
|
||||
}
|
||||
}
|
||||
|
||||
fn contents_weight(items: &Items, inside: &[ItemStack]) -> Weight {
|
||||
inside.iter().fold(Weight::ZERO, |w, s| {
|
||||
w.add(item_weight(items, s).times(s.count))
|
||||
})
|
||||
}
|
||||
|
||||
/// `BundleContents.canItemBeInBundle`: shulker boxes don't fit.
|
||||
pub fn can_be_in_bundle(stack: &ItemStack) -> bool {
|
||||
stack.count > 0 && !stack.item.ends_with("shulker_box")
|
||||
}
|
||||
|
||||
/// `BundleContents.Mutable`.
|
||||
pub struct Contents {
|
||||
items: Vec<ItemStack>,
|
||||
weight: Weight,
|
||||
}
|
||||
|
||||
impl Contents {
|
||||
/// The bundle's contents; None when the stack is no bundle.
|
||||
pub fn of(items: &Items, stack: &ItemStack) -> Option<Self> {
|
||||
let inside = contents_of(items, stack)?.to_vec();
|
||||
let weight = contents_weight(items, &inside);
|
||||
Some(Self {
|
||||
items: inside,
|
||||
weight,
|
||||
})
|
||||
}
|
||||
|
||||
/// `getMaxAmountToAdd` for `stack`'s kind of item.
|
||||
pub fn room_for(&self, items: &Items, stack: &ItemStack) -> u32 {
|
||||
Weight::ONE
|
||||
.sub(self.weight)
|
||||
.how_many(item_weight(items, stack))
|
||||
}
|
||||
|
||||
/// `tryInsert`: what fits moves to the front, merged with its kind.
|
||||
pub fn try_insert(&mut self, items: &Items, stack: &mut ItemStack) -> u32 {
|
||||
if !can_be_in_bundle(stack) {
|
||||
return 0;
|
||||
}
|
||||
let weight = item_weight(items, stack);
|
||||
let amount = stack
|
||||
.count
|
||||
.min(Weight::ONE.sub(self.weight).how_many(weight));
|
||||
if amount == 0 {
|
||||
return 0;
|
||||
}
|
||||
self.weight = self.weight.add(weight.times(amount));
|
||||
// ItemStack.isStackable: a damaged stack is damageable.
|
||||
let stackable = max_stack(items, stack) > 1 && stack.damage == 0;
|
||||
let same = self
|
||||
.items
|
||||
.iter()
|
||||
.position(|s| stackable && s.same_item_same_components(stack));
|
||||
let added = match same {
|
||||
Some(i) => {
|
||||
let mut merged = self.items.remove(i);
|
||||
merged.count += amount;
|
||||
stack.count -= amount;
|
||||
merged
|
||||
}
|
||||
None => {
|
||||
let mut part = stack.clone();
|
||||
part.count = amount;
|
||||
stack.count -= amount;
|
||||
part
|
||||
}
|
||||
};
|
||||
self.items.insert(0, added);
|
||||
amount
|
||||
}
|
||||
|
||||
/// `removeOne`: the first stack (Horizon keeps no selected item).
|
||||
pub fn remove_one(&mut self, items: &Items) -> Option<ItemStack> {
|
||||
if self.items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let stack = self.items.remove(0);
|
||||
self.weight = self
|
||||
.weight
|
||||
.sub(item_weight(items, &stack).times(stack.count));
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
/// `toImmutable`, written back into the bundle's component.
|
||||
pub fn store(self, bundle: &mut ItemStack) {
|
||||
bundle.data_mut().bundle_contents = self.items;
|
||||
bundle.tidy();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Weight;
|
||||
|
||||
#[test]
|
||||
fn weights_add_up_exactly() {
|
||||
let w = Weight::new(1, 64)
|
||||
.times(32)
|
||||
.add(Weight::new(1, 16).times(4));
|
||||
assert_eq!(w, Weight::new(3, 4));
|
||||
assert_eq!(Weight::ONE.sub(w).how_many(Weight::new(1, 64)), 16);
|
||||
assert_eq!(Weight::ONE.sub(Weight::ONE).how_many(Weight::ONE), 0);
|
||||
// An over-full bundle (made by a command) has no room.
|
||||
let over = Weight::ONE.sub(Weight::new(5, 4));
|
||||
assert_eq!(over.how_many(Weight::new(1, 64)), 0);
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,14 @@ pub struct ChatOptions {
|
||||
pub height_focused: f64,
|
||||
pub height_unfocused: f64,
|
||||
pub line_spacing: f64,
|
||||
/// Chat Colors (`chatColors`): legacy `§` codes format the text, or are
|
||||
/// left out.
|
||||
pub colors: bool,
|
||||
}
|
||||
impl Default for ChatOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
colors: true,
|
||||
opacity: 1.0,
|
||||
background_opacity: 0.5,
|
||||
scale: 1.0,
|
||||
@@ -136,7 +140,8 @@ impl Chat {
|
||||
}
|
||||
fn add_lines(&mut self, atlas: &HudAtlas, message: &Message, focused: bool) {
|
||||
let width = self.max_width();
|
||||
for line in atlas.font.wrap_components(&message.content, width) {
|
||||
let content = self.displayed(&message.content);
|
||||
for line in atlas.font.wrap_components(&content, width) {
|
||||
if focused && self.scroll > 0 {
|
||||
self.new_since_scroll = true;
|
||||
self.scroll_by(1, focused);
|
||||
@@ -152,6 +157,16 @@ impl Chat {
|
||||
}
|
||||
self.lines.truncate(100);
|
||||
}
|
||||
/// What a message's lines wrap (ComponentRenderUtils.wrapComponents):
|
||||
/// without Chat Colors its `§` codes are stripped (stripColor); with
|
||||
/// them the font draws them as formatting.
|
||||
fn displayed(&self, content: &Text) -> Text {
|
||||
if self.options.colors {
|
||||
content.resolve_legacy()
|
||||
} else {
|
||||
content.strip_legacy()
|
||||
}
|
||||
}
|
||||
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat).
|
||||
pub fn rescale(&mut self, atlas: &HudAtlas) {
|
||||
self.scroll = 0;
|
||||
@@ -308,6 +323,21 @@ impl Chat {
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> Option<ClickEvent> {
|
||||
self.style_at(font, height, x, y)?.click
|
||||
}
|
||||
/// The hover text under a GUI-space cursor while chat is focused
|
||||
/// (ChatScreen.render's `renderComponentHoverEffect`).
|
||||
pub fn hover_at(&self, font: &crate::font::Font, height: i32, x: f64, y: f64) -> Option<Text> {
|
||||
self.style_at(font, height, x, y)?.hover.map(|h| *h)
|
||||
}
|
||||
/// ChatComponent.getClickedComponentStyleAt.
|
||||
fn style_at(
|
||||
&self,
|
||||
font: &crate::font::Font,
|
||||
height: i32,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> Option<crate::text::Style> {
|
||||
let scale = self.options.scale as f32;
|
||||
let mut x = x / f64::from(scale) - 4.0;
|
||||
let bottom = hud_layout::floor((height - 40) as f32 / scale);
|
||||
@@ -324,7 +354,7 @@ impl Chat {
|
||||
for (c, style) in content.chars() {
|
||||
x -= f64::from(font.advance(c, style));
|
||||
if x < 0.0 {
|
||||
return style.click.clone();
|
||||
return Some(style.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -337,6 +367,14 @@ impl Chat {
|
||||
.map(|m| m.content.plain())
|
||||
.collect()
|
||||
}
|
||||
/// The newest messages as components, styles included (control socket).
|
||||
pub fn recent_components(&self, n: usize) -> Vec<serde_json::Value> {
|
||||
self.messages
|
||||
.iter()
|
||||
.take(n)
|
||||
.map(|m| m.content.component())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// `CommandSuggestions.SuggestionsList`.
|
||||
|
||||
@@ -285,3 +285,45 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
|
||||
screen.type_text(&m, &c, "/bossbar get pt");
|
||||
assert_eq!(screen.take_request(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_codes_colour_the_line_or_vanish_without_chat_colours() {
|
||||
let mut chat = Chat::default();
|
||||
let red = Text::literal("\u{a7}cRED");
|
||||
let shown = chat.displayed(&red);
|
||||
assert_eq!(shown.plain(), "RED");
|
||||
assert!(shown.chars().all(|(_, s)| s.color == Some(0xFF5555)));
|
||||
chat.options.colors = false;
|
||||
let shown = chat.displayed(&red);
|
||||
assert_eq!(shown.plain(), "RED");
|
||||
assert!(shown.chars().all(|(_, s)| s.color.is_none()));
|
||||
// A lone section sign or an unknown code stays as written.
|
||||
assert_eq!(
|
||||
chat.displayed(&Text::literal("a\u{a7}zb")).plain(),
|
||||
"a\u{a7}zb"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_component_keeps_its_click_and_hover_events() {
|
||||
let font = font();
|
||||
let json = serde_json::json!({
|
||||
"text": "CLICK", "color": "green", "bold": true,
|
||||
"click_event": {"action": "suggest_command", "command": "/say clicked"},
|
||||
"hover_event": {"action": "show_text", "value": {"text": "Tip", "color": "aqua"}},
|
||||
});
|
||||
let line = crate::component::to_text(&json, &mut |s| Text::literal(s));
|
||||
let mut chat = Chat::default();
|
||||
add(&mut chat, &font, line);
|
||||
let x = 4.0 + f64::from(font.width("CL"));
|
||||
let Some(ClickEvent::SuggestCommand(command)) = chat.click_at(&font, 360, x, 315.0) else {
|
||||
panic!("the line must suggest its command");
|
||||
};
|
||||
assert_eq!(command, "/say clicked");
|
||||
let hover = chat.hover_at(&font, 360, x, 315.0).expect("a hover text");
|
||||
assert_eq!(hover.plain(), "Tip");
|
||||
assert_eq!(hover.spans[0].style.color, Some(text::AQUA));
|
||||
// Past the (bold, so wider) text, nothing.
|
||||
let past = 4.0 + f64::from(font.width("CLICK")) + 20.0;
|
||||
assert!(chat.hover_at(&font, 360, past, 315.0).is_none());
|
||||
}
|
||||
@@ -300,6 +300,10 @@ pub struct FixedStep {
|
||||
/// How many times `/tick step` started or stopped a step
|
||||
/// (`ServerTickRateManager.updateStepTicks` broadcasts each one).
|
||||
step_changes: u32,
|
||||
/// In-frame ticks dropped since the last overload warning (`drop_ticks`).
|
||||
dropped: u32,
|
||||
/// When the last overload warning was due, on the frame clock.
|
||||
last_overload_warning: std::time::Duration,
|
||||
}
|
||||
|
||||
impl Default for FixedStep {
|
||||
@@ -316,6 +320,8 @@ impl Default for FixedStep {
|
||||
tick_count: 0,
|
||||
client: false,
|
||||
step_changes: 0,
|
||||
dropped: 0,
|
||||
last_overload_warning: std::time::Duration::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -430,6 +436,41 @@ impl FixedStep {
|
||||
pub fn sprinting(&self) -> bool {
|
||||
self.manager.is_sprinting()
|
||||
}
|
||||
/// Whether a frame that ran its world in the frame, and whose ticks so
|
||||
/// far took `spent`, may run another one. Vanilla's world ticks on the
|
||||
/// integrated server's thread, so a slow tick costs its frames nothing:
|
||||
/// the server falls behind and says so (`MinecraftServer.runServer`'s
|
||||
/// "Can't keep up!"). In the frame, a world whose ticks take longer than
|
||||
/// their length falls behind every frame, so every frame ran the client
|
||||
/// timer's ten: at 57 ms a tick, 1.7 FPS. So a frame stops once its
|
||||
/// ticks have taken a tick's length: ticks of a few milliseconds keep the
|
||||
/// full ten, and a world that cannot keep up runs one tick a frame,
|
||||
/// slower but drawn. A sprint (`/tick sprint`) runs its own frame's worth.
|
||||
pub fn tick_fits(&self, spent: std::time::Duration) -> bool {
|
||||
self.sprinting() || spent < self.tick_duration()
|
||||
}
|
||||
/// The frame skips its remaining `ticks` (`tick_fits`): the world runs
|
||||
/// slower than its tick rate. Returns vanilla's overload warning when one
|
||||
/// is due: a second's worth of ticks behind, at most every ten seconds
|
||||
/// (`OVERLOADED_THRESHOLD_NANOS`, `OVERLOADED_WARNING_INTERVAL_NANOS`).
|
||||
pub fn drop_ticks(&mut self, ticks: u32) -> Option<String> {
|
||||
self.dropped = self.dropped.saturating_add(ticks);
|
||||
let tick = self.tick_duration();
|
||||
let behind = tick * self.dropped;
|
||||
let threshold = std::time::Duration::from_secs(1) + tick * 20;
|
||||
let interval = std::time::Duration::from_secs(10) + tick * 100;
|
||||
if behind <= threshold || self.now < self.last_overload_warning + interval {
|
||||
return None;
|
||||
}
|
||||
let warning = format!(
|
||||
"Can't keep up! Is the server overloaded? Running {}ms or {} ticks behind",
|
||||
behind.as_millis(),
|
||||
self.dropped
|
||||
);
|
||||
self.dropped = 0;
|
||||
self.last_overload_warning = self.now;
|
||||
Some(warning)
|
||||
}
|
||||
/// `requestGameToSprint`: true when a sprint was already running.
|
||||
pub fn start_sprint(&mut self, ticks: u32) -> bool {
|
||||
self.manager
|
||||
@@ -490,6 +531,37 @@ impl FixedStep {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_frame_stops_ticking_after_a_ticks_length() {
|
||||
let clock = FixedStep::default();
|
||||
let ms = std::time::Duration::from_millis;
|
||||
// Ticks of 4 ms: the ten the timer allows fit in 50 ms.
|
||||
assert!(clock.tick_fits(ms(4 * 9)));
|
||||
// Ticks of 40 ms: the second one runs, not a third.
|
||||
assert!(clock.tick_fits(ms(40)));
|
||||
assert!(!clock.tick_fits(ms(80)));
|
||||
// One tick longer than its length still runs, once a frame.
|
||||
assert!(clock.tick_fits(ms(0)));
|
||||
assert!(!clock.tick_fits(ms(120)));
|
||||
}
|
||||
#[test]
|
||||
fn dropped_ticks_warn_once_a_second_behind_and_not_again_for_ten() {
|
||||
let mut clock = FixedStep::default();
|
||||
let s = std::time::Duration::from_secs;
|
||||
clock.now = s(20);
|
||||
assert_eq!(
|
||||
clock.drop_ticks(40),
|
||||
None,
|
||||
"two seconds: not past 1 s + 20 ticks"
|
||||
);
|
||||
let warning = clock.drop_ticks(1).expect("past the threshold");
|
||||
assert!(warning.contains("2050ms or 41 ticks behind"), "{warning}");
|
||||
clock.now = s(25);
|
||||
assert_eq!(clock.drop_ticks(100), None, "within ten seconds");
|
||||
clock.now = s(36);
|
||||
assert!(clock.drop_ticks(0).unwrap().contains("100 ticks behind"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_carries_fractions_and_caps_nothing_itself() {
|
||||
let mut t = Timer::new(20.0, 0);
|
||||
|
||||
@@ -168,6 +168,25 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
style.strikethrough = flag("strikethrough");
|
||||
style.obfuscated = flag("obfuscated");
|
||||
style.font = o.get("font").and_then(crate::text::font_id);
|
||||
// ClickEvent.CODEC: the actions a chat line acts on here. A component
|
||||
// from a server never opens a file.
|
||||
if let Some(e) = o.get("click_event").and_then(Value::as_object) {
|
||||
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
|
||||
style.click = match e.get("action").and_then(Value::as_str) {
|
||||
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
|
||||
Some("copy_to_clipboard") => {
|
||||
text("value").map(crate::text::ClickEvent::CopyToClipboard)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
// HoverEvent.ShowText.
|
||||
if let Some(e) = o.get("hover_event").and_then(Value::as_object)
|
||||
&& e.get("action").and_then(Value::as_str) == Some("show_text")
|
||||
&& let Some(value) = e.get("value")
|
||||
{
|
||||
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
|
||||
}
|
||||
style
|
||||
}
|
||||
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
|
||||
|
||||
@@ -382,7 +382,7 @@ impl Providers {
|
||||
);
|
||||
Self { ints, floats }
|
||||
}
|
||||
fn eval(&self, v: &Value, fast: bool, table: &HashMap<String, Value>, depth: u32) -> f64 {
|
||||
fn eval(v: &Value, fast: bool, table: &HashMap<String, Value>, depth: u32) -> f64 {
|
||||
if depth > 16 {
|
||||
return 0.0;
|
||||
}
|
||||
@@ -390,10 +390,10 @@ impl Providers {
|
||||
Value::Number(n) => n.as_f64().unwrap_or(0.0),
|
||||
Value::String(name) => table
|
||||
.get(name)
|
||||
.map_or(0.0, |v| self.eval(v, fast, table, depth + 1)),
|
||||
.map_or(0.0, |v| Self::eval(v, fast, table, depth + 1)),
|
||||
Value::Object(o) => {
|
||||
let f =
|
||||
|k: &str| self.eval(o.get(k).unwrap_or(&Value::Null), fast, table, depth + 1);
|
||||
|k: &str| Self::eval(o.get(k).unwrap_or(&Value::Null), fast, table, depth + 1);
|
||||
match o.get("type").and_then(Value::as_str).unwrap_or("") {
|
||||
"minecraft:div" => {
|
||||
let r = f("right");
|
||||
@@ -425,14 +425,14 @@ impl Providers {
|
||||
}
|
||||
/// A plain `context_int_provider` value or reference (no context conditions).
|
||||
pub fn int_value(&self, v: &Value) -> i32 {
|
||||
self.eval(v, false, &self.ints, 0) as i32
|
||||
Self::eval(v, false, &self.ints, 0) as i32
|
||||
}
|
||||
/// A plain `context_float_provider` value or reference; `default` when absent.
|
||||
pub fn float_value(&self, v: &Value, default: f32) -> f32 {
|
||||
if v.is_null() {
|
||||
default
|
||||
} else {
|
||||
self.eval(v, false, &self.floats, 0) as f32
|
||||
Self::eval(v, false, &self.floats, 0) as f32
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
@@ -446,14 +446,14 @@ impl Providers {
|
||||
items
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.fuel.as_ref())
|
||||
.map_or(0, |(time, _)| self.eval(time, fast, &self.ints, 0) as i32)
|
||||
.map_or(0, |(time, _)| Self::eval(time, fast, &self.ints, 0) as i32)
|
||||
}
|
||||
pub fn speed(&self, items: &Items, stack: &ItemStack, fast: bool) -> f32 {
|
||||
items
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.fuel.as_ref())
|
||||
.map_or(1.0, |(_, speed)| {
|
||||
self.eval(speed, fast, &self.floats, 0) as f32
|
||||
Self::eval(speed, fast, &self.floats, 0) as f32
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +425,29 @@ fn split_id(id: &str) -> (String, String) {
|
||||
None => ("minecraft".to_owned(), id.to_owned()),
|
||||
}
|
||||
}
|
||||
/// The slot under the cursor, for the search tab's keys.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Hover {
|
||||
Nothing,
|
||||
/// A display slot (`isCreativeSlot`), showing an item or past the end.
|
||||
Display {
|
||||
filled: bool,
|
||||
},
|
||||
/// Any other slot: the hotbar row, or the inventory tab's slots.
|
||||
Slot,
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.keyPressed` on the search tab: whether a
|
||||
/// key is a hotbar swap on the hovered slot instead of text for the search
|
||||
/// box. `numeric` is `getNumericKeyValue().isPresent()` (the digit and keypad
|
||||
/// keys); `hotbar_key` is a `keyHotbarSlots` key. Over an empty display slot
|
||||
/// the key types (`doQuickSwap` is false), and `checkHotbarKeyPressed` needs
|
||||
/// a hovered slot and an empty cursor.
|
||||
pub fn search_key_swaps(numeric: bool, hotbar_key: bool, hover: Hover, carrying: bool) -> bool {
|
||||
let quick_swap = hover != Hover::Display { filled: false };
|
||||
quick_swap && numeric && hotbar_key && !carrying && hover != Hover::Nothing
|
||||
}
|
||||
|
||||
/// What a click on an item tab's display slot does besides changing the
|
||||
/// carried stack.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
@@ -529,6 +552,22 @@ mod tests {
|
||||
(carried, effect)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_tab_number_keys_swap_over_a_slot_before_typing() {
|
||||
let filled = Hover::Display { filled: true };
|
||||
let empty = Hover::Display { filled: false };
|
||||
// Over an item, the hotbar row or another slot: the hotbar swap.
|
||||
assert!(search_key_swaps(true, true, filled, false));
|
||||
assert!(search_key_swaps(true, true, Hover::Slot, false));
|
||||
// Over an empty display slot, nothing or with a carried stack: text.
|
||||
assert!(!search_key_swaps(true, true, empty, false));
|
||||
assert!(!search_key_swaps(true, true, Hover::Nothing, false));
|
||||
assert!(!search_key_swaps(true, true, filled, true));
|
||||
// Letters always type; so do the digits no hotbar key is bound to.
|
||||
assert!(!search_key_swaps(false, false, filled, false));
|
||||
assert!(!search_key_swaps(true, false, filled, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_slot_clicks_follow_slot_clicked() {
|
||||
use crate::menu::Input::*;
|
||||
|
||||
@@ -137,6 +137,48 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
|
||||
m
|
||||
}
|
||||
|
||||
/// How `submitArmWithItem` holds a stack with a `minecraft:map_id`: in both
|
||||
/// hands when it is the main hand's and the off hand is empty
|
||||
/// (`renderTwoHandedMap`), else in its own hand (`renderOneHandedMap`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MapHold {
|
||||
TwoHanded,
|
||||
OneHanded,
|
||||
}
|
||||
impl MapHold {
|
||||
pub fn of(hand: Hand, off_hand_empty: bool) -> Self {
|
||||
if hand == Hand::Main && off_hand_empty {
|
||||
Self::TwoHanded
|
||||
} else {
|
||||
Self::OneHanded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `renderOneHandedMap` up to `renderMap`: the pose of a map in the hand on
|
||||
/// the `left` or right arm (the arm under it is not drawn).
|
||||
pub fn one_handed_map_pose(left: bool, inverse_height: f32, attack: f32) -> glam::Mat4 {
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
let invert = if left { -1.0 } else { 1.0 };
|
||||
let pi = std::f32::consts::PI;
|
||||
let root = attack.sqrt();
|
||||
let x_swing = (root * pi).msin();
|
||||
Mat4::from_translation(Vec3::new(invert * 0.125, -0.125, 0.0))
|
||||
* Mat4::from_translation(Vec3::new(
|
||||
invert * 0.51,
|
||||
-0.08 + inverse_height * -1.2,
|
||||
-0.75,
|
||||
))
|
||||
* Mat4::from_translation(Vec3::new(
|
||||
invert * -0.5 * x_swing,
|
||||
0.4 * (root * std::f32::consts::TAU).msin() - 0.3 * x_swing,
|
||||
-0.3 * (attack * pi).msin(),
|
||||
))
|
||||
* Mat4::from_rotation_x((x_swing * -45.0).to_radians())
|
||||
* Mat4::from_rotation_y((invert * x_swing * -30.0).to_radians())
|
||||
}
|
||||
|
||||
/// `FirstPersonHandsAndItems.evaluateWhichHandsToRender`: whether the main
|
||||
/// and the off hand show, from what each holds (item and whether it is a
|
||||
/// charged crossbow) and the item in use with its hand. A bow or crossbow
|
||||
@@ -819,6 +861,84 @@ mod tests {
|
||||
mod item_pose_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn a_map_is_two_handed_only_in_the_main_hand_with_an_empty_off_hand() {
|
||||
assert_eq!(MapHold::of(Hand::Main, true), MapHold::TwoHanded);
|
||||
assert_eq!(MapHold::of(Hand::Main, false), MapHold::OneHanded);
|
||||
assert_eq!(MapHold::of(Hand::Off, true), MapHold::OneHanded);
|
||||
assert_eq!(MapHold::of(Hand::Off, false), MapHold::OneHanded);
|
||||
}
|
||||
#[test]
|
||||
fn one_handed_maps_follow_render_one_handed_map() {
|
||||
// PoseStack.last().pose() from vanilla's renderOneHandedMap
|
||||
// transforms (column-major), for (left, inverse height, attack).
|
||||
let cases: [(bool, f32, f32, [f32; 16]); 3] = [
|
||||
(
|
||||
false,
|
||||
0.0,
|
||||
0.0,
|
||||
[
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, -0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.635, -0.205,
|
||||
-0.75, 1.0,
|
||||
],
|
||||
),
|
||||
(
|
||||
true,
|
||||
0.25,
|
||||
0.3,
|
||||
[
|
||||
0.86894435,
|
||||
-0.34686038,
|
||||
-0.35302067,
|
||||
0.0,
|
||||
0.0,
|
||||
0.713303,
|
||||
-0.70085573,
|
||||
0.0,
|
||||
0.4949098,
|
||||
0.6090046,
|
||||
0.61982065,
|
||||
0.0,
|
||||
-0.1406033,
|
||||
-0.9197687,
|
||||
-0.9926984,
|
||||
1.0,
|
||||
],
|
||||
),
|
||||
(
|
||||
false,
|
||||
0.6,
|
||||
0.85,
|
||||
[
|
||||
0.9919303,
|
||||
0.024030307,
|
||||
0.12448644,
|
||||
0.0,
|
||||
0.0,
|
||||
0.9818737,
|
||||
-0.18953651,
|
||||
0.0,
|
||||
-0.12678458,
|
||||
0.18800701,
|
||||
0.97395027,
|
||||
0.0,
|
||||
0.5136029,
|
||||
-1.1862278,
|
||||
-0.88621765,
|
||||
1.0,
|
||||
],
|
||||
),
|
||||
];
|
||||
for (left, inverse_height, attack, want) in cases {
|
||||
let got = one_handed_map_pose(left, inverse_height, attack).to_cols_array();
|
||||
for (g, w) in got.iter().zip(want) {
|
||||
assert!(
|
||||
(g - w).abs() < 1e-5,
|
||||
"{left} {inverse_height} {attack}: {got:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn ranged_draw_uses_native_charge_curves_and_caps() {
|
||||
for (ticks, stretch) in [
|
||||
(0.0, 1.0),
|
||||
|
||||
@@ -102,7 +102,8 @@ enum Provider {
|
||||
/// One font (`FontSet`): its providers and the glyphs resolved so far.
|
||||
struct FontSet {
|
||||
providers: Vec<Provider>,
|
||||
glyphs: HashMap<char, Glyph>,
|
||||
/// Every char drawn looks up here, so the hasher is Fx, not SipHash.
|
||||
glyphs: crate::fast_hash::FxHashMap<char, Glyph>,
|
||||
/// `glyphsByWidth` for obfuscated text (built on first use).
|
||||
by_width: Option<HashMap<u32, Vec<char>>>,
|
||||
}
|
||||
@@ -647,7 +648,7 @@ impl Font {
|
||||
.collect();
|
||||
FontSet {
|
||||
providers,
|
||||
glyphs: HashMap::new(),
|
||||
glyphs: Default::default(),
|
||||
by_width: None,
|
||||
}
|
||||
}
|
||||
|
||||
+295
-175
@@ -123,6 +123,7 @@ mod raids;
|
||||
mod recipe_book;
|
||||
mod remote;
|
||||
mod remote_mob_data;
|
||||
mod remote_mob_use;
|
||||
mod remote_objects;
|
||||
mod remote_projectiles;
|
||||
mod remote_stations;
|
||||
@@ -137,7 +138,6 @@ mod screens;
|
||||
mod sculk;
|
||||
mod sculk_fixture;
|
||||
mod server_travel;
|
||||
mod shader_packs;
|
||||
mod shield;
|
||||
mod sign_edit;
|
||||
mod sniffers;
|
||||
@@ -284,11 +284,9 @@ struct Options {
|
||||
benchmark: Option<String>,
|
||||
/// `--dev-mod PATH` (repeatable): mods always on and reloaded when rebuilt.
|
||||
dev_mods: Vec<PathBuf>,
|
||||
/// `--server-thread` or `HORIZON_SERVER_THREAD=1`: single player runs on an
|
||||
/// integrated server thread (game/integrated.rs), as vanilla's does. Off by
|
||||
/// default again until a server's players reach parity with the in-frame
|
||||
/// world (signs, books, totems, keep_inventory...); `--no-server-thread`
|
||||
/// keeps it off.
|
||||
/// Single player runs on an integrated server thread (game/integrated.rs),
|
||||
/// 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,
|
||||
@@ -346,7 +344,7 @@ impl Options {
|
||||
dev_mods: Vec::new(),
|
||||
// The command harness (HORIZON_COMMAND_SCRIPT) drives the world
|
||||
// itself, without the event loop: it stays in the frame.
|
||||
server_thread: std::env::var("HORIZON_SERVER_THREAD").is_ok_and(|v| v == "1")
|
||||
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,
|
||||
};
|
||||
@@ -356,7 +354,7 @@ impl Options {
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --server-thread Run single player's world on its server thread (not at parity yet)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
horizon_core::VANILLA_VERSION
|
||||
);
|
||||
return Ok(None);
|
||||
@@ -371,7 +369,7 @@ impl Options {
|
||||
"--offscreen" => o.offscreen = true,
|
||||
"--control" => o.control = Some(horizon_client::control::default_path()),
|
||||
"--server-thread" => o.server_thread = true,
|
||||
"--no-server-thread" => o.server_thread = false,
|
||||
"--in-frame" | "--no-server-thread" => o.server_thread = false,
|
||||
_ => {
|
||||
let value = args
|
||||
.next()
|
||||
@@ -505,6 +503,9 @@ enum FallStep {
|
||||
enum GuestUse {
|
||||
OnBlock(Hand),
|
||||
Item(Hand),
|
||||
/// `handleInteract`: `Player.interactOn` with the hand, nothing else (the
|
||||
/// client sends UseItem itself when the entity passed).
|
||||
Entity(Hand),
|
||||
}
|
||||
|
||||
/// The client world as seen by the vanilla block-behaviour library.
|
||||
@@ -1197,8 +1198,6 @@ struct Game<'a> {
|
||||
modifiers: winit::keyboard::ModifiersState,
|
||||
/// The window's fullscreen request and reported state.
|
||||
fullscreen: horizon_client::window::FullscreenState,
|
||||
/// The selected Horizon shader pack (`game/shader_packs.rs`).
|
||||
shader_packs: shader_packs::ShaderPacks,
|
||||
/// MouseHandler's accumulated look and wheel state.
|
||||
mouse: MouseLook,
|
||||
/// An agent moved the mouse (`mouse-move`) since the pointer was last
|
||||
@@ -1442,6 +1441,8 @@ struct Game<'a> {
|
||||
mount_menu: mount_menu::MountMenu,
|
||||
/// The player's fishing hook.
|
||||
hook: Option<fishing::Hook>,
|
||||
/// The other players' hooks a server tracks to this client.
|
||||
remote_hooks: Vec<fishing::RemoteHook>,
|
||||
tridents: Vec<trident::ThrownTrident>,
|
||||
rockets: Vec<fireworks::Rocket>,
|
||||
/// Ominous trial spawners' item spawners.
|
||||
@@ -1737,10 +1738,10 @@ impl Game<'_> {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let f = self.res.facts().get(state);
|
||||
if !self.res.facts().is_air(state)
|
||||
&& !(f.fluid != Fluid::None
|
||||
&& !f.waterlogged
|
||||
&& &*f.name != "minecraft:kelp"
|
||||
&& f.render_shape != horizon_client::blocks::RenderShape::Model)
|
||||
&& (f.fluid == Fluid::None
|
||||
|| f.waterlogged
|
||||
|| &*f.name == "minecraft:kelp"
|
||||
|| f.render_shape == horizon_client::blocks::RenderShape::Model)
|
||||
&& !matches!(
|
||||
&*f.name,
|
||||
"minecraft:water" | "minecraft:lava" | "minecraft:bubble_column"
|
||||
@@ -1813,6 +1814,65 @@ impl Game<'_> {
|
||||
self.stack_in_mut(self.held_slot())
|
||||
}
|
||||
/// The slot of the hand that acts: the selected one, or `OFFHAND_SLOT`.
|
||||
/// `LivingEntity.canGlideUsing`: a glider in the chest slot that will
|
||||
/// not break with its next point of wear.
|
||||
fn glider_worn(&self) -> bool {
|
||||
self.armor[1].as_ref().is_some_and(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.get(&s.item)
|
||||
.is_some_and(|i| i.glider && (i.max_damage == 0 || s.damage + 1 < i.max_damage))
|
||||
})
|
||||
}
|
||||
/// What the window decides for its player's body every tick, in frame and
|
||||
/// on a server alike: the look (Player.travel steers sprint-swimming by
|
||||
/// the pitch), Auto Jump (LocalPlayer.sendPosition), the slowdown of an
|
||||
/// item in use (LocalPlayer.modifyInput), the helmet's Respiration
|
||||
/// (decreaseAirSupply), the wings, leather boots on powder snow
|
||||
/// (PowderSnowBlock.canEntityWalkOnPowderSnow), freeze-immune wear
|
||||
/// (LivingEntity.canFreeze) and the dimension's FAST_LAVA.
|
||||
pub(crate) fn sync_player_body(&mut self) {
|
||||
let glider = self.glider_worn();
|
||||
let item_use_speed = self
|
||||
.using
|
||||
.and_then(|(slot, _)| {
|
||||
if slot == shield::OFFHAND_SLOT {
|
||||
self.offhand.as_ref()
|
||||
} else {
|
||||
self.inventory.get(slot)?.as_ref()
|
||||
}
|
||||
})
|
||||
.and_then(|s| self.res.items().get(&s.item))
|
||||
.map(|i| i.use_speed);
|
||||
let respiration = self.armor[0]
|
||||
.as_ref()
|
||||
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
|
||||
let leather_boots = self.armor[3]
|
||||
.as_ref()
|
||||
.is_some_and(|s| &*s.item == "minecraft:leather_boots");
|
||||
let immune = self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:freeze_immune_wearables");
|
||||
let freeze_immune = self
|
||||
.armor
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)));
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
player.yaw = self.yaw;
|
||||
player.pitch = self.pitch;
|
||||
player.auto_jump_enabled = self.settings.auto_jump;
|
||||
player.item_use_speed = item_use_speed;
|
||||
player.respiration = respiration;
|
||||
player.glider = glider;
|
||||
player.leather_boots = leather_boots;
|
||||
player.freeze_immune = freeze_immune;
|
||||
player.fluids.fast_lava = self.dim == Dim::Nether;
|
||||
}
|
||||
fn held_slot(&self) -> usize {
|
||||
match self.hand {
|
||||
Hand::Main => self.selected,
|
||||
@@ -1990,6 +2050,28 @@ impl Game<'_> {
|
||||
particle.scale(0.6);
|
||||
self.particles.push(particle);
|
||||
}
|
||||
/// `Player.dropEquipment` for the player who died: unless keep_inventory
|
||||
/// is on, `destroyVanishingCursedItems`, then `Inventory.dropAll` from
|
||||
/// `at` (the eyes less 0.3): slots 0..36, armor feet to head (36..39),
|
||||
/// the off hand (40), each thrown in a random direction.
|
||||
fn drop_inventory(&mut self, at: [f64; 3]) {
|
||||
if self.ui.rules.bool("keep_inventory") {
|
||||
return;
|
||||
}
|
||||
let mut stacks: Vec<ItemStack> =
|
||||
self.inventory.iter_mut().filter_map(Option::take).collect();
|
||||
stacks.extend(self.armor.iter_mut().rev().filter_map(Option::take));
|
||||
stacks.extend(self.offhand.take());
|
||||
stacks.retain(|s| s.enchantment_level("minecraft:vanishing_curse") == 0);
|
||||
self.apply_equipment();
|
||||
for stack in stacks {
|
||||
let r = &mut self.world.random;
|
||||
let strength = f64::from(r.next_float()) * 0.5;
|
||||
let angle = f64::from(r.next_float()) * std::f64::consts::TAU;
|
||||
let v = [-angle.sin() * strength, 0.2, angle.cos() * strength];
|
||||
self.spawn_item(stack, at, v, 40);
|
||||
}
|
||||
}
|
||||
fn spawn_item(
|
||||
&mut self,
|
||||
stack: ItemStack,
|
||||
@@ -3239,6 +3321,9 @@ impl Game<'_> {
|
||||
// A guest's use packets name one hand and touch no entity: the client
|
||||
// sends one per hand and part of its click, and Interact for entities.
|
||||
let guest = self.guest_use;
|
||||
if let Some(GuestUse::Entity(hand)) = guest {
|
||||
return Ok(self.with_hand(hand, Game::interact_entity)?.then_some(hand));
|
||||
}
|
||||
let on_block = matches!(guest, Some(GuestUse::OnBlock(_)));
|
||||
if !on_block
|
||||
&& self.riding.is_some()
|
||||
@@ -3247,14 +3332,7 @@ impl Game<'_> {
|
||||
{
|
||||
return Ok(Some(Hand::Main));
|
||||
}
|
||||
if guest.is_none()
|
||||
&& (self.interact_frame()
|
||||
|| self.interact_armor_stand()
|
||||
|| self.interact_boat()
|
||||
|| self.interact_cart()
|
||||
|| self.interact_mob()?
|
||||
|| self.interact_knot())
|
||||
{
|
||||
if guest.is_none() && self.interact_entity()? {
|
||||
return Ok(Some(Hand::Main));
|
||||
}
|
||||
// An EntityHitResult may fall through to Item.use, never to useItemOn.
|
||||
@@ -3264,16 +3342,33 @@ impl Game<'_> {
|
||||
self.pick()
|
||||
};
|
||||
let hands = match guest {
|
||||
Some(GuestUse::OnBlock(hand) | GuestUse::Item(hand)) => vec![hand],
|
||||
Some(GuestUse::OnBlock(hand) | GuestUse::Item(hand) | GuestUse::Entity(hand)) => {
|
||||
vec![hand]
|
||||
}
|
||||
None => Hand::BOTH.to_vec(),
|
||||
};
|
||||
let entity = guest.is_none() && self.entity_targeted();
|
||||
for hand in hands {
|
||||
// The off hand's turn starts on the entity too (Minecraft.startUseItem).
|
||||
if entity && hand == Hand::Off && self.with_hand(hand, Game::interact_entity)? {
|
||||
return Ok(Some(hand));
|
||||
}
|
||||
if self.with_hand(hand, |game| game.use_hand(block_hit.as_ref()))? {
|
||||
return Ok(Some(hand));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
/// `Player.interactOn` for the entity under the crosshair with the
|
||||
/// acting hand: whether it took the click.
|
||||
fn interact_entity(&mut self) -> Result<bool> {
|
||||
Ok(self.interact_frame()
|
||||
|| self.interact_armor_stand()
|
||||
|| self.interact_boat()
|
||||
|| self.interact_cart()
|
||||
|| self.interact_mob()?
|
||||
|| self.interact_knot())
|
||||
}
|
||||
/// One hand's turn in `startUseItem`: `useItemOn` the block aimed at,
|
||||
/// then `useItem`. Whether the click ends here (it acted or failed).
|
||||
fn use_hand(&mut self, block_hit: Option<&Hit>) -> Result<bool> {
|
||||
@@ -3412,7 +3507,7 @@ impl Game<'_> {
|
||||
return Ok(true);
|
||||
}
|
||||
// Block foods attempt placement before starting consumption.
|
||||
if !on_block && !(info.block.is_some() && block_hit.is_some()) && self.start_eating(&info) {
|
||||
if !on_block && (info.block.is_none() || block_hit.is_none()) && self.start_eating(&info) {
|
||||
return Ok(true);
|
||||
}
|
||||
// BoatItem.use also performs its own fluid/block raycast.
|
||||
@@ -3632,16 +3727,8 @@ impl Game<'_> {
|
||||
// Switching hotbar slots only ends a main-hand use.
|
||||
let hand_changed = slot != self.selected && slot != shield::OFFHAND_SLOT;
|
||||
if !self.right || hand_changed || self.screen != Screen::Playing {
|
||||
if &*stack.item == "minecraft:bow" && !hand_changed {
|
||||
self.release_bow(ticks);
|
||||
}
|
||||
if &*stack.item == "minecraft:spyglass" {
|
||||
self.stop_spyglass();
|
||||
}
|
||||
if &*stack.item == "minecraft:trident" && !hand_changed {
|
||||
self.release_trident(ticks);
|
||||
}
|
||||
self.using = None;
|
||||
self.release_used_item(&stack, ticks, hand_changed);
|
||||
return;
|
||||
}
|
||||
let Some(info) = self.res.items().get(&stack.item).cloned() else {
|
||||
@@ -3697,6 +3784,17 @@ impl Game<'_> {
|
||||
self.using = Some((slot, ticks));
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.releaseUsingItem` (`Item.releaseUsing`) for `stack`,
|
||||
/// used for `ticks`, its hand acting: a bow or a trident lets go (not
|
||||
/// when its hand changed), a spyglass stops scoping.
|
||||
pub(super) fn release_used_item(&mut self, stack: &ItemStack, ticks: u32, hand_changed: bool) {
|
||||
match &*stack.item {
|
||||
"minecraft:bow" if !hand_changed => self.release_bow(ticks),
|
||||
"minecraft:trident" if !hand_changed => self.release_trident(ticks),
|
||||
"minecraft:spyglass" => self.stop_spyglass(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn tick_mining(&mut self) -> Result<()> {
|
||||
if self.destroy_delay > 0 {
|
||||
self.destroy_delay -= 1;
|
||||
@@ -3786,7 +3884,12 @@ impl Game<'_> {
|
||||
}
|
||||
self.crack_particle(crack.0, crack.1, state);
|
||||
let m = self.mining.as_mut().ok_or("mining")?;
|
||||
if m.progress >= 1.0 {
|
||||
// ServerPlayerGameMode breaks a player's block at once only when it is instant (START's
|
||||
// insta-mine); otherwise it waits for the client's STOP_DESTROY_BLOCK
|
||||
// (`guest_stop_digging`), never breaking it on its own count. Breaking it here would
|
||||
// take the block from under the player's crack before the player's client predicts the
|
||||
// break, so it gets neither particles nor sound.
|
||||
if m.progress >= 1.0 && (self.lan_acting.is_none() || m.ticks == 1) {
|
||||
let pos = m.pos;
|
||||
self.mining = None;
|
||||
self.break_block(pos)?;
|
||||
@@ -3920,13 +4023,7 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
|
||||
player.pitch = self.pitch;
|
||||
// LivingEntity.canGlideUsing: a glider in its slot that will not break next.
|
||||
player.glider = self.armor[1].as_ref().is_some_and(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.get(&s.item)
|
||||
.is_some_and(|i| i.glider && (i.max_damage == 0 || s.damage + 1 < i.max_damage))
|
||||
});
|
||||
player.glider = self.glider_worn();
|
||||
// A saved death already dropped inventory/XP and counted the death.
|
||||
if player.health.dead_or_dying() {
|
||||
self.screen = Screen::Dead;
|
||||
@@ -4002,8 +4099,9 @@ impl Game<'_> {
|
||||
self.tick_riding(keys)?;
|
||||
}
|
||||
let riding = self.riding.is_some();
|
||||
let ghast_collisions = self.happy_ghast_player_collisions();
|
||||
let entity_collisions = self.player_entity_collisions();
|
||||
let spin_targets = self.spin_targets();
|
||||
self.sync_player_body();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
// A dedicated server has no local player: the world still runs.
|
||||
if self.options.dedicated.is_some() {
|
||||
@@ -4035,52 +4133,10 @@ impl Game<'_> {
|
||||
if !riding {
|
||||
self.previous_position = player.position;
|
||||
}
|
||||
player.yaw = self.yaw;
|
||||
let spectator = self.mode == horizon_client::command::args::GameMode::Spectator;
|
||||
player.may_fly = self.creative || spectator;
|
||||
// LocalPlayer.sendPosition: autoJumpEnabled follows the option.
|
||||
player.auto_jump_enabled = self.settings.auto_jump;
|
||||
player.invulnerable = self.creative || spectator;
|
||||
player.spectator = spectator;
|
||||
player.respiration = self.armor[0]
|
||||
.as_ref()
|
||||
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
|
||||
player.pitch = self.pitch;
|
||||
// LivingEntity.canGlideUsing: a glider in its slot that will not break next.
|
||||
player.glider = self.armor[1].as_ref().is_some_and(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.get(&s.item)
|
||||
.is_some_and(|i| i.glider && (i.max_damage == 0 || s.damage + 1 < i.max_damage))
|
||||
});
|
||||
// LocalPlayer.modifyInput: walking while using an item.
|
||||
player.item_use_speed = self
|
||||
.using
|
||||
.and_then(|(slot, _)| {
|
||||
if slot == shield::OFFHAND_SLOT {
|
||||
self.offhand.as_ref()
|
||||
} else {
|
||||
self.inventory.get(slot)?.as_ref()
|
||||
}
|
||||
})
|
||||
.and_then(|s| self.res.items().get(&s.item))
|
||||
.map(|i| i.use_speed);
|
||||
// PowderSnowBlock.canEntityWalkOnPowderSnow and LivingEntity.canFreeze.
|
||||
player.leather_boots = self.armor[3]
|
||||
.as_ref()
|
||||
.is_some_and(|s| &*s.item == "minecraft:leather_boots");
|
||||
let immune = self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:freeze_immune_wearables");
|
||||
player.freeze_immune = self
|
||||
.armor
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)));
|
||||
// The dimension's FAST_LAVA: the Nether's lava pushes harder.
|
||||
player.fluids.fast_lava = self.dim == Dim::Nether;
|
||||
player.spin_targets = spin_targets;
|
||||
let grounded = player.ground.on_ground;
|
||||
let before = (
|
||||
@@ -4090,7 +4146,7 @@ impl Game<'_> {
|
||||
);
|
||||
if !riding {
|
||||
MotionWorld {
|
||||
entities: &ghast_collisions,
|
||||
entities: &entity_collisions,
|
||||
player,
|
||||
scene: &mut self.world,
|
||||
assets: &self.pack.assets,
|
||||
@@ -4289,20 +4345,8 @@ impl Game<'_> {
|
||||
.experience
|
||||
.death_reward(self.ui.rules.bool("keep_inventory") || self.spectator());
|
||||
self.spawn_orbs(feet, reward);
|
||||
if !self.creative && !self.ui.rules.bool("keep_inventory") {
|
||||
// Inventory.dropAll: slots 0..36, armor feet to head (36..39), off hand (40).
|
||||
let mut stacks: Vec<ItemStack> =
|
||||
self.inventory.iter_mut().filter_map(Option::take).collect();
|
||||
stacks.extend(self.armor.iter_mut().rev().filter_map(Option::take));
|
||||
stacks.extend(self.offhand.take());
|
||||
self.apply_equipment();
|
||||
for stack in stacks {
|
||||
let r = &mut self.world.random;
|
||||
let strength = f64::from(r.next_float()) * 0.5;
|
||||
let angle = f64::from(r.next_float()) * std::f64::consts::TAU;
|
||||
let v = [-angle.sin() * strength, 0.2, angle.cos() * strength];
|
||||
self.spawn_item(stack, at, v, 40);
|
||||
}
|
||||
if !self.spectator() {
|
||||
self.drop_inventory(at);
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:entity.player.death", Some(at), 1.0, 1.0);
|
||||
@@ -5482,6 +5526,20 @@ impl Game<'_> {
|
||||
Some(true)
|
||||
})
|
||||
}
|
||||
/// A sound a menu click made the player play (a bundle's insert or
|
||||
/// remove): `Entity.playSound` at the player.
|
||||
pub(super) fn player_menu_sound(&mut self, sound: &str) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
|
||||
(1.0, 1.0)
|
||||
} else {
|
||||
(0.8, 0.8 + self.world.random.next_float() * 0.4)
|
||||
};
|
||||
self.audio.play(sound, Some(at), volume, pitch);
|
||||
}
|
||||
/// The open menu as the verified engine (`horizon_client::menu`): the
|
||||
/// game's slots are copied in, `run` acts on it, and the slots, carried
|
||||
/// stack, menu data and drops are copied back when it returns Some(true)
|
||||
@@ -5871,6 +5929,11 @@ impl Game<'_> {
|
||||
let carried = menu.carried.take();
|
||||
let dropped = std::mem::take(&mut menu.dropped);
|
||||
let sounds = std::mem::take(&mut menu.sounds);
|
||||
// A bundle's sounds are the player's own (`Entity.playSound`): the
|
||||
// client plays them as it clicks, predicting or not.
|
||||
for sound in std::mem::take(&mut menu.player_sounds) {
|
||||
self.player_menu_sound(sound);
|
||||
}
|
||||
let crafted = std::mem::take(&mut menu.crafted);
|
||||
let equipped = std::mem::take(&mut menu.equipped);
|
||||
for (i, stack) in after.into_iter().enumerate() {
|
||||
@@ -5956,6 +6019,37 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.keyPressed` on the search tab: a hotbar
|
||||
/// key over a slot swaps before the search box sees it.
|
||||
fn search_tab_hotbar_key(&mut self, code: KeyCode) -> bool {
|
||||
use horizon_client::creative::{Hover, search_key_swaps};
|
||||
let hotbar_key = HOTBAR_DIGITS.contains(&code);
|
||||
let numeric = hotbar_key || NUMERIC_KEYS.contains(&code);
|
||||
if !numeric {
|
||||
return false;
|
||||
}
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|r| [r.config.width, r.config.height])
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let batch = HudBatch::new(size);
|
||||
let hover = match self.slot_at(&batch, self.cursor.0, self.cursor.1) {
|
||||
None => Hover::Nothing,
|
||||
Some(slot) if slot >= Creative::DISPLAY => {
|
||||
let first = self.creative_screen.first_row(&self.pack.names);
|
||||
let shown = self.creative_screen.items(&self.pack.names).len();
|
||||
Hover::Display {
|
||||
filled: first * 9 + slot - Creative::DISPLAY < shown,
|
||||
}
|
||||
}
|
||||
Some(_) => Hover::Slot,
|
||||
};
|
||||
search_key_swaps(numeric, hotbar_key, hover, self.carried.is_some())
|
||||
&& self.inventory_hotkey(code)
|
||||
}
|
||||
/// Number keys, F and Q over a slot of the open menu: SWAP with a hotbar
|
||||
/// slot or the off hand, THROW one or the whole stack.
|
||||
fn inventory_hotkey(&mut self, key: KeyCode) -> bool {
|
||||
@@ -5971,19 +6065,7 @@ impl Game<'_> {
|
||||
let Some(slot) = self.slot_at(&batch, self.cursor.0, self.cursor.1) else {
|
||||
return false;
|
||||
};
|
||||
let digit = [
|
||||
KeyCode::Digit1,
|
||||
KeyCode::Digit2,
|
||||
KeyCode::Digit3,
|
||||
KeyCode::Digit4,
|
||||
KeyCode::Digit5,
|
||||
KeyCode::Digit6,
|
||||
KeyCode::Digit7,
|
||||
KeyCode::Digit8,
|
||||
KeyCode::Digit9,
|
||||
]
|
||||
.iter()
|
||||
.position(|k| *k == key);
|
||||
let digit = HOTBAR_DIGITS.iter().position(|k| *k == key);
|
||||
let done = match (digit, key) {
|
||||
(Some(n), _) => self.menu_click(Some(slot), n as i32, Input::Swap),
|
||||
(None, KeyCode::KeyF) => self.menu_click(Some(slot), 40, Input::Swap),
|
||||
@@ -6115,6 +6197,17 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// mouseClicked: an empty hand's PICKUP is the menu's click at once
|
||||
// (a right click on a bundle takes its first stack out).
|
||||
if let (Some(input), Some(_)) = (input, slot)
|
||||
&& self.remote.is_none()
|
||||
&& self.menu_click(slot, i32::from(right), input)
|
||||
{
|
||||
if let Some(key) = self.container {
|
||||
self.container_changed(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.click_inventory(right);
|
||||
}
|
||||
/// Mouse movement while a quick craft drag is running.
|
||||
@@ -7338,30 +7431,17 @@ impl Game<'_> {
|
||||
self.tick_player_model();
|
||||
Ok(())
|
||||
}
|
||||
/// What the loaded Java shader pack changes in this dimension's vanilla
|
||||
/// sky (`sunPathRotation`, `clouds`, `sun`, `moon`); `None` without one.
|
||||
fn java_sky(&self) -> Option<(f32, horizon_shaderpack::properties::SkyDirectives)> {
|
||||
self.shader_packs.java_sky(match self.dim {
|
||||
Dim::Overworld => 0,
|
||||
Dim::Nether => -1,
|
||||
Dim::End => 1,
|
||||
})
|
||||
}
|
||||
/// SkyRenderer.renderSunMoonAndStars: bodies on a frame rotated Y -90 then X by angle.
|
||||
/// A Java pack's `sunPathRotation` tilts it about Z in between, and its
|
||||
/// `sun = false` / `moon = false` leave those out, as Iris does. Returns
|
||||
/// the sun and moon quads, and the stars' frame and brightness (the
|
||||
/// renderer keeps their geometry, built once).
|
||||
/// Returns the sun and moon quads, and the stars' frame and brightness
|
||||
/// (the renderer keeps their geometry, built once).
|
||||
fn celestial_geometry(
|
||||
&self,
|
||||
sky: &horizon_client::environment::Sky,
|
||||
) -> (Vec<Vertex>, Option<(glam::Mat4, f32)>) {
|
||||
let mut out = Vec::new();
|
||||
let atlas = &self.gui.atlas;
|
||||
let (tilt, keep) = self.java_sky().unwrap_or_default();
|
||||
let frame = |angle: f32| {
|
||||
glam::Mat4::from_rotation_y((-90.0f32).to_radians())
|
||||
* glam::Mat4::from_rotation_z(tilt.to_radians())
|
||||
* glam::Mat4::from_rotation_x(angle.to_radians())
|
||||
};
|
||||
let mut body = |name: &str, angle: f32, size: f32, color: u32| {
|
||||
@@ -7383,14 +7463,12 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
};
|
||||
if keep.sun {
|
||||
body(
|
||||
"environment/celestial/sun",
|
||||
sky.sun_angle,
|
||||
30.0,
|
||||
0xFFFF_FFFF,
|
||||
);
|
||||
}
|
||||
body(
|
||||
"environment/celestial/sun",
|
||||
sky.sun_angle,
|
||||
30.0,
|
||||
0xFFFF_FFFF,
|
||||
);
|
||||
let phases = [
|
||||
"full_moon",
|
||||
"waning_gibbous",
|
||||
@@ -7402,14 +7480,12 @@ impl Game<'_> {
|
||||
"waxing_gibbous",
|
||||
];
|
||||
let phase = phases[(self.world.day_time / 24000).rem_euclid(8) as usize];
|
||||
if keep.moon {
|
||||
body(
|
||||
&format!("environment/celestial/moon/{phase}"),
|
||||
sky.moon_angle,
|
||||
20.0,
|
||||
0xFFFF_FFFF,
|
||||
);
|
||||
}
|
||||
body(
|
||||
&format!("environment/celestial/moon/{phase}"),
|
||||
sky.moon_angle,
|
||||
20.0,
|
||||
0xFFFF_FFFF,
|
||||
);
|
||||
// SkyRenderer.renderStars: the static star buffer on the same frame.
|
||||
let stars =
|
||||
(sky.star_brightness > 0.0).then(|| (frame(sky.star_angle), sky.star_brightness));
|
||||
@@ -8799,20 +8875,15 @@ impl Game<'_> {
|
||||
);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
let deg = |d: f32| d.to_radians();
|
||||
if self.held_map_geometry(to_world, attacks[0], inverse_heights[0], packed, &mut out) {
|
||||
return out;
|
||||
}
|
||||
let shown = self.hands_to_render();
|
||||
let used_hand = self.used_hand();
|
||||
let spinning = self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.auto_spin_attack_ticks > 0);
|
||||
for (i, (hand, item)) in Hand::BOTH
|
||||
.into_iter()
|
||||
.zip(self.visible_hand_items())
|
||||
.enumerate()
|
||||
{
|
||||
let items = self.visible_hand_items();
|
||||
let off_hand_empty = items[1].is_none();
|
||||
for (i, (hand, item)) in Hand::BOTH.into_iter().zip(items).enumerate() {
|
||||
if !shown[i] {
|
||||
continue;
|
||||
}
|
||||
@@ -8875,6 +8946,26 @@ impl Game<'_> {
|
||||
);
|
||||
continue;
|
||||
};
|
||||
if let Some(id) = maps::map_id(&stack) {
|
||||
use horizon_client::first_person::{MapHold, one_handed_map_pose};
|
||||
match MapHold::of(hand, off_hand_empty) {
|
||||
MapHold::TwoHanded => self.two_handed_map_geometry(
|
||||
to_world,
|
||||
attack,
|
||||
inverse_height,
|
||||
id,
|
||||
packed,
|
||||
&mut out,
|
||||
),
|
||||
MapHold::OneHanded => self.held_map(
|
||||
to_world * one_handed_map_pose(left, inverse_height, attack),
|
||||
id,
|
||||
packed,
|
||||
&mut out,
|
||||
),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut owner = self.player_owner(Some(&stack));
|
||||
// The other hand may hold an equal stack: only the used
|
||||
// hand's item is in use, only the main hand's selected.
|
||||
@@ -9720,7 +9811,6 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
self.poll_shader_pack();
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
@@ -9933,18 +10023,6 @@ impl Game<'_> {
|
||||
self.dragon_fireball_geometry(camera, alpha, &mut dragon);
|
||||
entities.extend(dragon);
|
||||
}
|
||||
// A Java pack's `clouds` replaces the cloud option (`renderClouds`:
|
||||
// "false", "fast", "true"), as Iris overrides `getCloudsType`.
|
||||
let clouds_mode = {
|
||||
use horizon_shaderpack::properties::CloudSetting;
|
||||
match self.java_sky().and_then(|(_, sky)| sky.clouds) {
|
||||
Some(CloudSetting::Off) => "false",
|
||||
Some(CloudSetting::Fast) => "fast",
|
||||
Some(CloudSetting::Fancy) => "true",
|
||||
None => self.settings.clouds.as_str(),
|
||||
}
|
||||
.to_owned()
|
||||
};
|
||||
let (celestial, stars) = if title {
|
||||
(Vec::new(), None)
|
||||
} else if self.dim == Dim::Overworld
|
||||
@@ -9988,7 +10066,6 @@ impl Game<'_> {
|
||||
let frame = Frame {
|
||||
weather: &weather,
|
||||
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
|
||||
shader: self.shader_inputs(camera, alpha as f32),
|
||||
nether_lighting: self.dim == Dim::Nether,
|
||||
camera,
|
||||
yaw,
|
||||
@@ -10036,7 +10113,7 @@ impl Game<'_> {
|
||||
hud: &hud,
|
||||
clouds: (!title
|
||||
&& self.dim == Dim::Overworld
|
||||
&& horizon_client::clouds::visible(&clouds_mode, sky.cloud_color[3]))
|
||||
&& horizon_client::clouds::visible(&self.settings.clouds, sky.cloud_color[3]))
|
||||
.then(|| self.gui.atlas.sprites.get("horizon/white"))
|
||||
.flatten()
|
||||
.map(|white| horizon_client::clouds::Input {
|
||||
@@ -10046,7 +10123,7 @@ impl Game<'_> {
|
||||
time: self.world.game_time,
|
||||
partial_tick: alpha,
|
||||
range: self.settings.cloud_range,
|
||||
fast: clouds_mode == "fast",
|
||||
fast: self.settings.clouds == "fast",
|
||||
color: sky.cloud_color,
|
||||
}),
|
||||
entities: &entities,
|
||||
@@ -10268,6 +10345,11 @@ impl Game<'_> {
|
||||
if (self.screen == Screen::Inventory
|
||||
|| (self.screen == Screen::Creative && !self.creative_screen.tab().search))
|
||||
&& self.inventory_hotkey(key) => {}
|
||||
// The search tab's number keys (scripted presses; the window's
|
||||
// keys reach search_tab_hotbar_key before the search box).
|
||||
_ if self.screen == Screen::Creative
|
||||
&& self.creative_screen.tab().search
|
||||
&& self.search_tab_hotbar_key(key) => {}
|
||||
KeyCode::KeyT if self.screen == Screen::Inventory => self.recipe_focus_search(),
|
||||
KeyCode::KeyT if self.screen == Screen::Playing => self.open_chat(""),
|
||||
// key.advancements.
|
||||
@@ -10759,9 +10841,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
WindowEvent::CloseRequested => {
|
||||
self.pending_save.as_mut().unwrap().quit = true;
|
||||
}
|
||||
WindowEvent::DroppedFile(path) => {
|
||||
self.drop_shader_pack(&path);
|
||||
}
|
||||
WindowEvent::Focused(focused) => {
|
||||
self.focused = focused;
|
||||
if !focused {
|
||||
@@ -11139,7 +11218,11 @@ impl ApplicationHandler for Game<'_> {
|
||||
&& event.state == ElementState::Pressed
|
||||
&& !matches!(event.physical_key, PhysicalKey::Code(KeyCode::Escape)) =>
|
||||
{
|
||||
if let Some(shortcut) = self.edit_shortcut(&event) {
|
||||
if let PhysicalKey::Code(key) = event.physical_key
|
||||
&& self.search_tab_hotbar_key(key)
|
||||
{
|
||||
// The hovered slot took the key (`ignoreTextInput`).
|
||||
} else if let Some(shortcut) = self.edit_shortcut(&event) {
|
||||
self.creative_search_shortcut(shortcut);
|
||||
} else {
|
||||
let backspace = event.physical_key == PhysicalKey::Code(KeyCode::Backspace);
|
||||
@@ -11296,7 +11379,17 @@ impl ApplicationHandler for Game<'_> {
|
||||
// For the "I'm playing" ping: an atomic, sent on when it changes.
|
||||
horizon_client::heartbeat::set_lan(self.is_published());
|
||||
let steps = self.clock.advance(elapsed);
|
||||
for _ in 0..steps {
|
||||
// The world ticking in the frame: once its ticks took a
|
||||
// tick's length, the rest are dropped (`FixedStep::tick_fits`).
|
||||
let in_frame = self.remote.is_none();
|
||||
let mut spent = Duration::ZERO;
|
||||
for step in 0..steps {
|
||||
if in_frame && !self.clock.tick_fits(spent) {
|
||||
if let Some(warning) = self.clock.drop_ticks(steps - step) {
|
||||
log::warn!("{warning}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
let started = Instant::now();
|
||||
if self.title.is_none() && !self.pause {
|
||||
let tick = Instant::now();
|
||||
@@ -11313,6 +11406,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.tick_music();
|
||||
self.tick_sound_engine();
|
||||
self.clock.record(started.elapsed());
|
||||
spent += started.elapsed();
|
||||
if let Some(report) = self.clock.take_sprint_report() {
|
||||
self.sprint_report(&report);
|
||||
}
|
||||
@@ -11629,7 +11723,6 @@ impl<'a> Game<'a> {
|
||||
focused: true,
|
||||
modifiers: winit::keyboard::ModifiersState::empty(),
|
||||
fullscreen: horizon_client::window::FullscreenState::new(false),
|
||||
shader_packs: Default::default(),
|
||||
mouse: MouseLook::default(),
|
||||
agent_pointer: false,
|
||||
smooth_camera: false,
|
||||
@@ -11789,6 +11882,7 @@ impl<'a> Game<'a> {
|
||||
recipe_ui: Default::default(),
|
||||
mount_menu: Default::default(),
|
||||
hook: None,
|
||||
remote_hooks: Vec::new(),
|
||||
tridents: Vec::new(),
|
||||
rockets: Vec::new(),
|
||||
ominous_spawners: Vec::new(),
|
||||
@@ -12066,6 +12160,33 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `key.hotbar.1` to `key.hotbar.9` in a menu.
|
||||
const HOTBAR_DIGITS: [KeyCode; 9] = [
|
||||
KeyCode::Digit1,
|
||||
KeyCode::Digit2,
|
||||
KeyCode::Digit3,
|
||||
KeyCode::Digit4,
|
||||
KeyCode::Digit5,
|
||||
KeyCode::Digit6,
|
||||
KeyCode::Digit7,
|
||||
KeyCode::Digit8,
|
||||
KeyCode::Digit9,
|
||||
];
|
||||
/// The other keys `InputConstants.Key.getNumericKeyValue` gives a digit.
|
||||
const NUMERIC_KEYS: [KeyCode; 11] = [
|
||||
KeyCode::Digit0,
|
||||
KeyCode::Numpad0,
|
||||
KeyCode::Numpad1,
|
||||
KeyCode::Numpad2,
|
||||
KeyCode::Numpad3,
|
||||
KeyCode::Numpad4,
|
||||
KeyCode::Numpad5,
|
||||
KeyCode::Numpad6,
|
||||
KeyCode::Numpad7,
|
||||
KeyCode::Numpad8,
|
||||
KeyCode::Numpad9,
|
||||
];
|
||||
|
||||
const STAGES: u32 = 10;
|
||||
const STAGE_LINES: [&str; 10] = [
|
||||
"Loading menu",
|
||||
@@ -12560,7 +12681,6 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
night_vision: 0.0,
|
||||
block_flicker: 0.0,
|
||||
menu_blur: None,
|
||||
shader: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -335,6 +335,12 @@ impl Game<'_> {
|
||||
Some(Ticker::Shulker { .. }) => v["lid"] = json!(self.block_anim(pos, 1.0).shulker.0),
|
||||
_ => {}
|
||||
}
|
||||
// The lines of a sign, as this client has them.
|
||||
if let Some(horizon_client::containers::BlockEntity::Sign(sign)) =
|
||||
self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
|
||||
{
|
||||
v["sign"] = json!({ "front": sign.front.lines, "back": sign.back.lines });
|
||||
}
|
||||
v
|
||||
}
|
||||
pub(super) fn stack_value(&self, s: &ItemStack) -> Value {
|
||||
@@ -457,6 +463,7 @@ impl Game<'_> {
|
||||
"velocity": [p.velocity.x, p.velocity.y, p.velocity.z],
|
||||
"on_ground": p.ground.on_ground,
|
||||
"flying": p.flying,
|
||||
"fall_flying": p.fall_flying,
|
||||
"sneaking": self.keys.contains(&KeyCode::ShiftLeft),
|
||||
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
|
||||
"health": p.health.health(),
|
||||
@@ -678,6 +685,8 @@ impl Game<'_> {
|
||||
json!({
|
||||
"chat": self.remote_chat(n).unwrap_or_else(|| self.ui.chat.recent_lines(n)),
|
||||
"order": "newest first",
|
||||
// The chat lines as drawn: colours, click and hover events.
|
||||
"styled": self.ui.chat.recent_components(n),
|
||||
"title": (titles.time > 0).then(|| titles.title.as_ref().map(|t| t.plain())).flatten(),
|
||||
"subtitle": (titles.time > 0).then(|| titles.subtitle.as_ref().map(|t| t.plain())).flatten(),
|
||||
"actionbar": (titles.overlay_time > 0).then(|| titles.overlay.as_ref().map(|t| t.plain())).flatten(),
|
||||
|
||||
@@ -461,14 +461,14 @@ impl Game<'_> {
|
||||
count: 1,
|
||||
..h.clone()
|
||||
});
|
||||
self.inventory[self.selected] = Some(ItemStack {
|
||||
*self.held_mut() = Some(ItemStack {
|
||||
count: h.count - 1,
|
||||
..h
|
||||
});
|
||||
}
|
||||
h => {
|
||||
self.armor_stands[i].equipment[slot] = h;
|
||||
self.inventory[self.selected] = current;
|
||||
*self.held_mut() = current;
|
||||
}
|
||||
}
|
||||
let p = self.armor_stands[i].position;
|
||||
|
||||
@@ -59,6 +59,30 @@ impl Game<'_> {
|
||||
}
|
||||
/// Block commands; None when `words[0]` is not one of them.
|
||||
pub(super) fn block_command(&mut self, words: &[&str]) -> Result<Option<Value>> {
|
||||
let ours = matches!(
|
||||
words.first(),
|
||||
Some(&("setblock" | "fill" | "getblock" | "container"))
|
||||
);
|
||||
// Single player on its server thread: the world is the server's, so
|
||||
// the command runs there, in the owner's level, and the client hears
|
||||
// of it like any change.
|
||||
if ours && let Some(server) = &self.integrated {
|
||||
let owned: Vec<String> = words.iter().map(|w| (*w).to_owned()).collect();
|
||||
return server
|
||||
.call(move |g| {
|
||||
let words: Vec<&str> = owned.iter().map(String::as_str).collect();
|
||||
g.in_owner_level(|g| g.block_command(&words).map_err(|e| e.to_string()))
|
||||
})
|
||||
.ok_or("the integrated server stopped")?
|
||||
.map_err(Into::into);
|
||||
}
|
||||
// Another server's world changes only through its own commands, which
|
||||
// it checks as the player's; `getblock` and `container` read what this
|
||||
// client has of it.
|
||||
if self.remote.is_some() && matches!(words.first(), Some(&("setblock" | "fill"))) {
|
||||
self.send_chat(&format!("/{}", words.join(" ")));
|
||||
return Ok(Some(json!({ "ok": true, "sent": true })));
|
||||
}
|
||||
let int = |i: usize| -> Result<i32> {
|
||||
Ok(words
|
||||
.get(i)
|
||||
|
||||
@@ -183,13 +183,18 @@ impl BoatModels {
|
||||
}
|
||||
impl Game<'_> {
|
||||
fn fluid_height(&self, x: i32, y: i32, z: i32) -> Option<(f32, bool)> {
|
||||
self.fluid_height_of(x, y, z, Some(Fluid::Water))
|
||||
}
|
||||
/// `FluidState.getHeight` and whether it is a source, for `fluid` or,
|
||||
/// with `None`, any fluid (`ClipContext.Fluid.ANY`).
|
||||
fn fluid_height_of(&self, x: i32, y: i32, z: i32, fluid: Option<Fluid>) -> Option<(f32, bool)> {
|
||||
let f = self.res.facts().get(self.world.block(x, y, z));
|
||||
if f.fluid != Fluid::Water {
|
||||
if f.fluid == Fluid::None || fluid.is_some_and(|want| f.fluid != want) {
|
||||
return None;
|
||||
}
|
||||
let above = self.res.facts().get(self.world.block(x, y + 1, z));
|
||||
// FluidState.getHeight: 1 under the same fluid, else amount / 9.
|
||||
let height = if above.fluid == Fluid::Water {
|
||||
let height = if above.fluid == f.fluid {
|
||||
1.0
|
||||
} else {
|
||||
f32::from(f.fluid_level) / 9.0
|
||||
@@ -461,6 +466,59 @@ impl Game<'_> {
|
||||
let b = self.boats[i].clone();
|
||||
self.boats[i] = self.tick_boat(b, false);
|
||||
}
|
||||
// AbstractBoat.tick's applyEffectsFromBlocks, on the server for every
|
||||
// boat, the client-driven ones too.
|
||||
let mut i = 0;
|
||||
while i < self.boats.len() {
|
||||
let p = self.boats[i].position;
|
||||
let skip = Some(self.boats[i].id) == self.riding
|
||||
|| !ticking(p.x.floor() as i32, p.z.floor() as i32);
|
||||
if skip || !self.boat_lava_tick(i) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LavaFluid.entityInside` (`lavaHurt`) from the two
|
||||
/// `applyEffectsFromBlocks` calls in `AbstractBoat.tick`: twice 40
|
||||
/// damage, so lava breaks a boat on its first tick inside, and the fire
|
||||
/// `lavaIgnite` lights never gets to burn it. True when the boat broke.
|
||||
fn boat_lava_tick(&mut self, i: usize) -> bool {
|
||||
if !self.in_lava(self.boats[i].bounds()) {
|
||||
return false;
|
||||
}
|
||||
for _ in 0..2 {
|
||||
let p = self.boats[i].position;
|
||||
let broke = self.hurt_boat(i, 4.0, false);
|
||||
self.lava_hurt_sound([p.x, p.y, p.z]);
|
||||
if broke {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `Entity.lavaHurt`'s sound once the hurt went through.
|
||||
pub(super) fn lava_hurt_sound(&mut self, at: [f64; 3]) {
|
||||
let pitch = 2.0 + self.world.random.next_float() * 0.4;
|
||||
self.audio
|
||||
.play("minecraft:entity.generic.burn", Some(at), 0.4, pitch);
|
||||
}
|
||||
/// Whether a vehicle's box, deflated by 1e-5, reaches into lava below its
|
||||
/// surface (`Entity.collidedWithFluid`).
|
||||
pub(super) fn in_lava(&self, bb: Aabb) -> bool {
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
let e = 1.0e-5;
|
||||
for x in (lo.x + e).floor() as i32..=(hi.x - e).floor() as i32 {
|
||||
for y in (lo.y + e).floor() as i32..=(hi.y - e).floor() as i32 {
|
||||
for z in (lo.z + e).floor() as i32..=(hi.z - e).floor() as i32 {
|
||||
if let Some((height, _)) = self.fluid_height_of(x, y, z, Some(Fluid::Lava))
|
||||
&& lo.y + e < f64::from(y) + f64::from(height)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
/// The player's tick while riding: steer, tick the boat, sit in it.
|
||||
pub(super) fn tick_riding(&mut self, keys: Keys) -> horizon_client::Result<()> {
|
||||
@@ -489,6 +547,9 @@ impl Game<'_> {
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
self.boats[i] = b;
|
||||
if self.boat_lava_tick(i) {
|
||||
self.riding = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `getDismountLocationForPassenger`: in front of the player if there is
|
||||
@@ -534,7 +595,8 @@ impl Game<'_> {
|
||||
};
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let reach = self.reach();
|
||||
// ClipContext.Fluid.ANY: the first water surface or block along the ray.
|
||||
// ClipContext.Fluid.ANY: the first fluid surface (water or lava) or
|
||||
// block along the ray.
|
||||
let block = self.pick().map(|h| {
|
||||
(
|
||||
h.point,
|
||||
@@ -544,7 +606,7 @@ impl Game<'_> {
|
||||
.sqrt(),
|
||||
)
|
||||
});
|
||||
let mut water = None;
|
||||
let mut fluid = None;
|
||||
let mut t = 0.0;
|
||||
while t < reach {
|
||||
let p: [f64; 3] = std::array::from_fn(|a| eye[a] + dir[a] * t);
|
||||
@@ -553,15 +615,15 @@ impl Game<'_> {
|
||||
p[1].floor() as i32,
|
||||
p[2].floor() as i32,
|
||||
);
|
||||
if let Some((h, _)) = self.fluid_height(x, y, z)
|
||||
if let Some((h, _)) = self.fluid_height_of(x, y, z, None)
|
||||
&& p[1] <= f64::from(y) + f64::from(h)
|
||||
{
|
||||
water = Some(([p[0], f64::from(y) + f64::from(h), p[2]], t));
|
||||
fluid = Some(([p[0], f64::from(y) + f64::from(h), p[2]], t));
|
||||
break;
|
||||
}
|
||||
t += 0.05;
|
||||
}
|
||||
let hit = match (block, water) {
|
||||
let hit = match (block, fluid) {
|
||||
(Some(b), Some(w)) => {
|
||||
if w.1 < b.1 {
|
||||
w.0
|
||||
@@ -682,15 +744,23 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
self.vehicle_attack_sounds();
|
||||
self.hurt_boat(i, damage, self.creative);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// `VehicleEntity.hurtServer`: wobble, add the damage and break past 40
|
||||
/// (`creative`: a creative player's hit removes it without drops).
|
||||
/// True when the boat was removed.
|
||||
pub(super) fn hurt_boat(&mut self, i: usize, damage: f32, creative: bool) -> bool {
|
||||
let b = &mut self.boats[i];
|
||||
b.hurt_dir = -b.hurt_dir;
|
||||
b.hurt_time = 10;
|
||||
b.damage += damage * 10.0;
|
||||
let destroyed = self.creative || b.damage > 40.0;
|
||||
let destroyed = creative || b.damage > 40.0;
|
||||
if destroyed {
|
||||
let b = self.boats.remove(i);
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
if !self.creative && self.ui.rules.bool("entity_drops") {
|
||||
if !creative && self.ui.rules.bool("entity_drops") {
|
||||
// ChestBoat.destroy: chestVehicleDestroyed spills the contents.
|
||||
for stack in b.items.iter().flatten() {
|
||||
self.spawn_item(stack.clone(), at, super::dispenser::new_item_motion(), 10);
|
||||
@@ -709,8 +779,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
self.swing_arm();
|
||||
true
|
||||
destroyed
|
||||
}
|
||||
/// AbstractBoatRenderer.
|
||||
pub(super) fn boat_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
//! pixels, 1024 characters, 100 pages), signing it into a written book,
|
||||
//! reading written books, and lecterns (placing a book, turning its pages
|
||||
//! with a redstone pulse, the comparator output and taking the book).
|
||||
use super::Game;
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
containers::{BlockEntity, Lectern},
|
||||
first_person::Hand,
|
||||
hud::{HudBatch, rgba},
|
||||
item_data::WrittenBook,
|
||||
items::ItemStack,
|
||||
@@ -14,6 +15,7 @@ use horizon_client::{
|
||||
ui,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
use horizon_world::level::flags as level_flags;
|
||||
use std::time::Instant;
|
||||
use winit::{
|
||||
@@ -48,9 +50,32 @@ impl Game<'_> {
|
||||
fn book_lines(&self, page: &str) -> Vec<Text> {
|
||||
self.gui.atlas.font.split(&Text::literal(page), 114.0)
|
||||
}
|
||||
/// `WritableBookItem.use` / `WrittenBookItem.use`.
|
||||
/// `WritableBookItem.use` / `WrittenBookItem.use` (`Player.openItemGui`).
|
||||
/// A server's player writes in its own client's editor
|
||||
/// (`LocalPlayer.openItemGui`), and reads on its own client once told to
|
||||
/// (`ServerPlayer.openItemGui`'s ClientboundOpenBookPacket).
|
||||
pub(super) fn use_book(&mut self, stack: &ItemStack) -> bool {
|
||||
let screen = match &*stack.item {
|
||||
let written = match &*stack.item {
|
||||
"minecraft:writable_book" => false,
|
||||
"minecraft:written_book" => true,
|
||||
_ => return false,
|
||||
};
|
||||
if self.lan_acting.is_some() {
|
||||
if written {
|
||||
let hand = i32::from(self.hand == Hand::Off);
|
||||
self.guest_packets
|
||||
.push(C::OpenBook(play::OpenBook { hand }));
|
||||
}
|
||||
} else if let Some(screen) = self.book_screen(stack) {
|
||||
self.open_book(screen);
|
||||
}
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// The screen a book in hand opens: `BookEditScreen` for a book and
|
||||
/// quill, `BookViewScreen` for a written book.
|
||||
fn book_screen(&self, stack: &ItemStack) -> Option<BookScreen> {
|
||||
Some(match &*stack.item {
|
||||
"minecraft:writable_book" => {
|
||||
let mut pages = stack
|
||||
.data()
|
||||
@@ -77,12 +102,31 @@ impl Game<'_> {
|
||||
page: 0,
|
||||
lectern: None,
|
||||
},
|
||||
_ => return false,
|
||||
};
|
||||
self.open_book(screen);
|
||||
self.swing_arm();
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// `MultiPlayerGameMode.useItem`'s own use of a book and quill on a
|
||||
/// server's client: its editor opens here at once.
|
||||
pub(super) fn remote_use_book(&mut self, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:writable_book" {
|
||||
return false;
|
||||
}
|
||||
if let Some(screen) = self.book_screen(stack) {
|
||||
self.open_book(screen);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `ClientPacketListener.handleOpenBook`: the written book in that hand.
|
||||
pub(super) fn remote_open_book(&mut self, hand: i32) {
|
||||
let hand = if hand == 1 { Hand::Off } else { Hand::Main };
|
||||
let screen = self.with_hand(hand, |g| {
|
||||
let stack = g.held().filter(|s| &*s.item == "minecraft:written_book")?;
|
||||
g.book_screen(&stack.clone())
|
||||
});
|
||||
if let Some(screen) = screen {
|
||||
self.open_book(screen);
|
||||
}
|
||||
}
|
||||
fn open_book(&mut self, screen: BookScreen) {
|
||||
self.book = Some(screen);
|
||||
self.keys.clear();
|
||||
@@ -104,7 +148,9 @@ impl Game<'_> {
|
||||
self.book = None;
|
||||
self.capture(true);
|
||||
}
|
||||
/// `BookEditScreen.saveChanges`: trailing empty pages dropped.
|
||||
/// `BookEditScreen.saveChanges` and `BookSignScreen`'s Sign and Close:
|
||||
/// trailing empty pages dropped, the book in hand updated here and, on a
|
||||
/// server's client, sent (ServerboundEditBookPacket).
|
||||
fn save_book(&mut self, sign: bool) {
|
||||
let Some(BookScreen::Edit {
|
||||
slot, pages, title, ..
|
||||
@@ -116,29 +162,59 @@ impl Game<'_> {
|
||||
while pages.len() > 1 && pages.last().is_some_and(String::is_empty) {
|
||||
pages.pop();
|
||||
}
|
||||
if let Some(stack) = self.stack_in_mut(slot).as_mut()
|
||||
&& &*stack.item == "minecraft:writable_book"
|
||||
{
|
||||
if sign {
|
||||
let mut written = ItemStack::new("minecraft:written_book", 1);
|
||||
written.data_mut().written = Some(WrittenBook {
|
||||
title: title.trim().to_owned(),
|
||||
author: super::chat::PLAYER_NAME.to_owned(),
|
||||
generation: 0,
|
||||
pages,
|
||||
});
|
||||
*stack = written;
|
||||
} else {
|
||||
stack.data_mut().writable_pages = if pages.iter().all(String::is_empty) {
|
||||
Vec::new()
|
||||
} else {
|
||||
pages
|
||||
};
|
||||
stack.tidy();
|
||||
}
|
||||
let title = sign.then(|| title.trim().to_owned());
|
||||
if self.remote.is_some() {
|
||||
self.send(S::EditBook(play::EditBook {
|
||||
slot: slot as i32,
|
||||
pages: pages.clone(),
|
||||
title: title.clone(),
|
||||
}));
|
||||
}
|
||||
self.write_book(slot, pages, title);
|
||||
self.capture(true);
|
||||
}
|
||||
/// The book and quill at `slot` takes the pages, or with a title becomes
|
||||
/// the written book its writer signed.
|
||||
fn write_book(&mut self, slot: usize, pages: Vec<String>, title: Option<String>) {
|
||||
let author = self
|
||||
.lan_acting_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| super::chat::PLAYER_NAME.to_owned());
|
||||
let Some(stack) = self
|
||||
.stack_in_mut(slot)
|
||||
.as_mut()
|
||||
.filter(|s| &*s.item == "minecraft:writable_book")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Some(title) = title {
|
||||
let mut written = ItemStack::new("minecraft:written_book", 1);
|
||||
written.data_mut().written = Some(WrittenBook {
|
||||
title,
|
||||
author,
|
||||
generation: 0,
|
||||
pages,
|
||||
});
|
||||
*stack = written;
|
||||
} else {
|
||||
stack.data_mut().writable_pages = if pages.iter().all(String::is_empty) {
|
||||
Vec::new()
|
||||
} else {
|
||||
pages
|
||||
};
|
||||
stack.tidy();
|
||||
}
|
||||
}
|
||||
/// `ServerGamePacketListenerImpl.handleEditBook`: a guest's book in a
|
||||
/// hotbar slot or its off hand (`updateBookContents`, `signBook`).
|
||||
pub(super) fn lan_edit_book(&mut self, host: &mut LanHost, conn: u32, p: play::EditBook) {
|
||||
if !((0..9).contains(&p.slot) || p.slot == super::shield::OFFHAND_SLOT as i32) {
|
||||
return;
|
||||
}
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.write_book(p.slot as usize, p.pages, p.title)
|
||||
});
|
||||
}
|
||||
pub(super) fn book_key(&mut self, event: &KeyEvent) {
|
||||
if event.state != ElementState::Pressed {
|
||||
return;
|
||||
@@ -519,7 +595,26 @@ impl Game<'_> {
|
||||
Some(key) => self.lectern_button(key, 3),
|
||||
None => return Err("not a lectern".into()),
|
||||
},
|
||||
Some("done") => self.close_book(),
|
||||
// The book and quill's buttons: Sign, then Sign and Close; Done
|
||||
// (which saves), then Cancel.
|
||||
Some("sign") => match self.book.as_mut() {
|
||||
Some(BookScreen::Edit {
|
||||
signing: true,
|
||||
title,
|
||||
..
|
||||
}) if !title.trim().is_empty() => self.save_book(true),
|
||||
Some(BookScreen::Edit { signing, .. }) => *signing = true,
|
||||
_ => return Err("not a book and quill".into()),
|
||||
},
|
||||
Some("done") => match self.book.as_mut() {
|
||||
Some(BookScreen::Edit { signing: true, .. }) => {
|
||||
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
|
||||
*signing = false;
|
||||
}
|
||||
}
|
||||
Some(BookScreen::Edit { .. }) => self.save_book(false),
|
||||
_ => self.close_book(),
|
||||
},
|
||||
Some(other) => return Err(format!("unknown book button {other}").into()),
|
||||
}
|
||||
Ok(match &self.book {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Chat, the command registries and the per-world state behind commands
|
||||
//! (game rules, the world clock, difficulty), bound to the game.
|
||||
use super::{Dim, Game, Screen};
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
chat::{Chat, ChatAction, ChatScreen, Commands, SYSTEM_INDICATOR},
|
||||
command::Registries,
|
||||
@@ -450,11 +450,11 @@ impl Game<'_> {
|
||||
// Connected to a server (single player's own included), the chat on
|
||||
// screen is the connection's: the game's own messages join it.
|
||||
if self.remote.is_some() {
|
||||
self.chat_line(text.plain());
|
||||
self.chat_line(text);
|
||||
return;
|
||||
}
|
||||
let focused = self.screen == Screen::Chat;
|
||||
let heard = self.mods.mods.is_running().then(|| text.plain());
|
||||
let heard = self.mods.running().then(|| text.plain());
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
text,
|
||||
@@ -583,7 +583,7 @@ impl Game<'_> {
|
||||
&[Text::literal(PLAYER_NAME), Text::literal(message)],
|
||||
);
|
||||
let focused = self.screen == Screen::Chat;
|
||||
let heard = self.mods.mods.is_running().then(|| line.plain());
|
||||
let heard = self.mods.running().then(|| line.plain());
|
||||
self.ui
|
||||
.chat
|
||||
.add(&self.gui.atlas, line, None, self.ticks, focused);
|
||||
@@ -681,14 +681,21 @@ impl Game<'_> {
|
||||
let draws = screen.draw(&m, mouse, blink, background);
|
||||
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &draws);
|
||||
self.ui.chat_screen = Some(screen);
|
||||
// The hovered message part's tooltip, one row per line of its text.
|
||||
if let Some(hover) = self.ui.chat.hover_at(
|
||||
&self.gui.atlas.font,
|
||||
batch.height as i32,
|
||||
f64::from(self.cursor.0),
|
||||
f64::from(self.cursor.1),
|
||||
) {
|
||||
let lines = hover_rows(&hover);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
}
|
||||
/// Advance the overworld clock (ServerClockManager.tick) when
|
||||
/// `advance_time` allows it.
|
||||
pub(super) fn advance_clock(&mut self) {
|
||||
if !self.ui.rules.bool("advance_time")
|
||||
|| self.ui.clock.paused
|
||||
|| self.dim == Dim::Nether && false
|
||||
{
|
||||
if !self.ui.rules.bool("advance_time") || self.ui.clock.paused {
|
||||
return;
|
||||
}
|
||||
let c = &mut self.ui.clock;
|
||||
@@ -949,3 +956,26 @@ fn write_difficulty(world: &std::path::Path, difficulty: u8) -> horizon_client::
|
||||
fn owner_permission(allow_commands: bool) -> u8 {
|
||||
if allow_commands { 4 } else { 0 }
|
||||
}
|
||||
|
||||
/// A hover text's rows for the tooltip: split at line breaks, each in the
|
||||
/// colour of its first styled character (white without one).
|
||||
fn hover_rows(hover: &horizon_client::text::Text) -> Vec<(String, u32)> {
|
||||
let mut rows = vec![(String::new(), None)];
|
||||
for (c, style) in hover.resolve_legacy().chars() {
|
||||
if c == '\n' {
|
||||
rows.push((String::new(), None));
|
||||
continue;
|
||||
}
|
||||
let row = rows.last_mut().expect("one row");
|
||||
if row.0.is_empty() {
|
||||
row.1 = style.color;
|
||||
}
|
||||
row.0.push(c);
|
||||
}
|
||||
rows.into_iter()
|
||||
.map(|(text, color)| {
|
||||
let [_, r, g, b] = color.unwrap_or(0xFF_FFFF).to_be_bytes();
|
||||
(text, horizon_client::hud::rgba(r, g, b, 255))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1039,6 +1039,11 @@ impl Game<'_> {
|
||||
.play("minecraft:entity.item.pickup", Some(p), 0.2, pitch);
|
||||
}
|
||||
}
|
||||
// containerMenu.broadcastChanges: InventoryChangeTrigger
|
||||
// (and its announcement) comes before the command's message.
|
||||
if g.player.is_some() {
|
||||
g.check_inventory_trigger();
|
||||
}
|
||||
});
|
||||
}
|
||||
// The prototype stack: one item.
|
||||
|
||||
@@ -128,7 +128,7 @@ impl Game<'_> {
|
||||
if self.state_in_tag(avoid, ground) {
|
||||
return false;
|
||||
}
|
||||
let entities = self.happy_ghast_player_collisions();
|
||||
let entities = self.player_entity_collisions();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -232,6 +232,13 @@ impl Game<'_> {
|
||||
"key" => {
|
||||
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
|
||||
self.require_world()?;
|
||||
// The sign editor takes raw presses (Enter, arrows, Backspace, Escape).
|
||||
if self.sign_edit.is_some() {
|
||||
if arg(2)? != "up" {
|
||||
self.sign_input(Some(key), None);
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
// A held edit modifier (`key super down`) and A/C/V/X: the
|
||||
// open book's Select All, Copy, Paste or Cut.
|
||||
if self.book.is_some() && arg(2)? != "up" {
|
||||
@@ -524,6 +531,10 @@ impl Game<'_> {
|
||||
self.book_type(text);
|
||||
return ok();
|
||||
}
|
||||
if self.sign_edit.is_some() {
|
||||
self.sign_input(None, Some(text));
|
||||
return ok();
|
||||
}
|
||||
match self.screen {
|
||||
Screen::Chat => self.chat_type(text),
|
||||
Screen::CommandBlock => self.command_block_type(text),
|
||||
@@ -549,7 +560,7 @@ impl Game<'_> {
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"type needs the chat screen (key t tap), the language search, the creative search, the anvil or World Options".into(),
|
||||
"type needs the chat screen (key t tap), a sign or book, the language search, the creative search, the anvil or World Options".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -578,7 +589,6 @@ impl Game<'_> {
|
||||
"credits" => Page::Credits,
|
||||
"horizon" => Page::Horizon,
|
||||
"agent" => Page::Agent,
|
||||
"shader-packs" => Page::ShaderPacks,
|
||||
other => return Err(format!("unknown options page {other}").into()),
|
||||
};
|
||||
if self.screen == Screen::Playing && self.title.is_none() {
|
||||
@@ -977,6 +987,7 @@ impl Game<'_> {
|
||||
"flashes": self.lightning_seen[1], "flash": self.flash});
|
||||
// Primed TNT, falling blocks, projectiles, tridents and end crystals.
|
||||
status["projectiles"] = json!(self.projectiles_status());
|
||||
status["maps"] = json!(self.maps_status());
|
||||
// The particles made so far, by type.
|
||||
status["particles"] = json!(self.particles.made());
|
||||
// The world border this side knows of.
|
||||
|
||||
@@ -47,6 +47,8 @@ pub(super) struct Creatures {
|
||||
pub pearl_cooldown: u32,
|
||||
/// The wind charge's use cooldown (ticks).
|
||||
pub wind_cooldown: u32,
|
||||
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
|
||||
pub wind_bursts: Vec<super::wind::Burst>,
|
||||
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
|
||||
pub elder_apparition: i32,
|
||||
/// The wandering trader spawner's clock.
|
||||
@@ -1005,6 +1007,11 @@ impl Game<'_> {
|
||||
MobEvent::SetBlock { pos, state } => {
|
||||
self.world.set(pos[0], pos[1], pos[2], state)?
|
||||
}
|
||||
MobEvent::SetDoorOpen { pos, open } => {
|
||||
let pos = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open));
|
||||
self.process_level_events()?;
|
||||
}
|
||||
MobEvent::SetBlockName { pos, name, effect } => {
|
||||
if effect {
|
||||
let old = self.world.block(pos[0], pos[1], pos[2]);
|
||||
@@ -1489,12 +1496,16 @@ impl Game<'_> {
|
||||
o.hurt
|
||||
}
|
||||
{
|
||||
let (xd, zd) = (from[0] - player.position.x, from[2] - player.position.z);
|
||||
self.knock_back_player(f64::from(0.4_f32), xd, zd);
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& let Some(r) = p.hurt_log.last_mut()
|
||||
{
|
||||
r.knockback = Some([p.velocity.x, p.velocity.y, p.velocity.z]);
|
||||
// LivingEntity.hurtServer's dealDefaultKnockback: not inside the
|
||||
// hurt cooldown (a stronger hit there only adds its difference).
|
||||
if o.knockback {
|
||||
let (xd, zd) = (from[0] - player.position.x, from[2] - player.position.z);
|
||||
self.knock_back_player(f64::from(0.4_f32), xd, zd);
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& let Some(r) = p.hurt_log.last_mut()
|
||||
{
|
||||
r.knockback = Some([p.velocity.x, p.velocity.y, p.velocity.z]);
|
||||
}
|
||||
}
|
||||
self.hurt_armor(amount);
|
||||
self.thorns(from);
|
||||
@@ -1778,7 +1789,7 @@ impl Game<'_> {
|
||||
_ => self.projectile_hit(&p, hit)?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
self.flush_wind_bursts()
|
||||
}
|
||||
/// `projectile_hit` for a projectile the guest `conn` shot, run as them;
|
||||
/// as the host's when they have left.
|
||||
@@ -2413,13 +2424,14 @@ impl Game<'_> {
|
||||
self.spawn_item(rest, self.eye_position().unwrap_or([0.0; 3]), [0.0; 3], 40);
|
||||
}
|
||||
} else {
|
||||
self.inventory[self.selected] = Some(filled);
|
||||
*self.held_mut() = Some(filled);
|
||||
}
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
let creative = self.creative;
|
||||
let selected = self.selected;
|
||||
// The hand's slot (Player.interactOn passes the hand).
|
||||
let selected = self.held_slot();
|
||||
if self.interact_zombie_villager(index, &item) {
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -2451,10 +2463,10 @@ impl Game<'_> {
|
||||
// PiglinAi.mobInteract: hand over something gold to admire.
|
||||
if self.mobs[index].alive() && self.give_piglin(index, &item) {
|
||||
self.swing_arm();
|
||||
if !creative && let Some(stack) = self.inventory[selected].as_mut() {
|
||||
if !creative && let Some(stack) = self.stack_in_mut(selected).as_mut() {
|
||||
stack.count -= 1;
|
||||
if stack.count == 0 {
|
||||
self.inventory[selected] = None;
|
||||
*self.stack_in_mut(selected) = None;
|
||||
}
|
||||
}
|
||||
return Ok(true);
|
||||
@@ -2765,11 +2777,11 @@ impl Game<'_> {
|
||||
}
|
||||
if consume
|
||||
&& !creative
|
||||
&& let Some(stack) = self.inventory[selected].as_mut()
|
||||
&& let Some(stack) = self.stack_in_mut(selected).as_mut()
|
||||
{
|
||||
stack.count -= 1;
|
||||
if stack.count == 0 {
|
||||
self.inventory[selected] = None;
|
||||
*self.stack_in_mut(selected) = None;
|
||||
}
|
||||
}
|
||||
if let Some(g) = give {
|
||||
@@ -2779,8 +2791,8 @@ impl Game<'_> {
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
if self.inventory[selected].is_none() {
|
||||
self.inventory[selected] = Some(stack);
|
||||
if self.stack_in(selected).is_none() {
|
||||
*self.stack_in_mut(selected) = Some(stack);
|
||||
} else if let Some(rest) = self.give(stack) {
|
||||
let at = self.eye_position().unwrap_or(center);
|
||||
self.spawn_item(rest, at, [0.0, 0.0, 0.0], 40);
|
||||
@@ -2812,13 +2824,17 @@ impl Game<'_> {
|
||||
if self.creative {
|
||||
return;
|
||||
}
|
||||
if let Some(stack) = self.inventory[self.selected].as_mut()
|
||||
&& let Some(info) = self.res.items().get(&stack.item)
|
||||
&& info.max_damage > 0
|
||||
let slot = self.held_slot();
|
||||
let max_damage = self
|
||||
.held()
|
||||
.and_then(|s| self.res.items().get(&s.item))
|
||||
.map_or(0, |i| i.max_damage);
|
||||
if max_damage > 0
|
||||
&& let Some(stack) = self.stack_in_mut(slot).as_mut()
|
||||
{
|
||||
stack.damage += 1;
|
||||
if stack.damage >= info.max_damage {
|
||||
self.inventory[self.selected] = None;
|
||||
if stack.damage >= max_damage {
|
||||
*self.stack_in_mut(slot) = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ impl DebugHost for Host<'_, '_> {
|
||||
// the menu as it always has (key_pressed).
|
||||
let g = &mut *self.game;
|
||||
if without_menu && g.screen == Screen::Playing {
|
||||
g.pause_menu = !(g.has_singleplayer_server() && !g.is_published());
|
||||
g.pause_menu = !g.has_singleplayer_server() || g.is_published();
|
||||
g.screen = Screen::Paused;
|
||||
g.capture(false);
|
||||
g.mining = None;
|
||||
@@ -593,7 +593,6 @@ impl Game<'_> {
|
||||
DebugAction::ReloadResourcePacks => {
|
||||
self.debug_translated("debug.reload_resourcepacks.message");
|
||||
self.reload_packs();
|
||||
self.load_shader_pack();
|
||||
true
|
||||
}
|
||||
DebugAction::CopyLocation if player => {
|
||||
|
||||
@@ -121,11 +121,13 @@ impl Game<'_> {
|
||||
let bonus = if knockback_attack { 0.5 } else { 0.0 };
|
||||
(enchanted / 2.0 + bonus, knockback_attack)
|
||||
}
|
||||
/// `causeExtraKnockback`'s direction, `(sin yRot, -cos yRot)`: the target
|
||||
/// flies where the attacker looks, not away from it.
|
||||
/// `causeExtraKnockback`'s direction, `(sin yRot, -cos yRot)` from Mth's
|
||||
/// sine table: the target flies where the attacker looks, not away from
|
||||
/// it (straight along an axis when the attacker faces one).
|
||||
pub(super) fn extra_knockback_direction(&self) -> (f64, f64) {
|
||||
let yaw = self.yaw.to_radians();
|
||||
(f64::from(yaw.sin()), -f64::from(yaw.cos()))
|
||||
use horizon_core::geometry::trig;
|
||||
let yaw = f64::from(self.yaw * trig::DEG_TO_RAD);
|
||||
(f64::from(trig::sin(yaw)), -f64::from(trig::cos(yaw)))
|
||||
}
|
||||
/// The attacker's side of `causeExtraKnockback` as the attacking client
|
||||
/// sees it: the server's player stops sprinting, and its flag reaches the
|
||||
|
||||
@@ -237,7 +237,7 @@ impl Game<'_> {
|
||||
/// the caller). The top left corner gives way to the debug screen.
|
||||
pub(super) fn draw_extras(&mut self, batch: &mut HudBatch) {
|
||||
let e = &self.settings.extras;
|
||||
let mod_panels = self.mods.mods.panels().next().is_some();
|
||||
let mod_panels = self.mods.has_panels();
|
||||
if !e.fps && !e.keystrokes && !e.minimap && !mod_panels {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,12 +69,18 @@ impl Game<'_> {
|
||||
let lo = Vec3::new(f.pos[0] - half, f.pos[1], f.pos[2] - half);
|
||||
let hi = Vec3::new(f.pos[0] + half, f.pos[1] + 0.8, f.pos[2] + half);
|
||||
let b = horizon_core::geometry::Aabb::new(lo, hi);
|
||||
if let Some(p) = self.player_view()
|
||||
&& p.alive
|
||||
&& p.bounds.intersects(b)
|
||||
{
|
||||
self.mob_hits_player_kind(6.0, f.pos, "minecraft:indirect_magic");
|
||||
}
|
||||
// Every player in the box, the server's players too.
|
||||
let pos = f.pos;
|
||||
let bite = move |g: &mut Self| {
|
||||
if let Some(p) = g.player_view()
|
||||
&& p.alive
|
||||
&& p.bounds.intersects(b)
|
||||
{
|
||||
g.mob_hits_player_kind(6.0, pos, "minecraft:indirect_magic");
|
||||
}
|
||||
};
|
||||
bite(self);
|
||||
self.each_guest_hit(bite);
|
||||
for m in &mut self.mobs {
|
||||
let ally = matches!(
|
||||
m.species(),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! bobbing, lure and bite timers, open water, hooking mobs, retrieving the
|
||||
//! `gameplay/fishing` loot) and `FishingHookRenderer` (bobber and line).
|
||||
use super::{Game, view_vector};
|
||||
use horizon_client::lan::{entity::States, hub::Hub};
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
collisions::SceneCollisions,
|
||||
@@ -15,7 +16,10 @@ use horizon_core::{
|
||||
geometry::{Aabb, Vec3, trig},
|
||||
random::RandomSource,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use horizon_protocol::entity_data::{Value, serializer};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use super::lan_actions::GuestPlay;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum State {
|
||||
@@ -24,6 +28,8 @@ enum State {
|
||||
Hooked(u32),
|
||||
}
|
||||
pub(super) struct Hook {
|
||||
/// Its entity id, which a server's players track it under.
|
||||
id: u32,
|
||||
position: Vec3,
|
||||
previous: Vec3,
|
||||
velocity: Vec3,
|
||||
@@ -114,7 +120,9 @@ impl Game<'_> {
|
||||
);
|
||||
// A spawned hook's start position is its feet: centre the box on the eye.
|
||||
let start = Vec3::new(start.x, start.y - SIZE / 2.0, start.z);
|
||||
self.next_entity += 1;
|
||||
self.hook = Some(Hook {
|
||||
id: self.next_entity,
|
||||
position: start,
|
||||
previous: start,
|
||||
velocity: v,
|
||||
@@ -562,12 +570,17 @@ impl Game<'_> {
|
||||
lerp(h.previous.z, h.position.z),
|
||||
))
|
||||
}
|
||||
/// FishingHookRenderer: the bobber, a camera-facing half-block quad
|
||||
/// centred on the hook.
|
||||
/// FishingHookRenderer: the bobbers (this player's and the others' a
|
||||
/// server tracks), camera-facing half-block quads centred on the hooks.
|
||||
pub(super) fn fishing_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
let Some(p) = self.hook_position(alpha) else {
|
||||
let hooks: Vec<glam::DVec3> = self
|
||||
.hook_position(alpha)
|
||||
.into_iter()
|
||||
.chain(self.remote_hook_ends(alpha).into_iter().map(|(h, _)| h))
|
||||
.collect();
|
||||
if hooks.is_empty() {
|
||||
return;
|
||||
};
|
||||
}
|
||||
let Some(r) = self
|
||||
.gui
|
||||
.atlas
|
||||
@@ -577,56 +590,57 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let light = self
|
||||
.world
|
||||
.light(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
let forward = glam::DVec3::from_array(view_vector(self.yaw, self.pitch));
|
||||
let right = forward.cross(glam::DVec3::Y).normalize_or_zero();
|
||||
let up = right.cross(forward).normalize_or_zero();
|
||||
let s = 0.25;
|
||||
let corners = [
|
||||
p - right * s - up * s,
|
||||
p + right * s - up * s,
|
||||
p + right * s + up * s,
|
||||
p - right * s + up * s,
|
||||
];
|
||||
let uv = [[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]];
|
||||
for (v, uv) in corners.iter().zip(uv) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: (*v - camera).as_vec3().to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: packed,
|
||||
});
|
||||
for p in hooks {
|
||||
let light =
|
||||
self.world
|
||||
.light(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
let corners = [
|
||||
p - right * s - up * s,
|
||||
p + right * s - up * s,
|
||||
p + right * s + up * s,
|
||||
p - right * s + up * s,
|
||||
];
|
||||
for (v, uv) in corners.iter().zip(uv) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: (*v - camera).as_vec3().to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: packed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
/// FishingHookRenderer: the line, 16 black `lines` segments sagging from
|
||||
/// a quarter block above the hook to the rod in the player's hand,
|
||||
/// FishingHookRenderer: each line, 16 black `lines` segments sagging
|
||||
/// from a quarter block above the hook to the rod in its owner's hand,
|
||||
/// `width` pixels wide (`Window.getAppropriateLineWidth`).
|
||||
pub(super) fn fishing_line(&self, alpha: f64, size: [u32; 2]) -> Vec<Line> {
|
||||
let (Some(hook), Some(hand)) = (
|
||||
self.hook_position(alpha),
|
||||
self.rod_hand_position(alpha, size),
|
||||
) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let offset = (hand - (hook + glam::DVec3::new(0.0, 0.25, 0.0))).as_vec3();
|
||||
let point = |a: f32| {
|
||||
let y = offset.y * (a * a + a) * 0.5 + 0.25;
|
||||
hook + glam::Vec3::new(offset.x * a, y, offset.z * a).as_dvec3()
|
||||
};
|
||||
let own = self
|
||||
.hook_position(alpha)
|
||||
.zip(self.rod_hand_position(alpha, size));
|
||||
let width = horizon_client::gfx::outline_width(size[0]);
|
||||
(0..16)
|
||||
.map(|i| Line {
|
||||
let mut lines = Vec::new();
|
||||
for (hook, hand) in own.into_iter().chain(self.remote_hook_ends(alpha)) {
|
||||
let offset = (hand - (hook + glam::DVec3::new(0.0, 0.25, 0.0))).as_vec3();
|
||||
let point = |a: f32| {
|
||||
let y = offset.y * (a * a + a) * 0.5 + 0.25;
|
||||
hook + glam::Vec3::new(offset.x * a, y, offset.z * a).as_dvec3()
|
||||
};
|
||||
lines.extend((0..16).map(|i| Line {
|
||||
start: point(i as f32 / 16.0).to_array(),
|
||||
end: point((i + 1) as f32 / 16.0).to_array(),
|
||||
color: 0xFF00_0000,
|
||||
width,
|
||||
})
|
||||
.collect()
|
||||
}));
|
||||
}
|
||||
lines
|
||||
}
|
||||
/// `FishingHookRenderer.getPlayerHandPos`: on the near plane in first
|
||||
/// person, beside the body otherwise.
|
||||
@@ -694,6 +708,20 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// The hooks drawn here, with their owners' ids (0 for this player's own).
|
||||
pub(super) fn hooks_status(&self) -> Vec<serde_json::Value> {
|
||||
let at = |p: Vec3| [p.x, p.y, p.z];
|
||||
let own = self.hook.iter().map(|h| (h.id, 0, h.position));
|
||||
let others = self
|
||||
.remote_hooks
|
||||
.iter()
|
||||
.map(|h| (h.id, h.owner, h.position));
|
||||
own.chain(others)
|
||||
.map(|(id, owner, p)| {
|
||||
serde_json::json!({"id": id, "kind": "minecraft:fishing_bobber", "owner": owner, "position": at(p)})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The hook for `horizon-ctl status`.
|
||||
pub(super) fn hook_status(&self) -> Option<serde_json::Value> {
|
||||
let h = self.hook.as_ref()?;
|
||||
@@ -706,3 +734,175 @@ impl Game<'_> {
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// A hook a server tracks to this client (`FishingHook.recreateFromPacket`)
|
||||
/// that another player cast: drawn where the server puts it, its line
|
||||
/// running to that player's hand.
|
||||
pub(super) struct RemoteHook {
|
||||
id: u32,
|
||||
owner: u32,
|
||||
position: Vec3,
|
||||
previous: Vec3,
|
||||
}
|
||||
|
||||
impl Hook {
|
||||
/// This client's own hook as its server tracks it: drawn, never ticked here.
|
||||
fn remote(id: u32, at: Vec3) -> Self {
|
||||
Self {
|
||||
id,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
state: State::Flying,
|
||||
life: 0,
|
||||
on_ground: false,
|
||||
nibble: 0,
|
||||
time_until_lured: 0,
|
||||
time_until_hooked: 0,
|
||||
fish_angle: 0.0,
|
||||
out_of_water: 0,
|
||||
open_water: true,
|
||||
luck: 0,
|
||||
lure: 0,
|
||||
}
|
||||
}
|
||||
/// `FishingHook`'s entity for the players tracking it: the owner's id in
|
||||
/// its add packet, DATA_HOOKED_ENTITY (the hooked id plus one) and
|
||||
/// DATA_BITING when they differ from their defaults.
|
||||
fn track(&self, owner: i32, out: &mut States) {
|
||||
let p = self.position;
|
||||
let e = out.add(
|
||||
self.id as i32,
|
||||
super::lan::entity_uuid(self.id),
|
||||
"minecraft:fishing_bobber",
|
||||
);
|
||||
e.position = [p.x, p.y, p.z];
|
||||
e.velocity = [self.velocity.x, self.velocity.y, self.velocity.z];
|
||||
e.on_ground = self.on_ground;
|
||||
e.data = owner;
|
||||
if let State::Hooked(id) = self.state {
|
||||
e.meta.push((8, serializer::INT, Value::Int(id as i32 + 1)));
|
||||
}
|
||||
if self.nibble > 0 {
|
||||
e.meta.push((9, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The hooks guests track: the host's and each guest's in the level.
|
||||
pub(super) fn lan_hook_states(
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
play: &HashMap<u32, GuestPlay>,
|
||||
hub: &Hub,
|
||||
out: &mut States,
|
||||
) {
|
||||
if let (Some(h), Some(owner)) = (&self.hook, host_entity) {
|
||||
h.track(owner, out);
|
||||
}
|
||||
for g in hub.guests.iter().filter(|g| hub.here(g)) {
|
||||
if let Some(h) = play.get(&g.conn).and_then(|p| p.hook.as_ref()) {
|
||||
h.track(g.entity_id, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `handleAddEntity` for a fishing bobber: this player's own becomes its
|
||||
/// hook (`Player.fishing`), another player's is drawn on its line to them.
|
||||
pub(super) fn spawn_remote_hook(&mut self, id: u32, at: Vec3, owner: i32) {
|
||||
let own = self.remote.as_ref().map(|r| r.entity_id);
|
||||
if own == Some(owner) {
|
||||
self.hook = Some(Hook::remote(id, at));
|
||||
} else {
|
||||
self.remote_hooks.push(RemoteHook {
|
||||
id,
|
||||
owner: owner as u32,
|
||||
position: at,
|
||||
previous: at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// One tick of interpolation for the hooks a server tracks.
|
||||
pub(super) fn step_remote_hooks(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
|
||||
if self.remote.is_none() {
|
||||
return;
|
||||
}
|
||||
if let Some(h) = self.hook.as_mut() {
|
||||
h.previous = h.position;
|
||||
h.position = step(h.id, h.position);
|
||||
}
|
||||
for h in &mut self.remote_hooks {
|
||||
h.previous = h.position;
|
||||
h.position = step(h.id, h.position);
|
||||
}
|
||||
}
|
||||
|
||||
/// `FishingHook.onSyncedDataUpdated`: this player's hook is biting
|
||||
/// (DATA_BITING), which `status` shows as a nibble.
|
||||
pub(super) fn remote_hook_data(&mut self, id: u32, values: &[(u8, i32, Value)]) {
|
||||
let Some(h) = self
|
||||
.hook
|
||||
.as_mut()
|
||||
.filter(|h| h.id == id && self.remote.is_some())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for (index, _, value) in values {
|
||||
if let (9, Value::Bool(biting)) = (index, value) {
|
||||
h.nibble = i32::from(*biting);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_hook(&mut self, id: u32) {
|
||||
if self.remote.is_some() && self.hook.as_ref().is_some_and(|h| h.id == id) {
|
||||
self.hook = None;
|
||||
}
|
||||
self.remote_hooks.retain(|h| h.id != id);
|
||||
}
|
||||
|
||||
pub(super) fn clear_remote_hooks(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
self.hook = None;
|
||||
}
|
||||
self.remote_hooks.clear();
|
||||
}
|
||||
|
||||
/// Other players' hooks: the bobber and its line to the owner's hand
|
||||
/// (`FishingHookRenderer.getPlayerHandPos` in third person), as
|
||||
/// (hook, hand) this frame.
|
||||
fn remote_hook_ends(&self, alpha: f64) -> Vec<(glam::DVec3, glam::DVec3)> {
|
||||
let lerp = |a: Vec3, b: Vec3| {
|
||||
glam::DVec3::new(
|
||||
a.x + (b.x - a.x) * alpha,
|
||||
a.y + (b.y - a.y) * alpha,
|
||||
a.z + (b.z - a.z) * alpha,
|
||||
)
|
||||
};
|
||||
self.remote_hooks
|
||||
.iter()
|
||||
.filter_map(|h| {
|
||||
let owner = self.mobs.iter().find(|m| m.id == h.owner)?;
|
||||
let feet = lerp(owner.previous, owner.position);
|
||||
let body = horizon_client::camera::lerp(
|
||||
alpha as f32,
|
||||
owner.previous_body_yaw,
|
||||
owner.body_yaw,
|
||||
);
|
||||
let angle = f64::from(body * (std::f64::consts::PI / 180.0) as f32);
|
||||
let (sin, cos) = (f64::from(trig::sin(angle)), f64::from(trig::cos(angle)));
|
||||
// The right arm; a crouching owner's eyes are lower.
|
||||
let eye = owner.eye_y() - owner.position.y;
|
||||
let hand = feet
|
||||
+ glam::DVec3::new(
|
||||
-cos * 0.35 - sin * 0.8,
|
||||
eye - 0.45,
|
||||
-sin * 0.35 + cos * 0.8,
|
||||
);
|
||||
Some((lerp(h.previous, h.position), hand))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -200,7 +200,7 @@ impl Game<'_> {
|
||||
}
|
||||
Action::Copy => self.friends_copy(),
|
||||
Action::Stop => self.friends_stop(),
|
||||
// The system browser, as the Shader Packs page opens its folder.
|
||||
// The system browser, as the folder buttons open their folders.
|
||||
Action::Help => {
|
||||
if let Some(url) = &view.help {
|
||||
super::packs::open_folder(std::path::Path::new(url));
|
||||
|
||||
@@ -10,9 +10,10 @@ use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Client `EntityGetter.getEntityCollisions`: only solid happy ghasts,
|
||||
/// excluding the player's own vehicle.
|
||||
pub(super) fn happy_ghast_player_collisions(&self) -> Vec<Aabb> {
|
||||
/// Client `EntityGetter.getEntityCollisions` for the player: the
|
||||
/// entities that `canBeCollidedWith` it, solid happy ghasts and boats
|
||||
/// (`AbstractBoat.canBeCollidedWith`), except the player's own vehicle.
|
||||
pub(super) fn player_entity_collisions(&self) -> Vec<Aabb> {
|
||||
let Some(p) = &self.player else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -24,6 +25,12 @@ impl Game<'_> {
|
||||
&& happy_ghast::collides_with_player(m, p.position.y)
|
||||
})
|
||||
.map(|m| m.bounds())
|
||||
.chain(
|
||||
self.boats
|
||||
.iter()
|
||||
.filter(|b| Some(b.id) != self.riding)
|
||||
.map(super::boats::Boat::bounds),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
/// `HappyGhast.mobInteract`. Returns whether the click was used.
|
||||
|
||||
@@ -6,12 +6,10 @@
|
||||
//! the world's options) with [`Integrated::call`], as vanilla's client calls
|
||||
//! into its `IntegratedServer`.
|
||||
//!
|
||||
//! Opt-in again (`--server-thread` or `HORIZON_SERVER_THREAD=1`) until a
|
||||
//! server's players reach parity with the in-frame world: on the server
|
||||
//! thread, signs open no editor, books don't open, a totem doesn't save its
|
||||
//! holder and death keeps the inventory (#1380, #1366, #1292, #1284 and the
|
||||
//! other `[server-thread]` issues). LAN guests and dedicated servers use the
|
||||
//! same code either way.
|
||||
//! On by default. `--in-frame` (or `--no-server-thread`, or
|
||||
//! `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead, the
|
||||
//! escape hatch. LAN guests and dedicated servers use the same code either
|
||||
//! way.
|
||||
use super::{
|
||||
Game, Options, PackResources, Resources, UiResources,
|
||||
lan::{LanSettings, Owner},
|
||||
@@ -468,8 +466,10 @@ impl Game<'_> {
|
||||
host = self.lan.take().expect("a command puts the hub back");
|
||||
self.leave_guest(&mut host, swap);
|
||||
host.hub.sync_all_effects();
|
||||
// A teleport into another level happens before its feedback is sent.
|
||||
// A teleport into another level happens before its feedback is
|
||||
// sent, and what the command earned is announced before it too.
|
||||
self.run_level_teleports(&mut host);
|
||||
self.announce_guest_advancements(&mut host);
|
||||
for line in lines {
|
||||
out.push(line.plain());
|
||||
host.hub.send_to(
|
||||
@@ -496,6 +496,22 @@ impl Game<'_> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Run `f` with the owner's level in place (the server holds one per
|
||||
/// dimension), then put back the level that was.
|
||||
pub(super) fn in_owner_level<T>(&mut self, f: impl FnOnce(&mut Self) -> T) -> T {
|
||||
let owner = self.serving.as_ref().map(|s| s.owner);
|
||||
let dim = self.lan.as_ref().and_then(|h| {
|
||||
let g = h.hub.guests.iter().find(|g| Some(g.uuid) == owner)?;
|
||||
Some(super::Dim::from_id(&g.dimension))
|
||||
});
|
||||
let back = dim.and_then(|d| self.enter_command_level(d));
|
||||
let out = f(self);
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The window's requests, between two ticks.
|
||||
fn run_tasks(&mut self) {
|
||||
let tasks: Vec<Task> = match &self.serving {
|
||||
|
||||
@@ -411,7 +411,12 @@ impl Game<'_> {
|
||||
"game_mode": g.game_mode,
|
||||
// ItemCooldowns: the ticks the shield an axe disabled stays down.
|
||||
"shield_cooldown": h.play.get(&g.conn).map_or(0, |p| p.shield_cooldown),
|
||||
// The item the server has this guest using: its slot and ticks.
|
||||
"using": h.play.get(&g.conn).and_then(|p| p.using())
|
||||
.map(|(slot, ticks)| json!({ "slot": slot, "ticks": ticks })),
|
||||
"sneaking": g.sneaking,
|
||||
// The server's Elytra glide (shared flag 7).
|
||||
"fall_flying": g.fall_flying,
|
||||
"sleeping": g.bed,
|
||||
"health": g.health,
|
||||
// The server's body: its air supply and golden hearts.
|
||||
@@ -870,6 +875,7 @@ impl Game<'_> {
|
||||
.collect();
|
||||
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
|
||||
self.lan_entities(host_entity, &host.mob_players, &mut host.hub, &mut states);
|
||||
self.lan_hook_states(host_entity, &host.play, &host.hub, &mut states);
|
||||
} else {
|
||||
states.clear();
|
||||
}
|
||||
@@ -1006,6 +1012,8 @@ impl Game<'_> {
|
||||
drop(holding);
|
||||
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
|
||||
self.lan = Some(host);
|
||||
// A mace's wind burst from a guest's attack.
|
||||
let _ = self.flush_wind_bursts();
|
||||
}
|
||||
|
||||
fn lan_guest_joined(
|
||||
@@ -1061,6 +1069,7 @@ impl Game<'_> {
|
||||
}
|
||||
// A dedicated server has no host player.
|
||||
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs);
|
||||
self.lan_hook_states(None, &host.play, &host.hub, &mut theirs);
|
||||
theirs.as_slice()
|
||||
} else {
|
||||
entities
|
||||
|
||||
@@ -30,6 +30,8 @@ use winit::keyboard::KeyCode;
|
||||
/// Per-guest state of the gameplay code that lives outside `Guest`.
|
||||
#[derive(Default)]
|
||||
pub(super) struct GuestPlay {
|
||||
/// The sign this guest may write on (`SignBlockEntity.playerWhoMayEdit`).
|
||||
pub(super) sign_editing: Option<[i32; 3]>,
|
||||
mining: Option<Mining>,
|
||||
destroy_delay: u32,
|
||||
using: Option<(usize, u32)>,
|
||||
@@ -80,6 +82,8 @@ pub(super) struct GuestPlay {
|
||||
/// (`LivingEntity.collectEquipmentChanges`).
|
||||
modifiers: Vec<(String, String)>,
|
||||
equipped: Option<Vec<Option<ItemStack>>>,
|
||||
/// The guest's cast fishing hook (`Player.fishing`).
|
||||
pub(super) hook: Option<super::fishing::Hook>,
|
||||
}
|
||||
|
||||
/// A guest's command waiting for the hub (`LanHost::commands`).
|
||||
@@ -87,7 +91,7 @@ pub(super) struct GuestCommand {
|
||||
conn: u32,
|
||||
command: String,
|
||||
permission: u8,
|
||||
/// An integrated server's: no admin feedback or level teleports (yet).
|
||||
/// An integrated server's: no level teleports (yet).
|
||||
integrated: bool,
|
||||
}
|
||||
|
||||
@@ -160,6 +164,9 @@ impl GuestPlay {
|
||||
|
||||
struct ItemRules<'a>(&'a horizon_client::items::Items);
|
||||
impl Rules for ItemRules<'_> {
|
||||
fn items(&self) -> &horizon_client::items::Items {
|
||||
self.0
|
||||
}
|
||||
fn max_stack(&self, item: &str) -> u32 {
|
||||
self.0.get(item).map_or(64, |i| i.max_stack)
|
||||
}
|
||||
@@ -256,6 +263,7 @@ impl Game<'_> {
|
||||
motion.pitch = g.pitch;
|
||||
motion.sprinting = g.sprinting;
|
||||
motion.may_fly = g.creative() || g.game_mode == SPECTATOR;
|
||||
motion.flying = g.flying;
|
||||
motion.invulnerable = motion.may_fly;
|
||||
let _ = motion.set_health(g.health, false);
|
||||
motion.health.food.food = g.food;
|
||||
@@ -339,6 +347,7 @@ impl Game<'_> {
|
||||
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
|
||||
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
|
||||
std::mem::swap(&mut self.stats, &mut play.stats);
|
||||
std::mem::swap(&mut self.hook, &mut play.hook);
|
||||
let adv_loaded = std::mem::replace(&mut self.adv.loaded, true);
|
||||
let last_inventory = std::mem::replace(&mut self.adv.last_inventory, play.last_inventory);
|
||||
let death_cause = std::mem::replace(&mut self.ui.death_cause, play.death_cause.take());
|
||||
@@ -510,7 +519,8 @@ impl Game<'_> {
|
||||
std::mem::swap(&mut self.mount, &mut play.mount);
|
||||
self.container = container;
|
||||
self.screen = screen;
|
||||
self.sign_edit = host_sign;
|
||||
// A sign the guest's placement opened (SignBlock.openTextEdit).
|
||||
let guest_sign = std::mem::replace(&mut self.sign_edit, host_sign);
|
||||
// A lectern the guest's use opened (LecternBlock.openScreen).
|
||||
let guest_lectern = match std::mem::replace(&mut self.book, host_book) {
|
||||
Some(super::books::BookScreen::View {
|
||||
@@ -545,6 +555,7 @@ impl Game<'_> {
|
||||
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
|
||||
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
|
||||
std::mem::swap(&mut self.stats, &mut play.stats);
|
||||
std::mem::swap(&mut self.hook, &mut play.hook);
|
||||
self.adv.loaded = adv_loaded;
|
||||
play.last_inventory = std::mem::replace(&mut self.adv.last_inventory, last_inventory);
|
||||
let stat_events = std::mem::take(&mut play.stats.events);
|
||||
@@ -584,6 +595,11 @@ impl Game<'_> {
|
||||
// A teleport (commands, a pearl) moves the guest; a pearl's is
|
||||
// sent even to where they stand (ServerPlayer.teleport).
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
// A hit with extra knockback stopped the guest's sprint
|
||||
// (Player.causeExtraKnockback's setSprinting(false)).
|
||||
if !m.sprinting {
|
||||
g.sprinting = false;
|
||||
}
|
||||
m.sprinting = false;
|
||||
let teleported = std::mem::take(&mut m.teleported);
|
||||
let moved: f64 = (0..3).map(|k| (at[k] - g.position[k]).powi(2)).sum();
|
||||
@@ -649,6 +665,9 @@ impl Game<'_> {
|
||||
if let Some(station) = opened_station {
|
||||
self.lan_station_opened(host, conn, station);
|
||||
}
|
||||
if let Some(edit) = guest_sign {
|
||||
self.lan_open_sign_editor(host, conn, edit);
|
||||
}
|
||||
if let Some(key) = guest_lectern {
|
||||
self.lan_open_lectern(host, conn, key);
|
||||
}
|
||||
@@ -862,14 +881,20 @@ impl Game<'_> {
|
||||
self.as_guest(host, conn, |g| g.drop_held(a.action == 4));
|
||||
}
|
||||
}
|
||||
// RELEASE_USE_ITEM: ServerPlayer.releaseUsingItem, with the
|
||||
// hand of the item in use acting.
|
||||
6 => {
|
||||
self.as_guest(host, conn, |g| {
|
||||
if let Some((slot, ticks)) = g.using.take()
|
||||
&& g.stack_in(slot)
|
||||
.is_some_and(|s| &*s.item == "minecraft:bow")
|
||||
{
|
||||
g.release_bow(ticks);
|
||||
}
|
||||
let Some(hand) = g.used_hand() else {
|
||||
return;
|
||||
};
|
||||
g.with_hand(hand, |g| {
|
||||
if let Some((slot, ticks)) = g.using.take()
|
||||
&& let Some(stack) = g.stack_in(slot).cloned()
|
||||
{
|
||||
g.release_used_item(&stack, ticks, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
// SWAP_ITEM_WITH_OFFHAND: the hands swap and the item in use stops
|
||||
@@ -974,9 +999,14 @@ impl Game<'_> {
|
||||
.look_at_entity(host, gi, p.id, Some(p.location))
|
||||
.is_some()
|
||||
{
|
||||
// handleInteract: the entity and that hand only; a click the
|
||||
// entity passes comes back as UseItem.
|
||||
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
|
||||
let swung = self.as_guest(host, conn, |g| {
|
||||
g.guest_use = Some(GuestUse::Entity(hand));
|
||||
let swings = g.swings_started;
|
||||
let _ = g.use_item();
|
||||
g.guest_use = None;
|
||||
g.swings_started != swings
|
||||
});
|
||||
self.lan_swing_back(host, conn, swung);
|
||||
@@ -1021,30 +1051,27 @@ impl Game<'_> {
|
||||
g.open_ridden_mount_menu();
|
||||
});
|
||||
}
|
||||
S::PlayerCommand(p) if p.action == 6 => self.lan_start_gliding(host, conn),
|
||||
S::ContainerButtonClick(p) => {
|
||||
self.lan_station_button(host, conn, p.container, p.button);
|
||||
}
|
||||
S::RenameItem(p) => self.lan_station_rename(host, conn, &p.name),
|
||||
S::SignUpdate(p) => self.lan_sign_update(host, conn, p),
|
||||
S::EditBook(p) => self.lan_edit_book(host, conn, p),
|
||||
S::ContainerSlotStateChanged(p) => {
|
||||
self.lan_crafter_slot_state(host, conn, p.container, p.slot, p.enabled);
|
||||
}
|
||||
S::SetBeacon(p) => self.lan_station_beacon(host, conn, p.primary, p.secondary),
|
||||
S::SelectTrade(p) => self.lan_select_trade(host, conn, p.item),
|
||||
S::ContainerClose(p) if self.lan_merchant_closed(host, conn, Some(p.container)) => {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if let Some(c) = g.carried.take() {
|
||||
self.drop_guest_items(host, gi, vec![c]);
|
||||
}
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
S::ContainerClose(p)
|
||||
if self.lan_crafting_closed(host, conn, Some(p.container))
|
||||
|| self.lan_station_closed(host, conn, Some(p.container))
|
||||
|| self.lan_mount_closed(host, conn, Some(p.container)) =>
|
||||
{
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if let Some(c) = g.carried.take() {
|
||||
self.drop_guest_items(host, gi, vec![c]);
|
||||
}
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
S::ContainerClick(p) => {
|
||||
let rules = ItemRules(self.res.items());
|
||||
@@ -1060,10 +1087,7 @@ impl Game<'_> {
|
||||
self.guest_container_closed(host, conn);
|
||||
}
|
||||
host.hub.container_closed(conn, p.container);
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if let Some(c) = g.carried.take() {
|
||||
self.drop_guest_items(host, gi, vec![c]);
|
||||
}
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
S::ChatCommand(c) => self.guest_chat_command(host, conn, &c.command),
|
||||
S::ChatCommandSigned(p) => self.guest_signed_command(host, conn, p),
|
||||
@@ -1210,6 +1234,17 @@ impl Game<'_> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.removed`: what a closing player held on the
|
||||
/// cursor goes back into their inventory (`placeItemBackInInventory`,
|
||||
/// dropped only if it doesn't fit), and their client's cursor empties
|
||||
/// with the inventory it is sent.
|
||||
fn guest_carried_back(&mut self, host: &mut LanHost, gi: usize) {
|
||||
if let Some(c) = host.hub.guests[gi].carried.take() {
|
||||
self.lan_place_back(host, gi, vec![c]);
|
||||
host.hub.send_inventory(gi);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn drop_guest_items(&mut self, host: &LanHost, gi: usize, stacks: Vec<ItemStack>) {
|
||||
let g = &host.hub.guests[gi];
|
||||
let eye = [
|
||||
@@ -1292,8 +1327,11 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if let Some(victim) = Self::pvp_victim(host, conn, id) {
|
||||
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike()) {
|
||||
self.pvp_land(host, victim, &strike);
|
||||
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
|
||||
&& self.pvp_land(host, victim, &strike)
|
||||
&& strike.stops_sprint()
|
||||
{
|
||||
self.as_guest(host, conn, |g| g.stop_sprint_after_extra_knockback());
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1414,6 +1452,8 @@ impl Game<'_> {
|
||||
// A teleport into another level happens before its feedback is sent.
|
||||
self.run_level_teleports(&mut host);
|
||||
}
|
||||
// What the command earned is announced before its feedback.
|
||||
self.announce_guest_advancements(&mut host);
|
||||
for line in lines {
|
||||
host.hub.send_to(
|
||||
c.conn,
|
||||
@@ -1423,9 +1463,8 @@ impl Game<'_> {
|
||||
}),
|
||||
);
|
||||
}
|
||||
if !c.integrated {
|
||||
self.send_admin_feedback(&mut host, Some(c.conn));
|
||||
}
|
||||
// The other operators hear of it; never the player whose command it was.
|
||||
self.send_admin_feedback(&mut host, Some(c.conn));
|
||||
self.lan = Some(host);
|
||||
}
|
||||
}
|
||||
@@ -1674,6 +1713,17 @@ impl Game<'_> {
|
||||
g.use_delay -= 1;
|
||||
}
|
||||
});
|
||||
// ServerPlayerGameMode.handleBlockBreakAction: a creative
|
||||
// player's START breaks the block once (destroyAndAck) and
|
||||
// leaves nothing being destroyed; its client sends no STOP.
|
||||
// Kept, the dig broke whatever was put there next, every
|
||||
// destroy delay, for as long as the player broke nothing else.
|
||||
if digging.is_some()
|
||||
&& host.hub.guests[gi].creative()
|
||||
&& let Some(play) = host.play.get_mut(&conn)
|
||||
{
|
||||
play.digging = None;
|
||||
}
|
||||
}
|
||||
// Player.tick: a different main-hand item resets the attack
|
||||
// strength, and detectEquipmentUpdates gives the guest its modifiers.
|
||||
@@ -1695,16 +1745,23 @@ impl Game<'_> {
|
||||
// PlayerAdvancements.flushDirty.
|
||||
let sleeping = host.hub.guests[gi].sleeping;
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.tick_fishing();
|
||||
g.tick_maps();
|
||||
g.count_tick_stats(sleeping);
|
||||
g.tick_advancements();
|
||||
g.update_player_attributes();
|
||||
});
|
||||
self.lan_map_updates(host, conn);
|
||||
host.hub.flush_progress(conn);
|
||||
self.guest_dig_feedback(host, conn);
|
||||
self.guest_pickups(host, gi);
|
||||
self.guest_orbs(host, gi);
|
||||
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
|
||||
self.guest_forget_digging(host, conn);
|
||||
// FishingHook.shouldStopFishing: its owner is dead.
|
||||
if let Some(play) = host.play.get_mut(&conn) {
|
||||
play.hook = None;
|
||||
}
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if g.health <= 0.0 && !g.dead {
|
||||
@@ -1718,6 +1775,9 @@ impl Game<'_> {
|
||||
}
|
||||
.death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
|
||||
let (name, uuid) = (g.name.clone(), g.uuid);
|
||||
// ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3.
|
||||
let drops = (g.game_mode != SPECTATOR)
|
||||
.then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
|
||||
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
|
||||
let message = match cause {
|
||||
// ServerPlayer.die: the death message, to everyone unless
|
||||
@@ -1751,6 +1811,9 @@ impl Game<'_> {
|
||||
);
|
||||
// LivingEntity.die: the death animation, the dying player included.
|
||||
host.hub.entity_event(id, 3, at);
|
||||
if let Some(eyes) = drops {
|
||||
self.as_guest(host, conn, |game| game.drop_inventory(eyes));
|
||||
}
|
||||
self.spawn_orbs(at, reward);
|
||||
}
|
||||
}
|
||||
@@ -1814,7 +1877,7 @@ impl Game<'_> {
|
||||
|
||||
/// `PlayerList.broadcastSystemMessage` of the advancements guests made
|
||||
/// (their own actions, commands and ticks).
|
||||
fn announce_guest_advancements(&mut self, host: &mut LanHost) {
|
||||
pub(super) fn announce_guest_advancements(&mut self, host: &mut LanHost) {
|
||||
for text in std::mem::take(&mut self.adv.announced) {
|
||||
host.hub
|
||||
.system_message(horizon_client::lan::data::component_nbt(&text.component()));
|
||||
@@ -1837,6 +1900,13 @@ impl Game<'_> {
|
||||
/// sounds are heard at the guest.
|
||||
fn guest_server_tick(&mut self, host: &mut LanHost, conn: u32) {
|
||||
self.guest_vehicle_tick(host, conn);
|
||||
// The last move's fall stands in for the server's delta movement.
|
||||
let fell = host
|
||||
.hub
|
||||
.guest_mut(conn)
|
||||
.and_then(|g| g.moves.last().map(|m| m.0[1]))
|
||||
.unwrap_or(0.0);
|
||||
self.guest_glide_tick(host, conn, fell);
|
||||
let Some(g) = host.hub.guest_mut(conn) else {
|
||||
return;
|
||||
};
|
||||
@@ -1860,7 +1930,7 @@ impl Game<'_> {
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return Vec::new();
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
let mut w = MotionWorld {
|
||||
entities: &[],
|
||||
@@ -1936,22 +2006,125 @@ impl Game<'_> {
|
||||
game.border_damage();
|
||||
game.tick_mob_effects();
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return Vec::new();
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
let _ = player.peaceful_regeneration(rules);
|
||||
let _ = player.food_tick(health, rules);
|
||||
events.append(&mut player.events);
|
||||
events
|
||||
let _ = MotionWorld {
|
||||
entities: &[],
|
||||
player,
|
||||
scene: &mut game.world,
|
||||
assets: &game.pack.assets,
|
||||
collision_binding: game.collision,
|
||||
resources: game.motion,
|
||||
}
|
||||
.server_travel();
|
||||
// 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<_>>()
|
||||
.collect::<Vec<_>>();
|
||||
(sounds, marked.then_some(player.velocity))
|
||||
});
|
||||
for sound in sounds.into_iter().flatten() {
|
||||
let Some((sounds, marked)) = sounds else {
|
||||
return;
|
||||
};
|
||||
for sound in sounds {
|
||||
self.player_hurt_sound(sound, at);
|
||||
}
|
||||
if let (Some(v), Some(g)) = (marked, host.hub.guest(conn)) {
|
||||
g.send(&C::SetEntityMotion(play::SetEntityMotion {
|
||||
id: g.entity_id,
|
||||
velocity: [v.x, v.y, v.z],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerGamePacketListenerImpl.handlePlayerCommand START_FALL_FLYING:
|
||||
/// `Player.tryToStartFallFlying`, or else `stopFallFlying` (a second
|
||||
/// start while gliding ends the glide, as in vanilla).
|
||||
fn lan_start_gliding(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(g) = host.hub.guest_mut(conn) else {
|
||||
return;
|
||||
};
|
||||
let start = !g.fall_flying && self.guest_can_glide(host, conn) == Some(true);
|
||||
let in_liquid = self
|
||||
.as_guest(host, conn, |game| {
|
||||
game.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.fluids.was_touching_water || p.is_in_lava())
|
||||
})
|
||||
.unwrap_or(true);
|
||||
if let Some(g) = host.hub.guest_mut(conn) {
|
||||
g.fall_flying = start && !in_liquid;
|
||||
}
|
||||
}
|
||||
|
||||
/// `Player.canGlide`: not flying, airborne, not riding, no levitation, and
|
||||
/// a glider that its next point of wear won't break.
|
||||
fn guest_can_glide(&mut self, host: &mut LanHost, conn: u32) -> Option<bool> {
|
||||
let g = host.hub.guest_mut(conn)?;
|
||||
if g.flying || g.on_ground || g.vehicle.is_some() {
|
||||
return Some(false);
|
||||
}
|
||||
self.as_guest(host, conn, |game| {
|
||||
game.glider_worn() && game.potions.effects.get("levitation").is_none()
|
||||
})
|
||||
}
|
||||
|
||||
/// `LivingEntity.updateFallFlying` on the server, once a tick for a
|
||||
/// gliding guest: the glide ends once `canGlide` fails; otherwise every
|
||||
/// 20th tick wears the glider by one, and every 10th is an
|
||||
/// `elytra_glide` game event. Entity.checkFallDistanceAccumulation runs
|
||||
/// first: a glide that isn't diving keeps the fall distance at 1.
|
||||
fn guest_glide_tick(&mut self, host: &mut LanHost, conn: u32, fell: f64) {
|
||||
let Some(g) = host.hub.guest_mut(conn) else {
|
||||
return;
|
||||
};
|
||||
if !g.fall_flying {
|
||||
if let Some(m) = g.motion.as_mut() {
|
||||
m.fall_fly_ticks = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let creative = g.creative();
|
||||
if let Some(m) = g.motion.as_mut()
|
||||
&& fell > -0.5
|
||||
&& m.fall_distance > 1.0
|
||||
{
|
||||
m.fall_distance = 1.0;
|
||||
}
|
||||
if self.guest_can_glide(host, conn) != Some(true) {
|
||||
if let Some(g) = host.hub.guest_mut(conn) {
|
||||
g.fall_flying = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.as_guest(host, conn, |game| {
|
||||
let Some(p) = game.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
p.fall_fly_ticks += 1;
|
||||
let next = p.fall_fly_ticks + 1;
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
if next % 10 != 0 {
|
||||
return;
|
||||
}
|
||||
if (next / 10) % 2 == 0
|
||||
&& !creative
|
||||
&& let Some(elytra) = game.armor[1].as_mut()
|
||||
{
|
||||
elytra.damage += 1;
|
||||
}
|
||||
let ctx = game.player_context(None);
|
||||
game.post_game_event("minecraft:elytra_glide", at, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/// `Player.isInWall`: a suffocating block in the 0.48-wide box at the
|
||||
|
||||
@@ -78,6 +78,8 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
|
||||
target: Who::Player,
|
||||
..
|
||||
}
|
||||
| MobEvent::EffectPlayer { .. }
|
||||
| MobEvent::AxolotlSupportPlayer
|
||||
)
|
||||
}
|
||||
|
||||
@@ -115,6 +117,25 @@ impl Game<'_> {
|
||||
views
|
||||
}
|
||||
|
||||
/// Runs `f` as each guest in play here, for what strikes every player in
|
||||
/// an area (`Level.getEntities`), not only the window's own.
|
||||
pub(super) fn each_guest_hit(&mut self, mut f: impl FnMut(&mut Self)) {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
let conns: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
self.guest_hit_by(&mut host, conn, &mut f);
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
|
||||
/// Mob events aimed at a guest, run as that guest (`guest_hit_by`).
|
||||
pub(super) fn lan_guest_mob_events(&mut self, events: Vec<(u32, MobEvent)>) {
|
||||
if events.is_empty() {
|
||||
@@ -192,7 +213,9 @@ impl Game<'_> {
|
||||
|
||||
impl Game<'_> {
|
||||
/// Hurt a guest: `f` runs as them, and the knockback it gave reaches
|
||||
/// their client (`hurtMarked`: ClientboundSetEntityMotionPacket).
|
||||
/// their client. A hurt marks the velocity (Entity.markHurt), which goes
|
||||
/// out once the guest's server copy has travelled (`guest_server_tick`);
|
||||
/// a push with no hurt goes out at once.
|
||||
pub(super) fn guest_hit_by<R>(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
@@ -202,16 +225,47 @@ impl Game<'_> {
|
||||
let (result, pushed) = self.as_guest(host, conn, |g| {
|
||||
let before = g.player.as_ref().map(|p| p.velocity);
|
||||
let result = f(g);
|
||||
let after = g.player.as_ref().map(|p| p.velocity);
|
||||
(result, after.filter(|v| Some(*v) != before))
|
||||
let pushed = g
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|p| !p.health.sync_velocity && Some(p.velocity) != before)
|
||||
.map(|p| p.velocity);
|
||||
(result, pushed)
|
||||
})?;
|
||||
if let (Some(v), Some(g)) = (pushed, host.hub.guest(conn)) {
|
||||
self.send_guest_motion(host, conn, pushed);
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// A player's melee hit on a guest (Player.causeExtraKnockback on a
|
||||
/// ServerPlayer): a hurt that marked the velocity sends it at once, and
|
||||
/// the server's copy goes back to the velocity it had before the hit.
|
||||
pub(super) fn guest_struck_by<R>(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
f: impl FnOnce(&mut Self) -> R,
|
||||
) -> Option<R> {
|
||||
let (result, sent) = self.as_guest(host, conn, |g| {
|
||||
let before = g.player.as_ref().map(|p| p.velocity);
|
||||
let result = f(g);
|
||||
let sent = g.player.as_mut().zip(before).and_then(|(p, before)| {
|
||||
std::mem::take(&mut p.health.sync_velocity)
|
||||
.then(|| std::mem::replace(&mut p.velocity, before))
|
||||
});
|
||||
(result, sent)
|
||||
})?;
|
||||
self.send_guest_motion(host, conn, sent);
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// ClientboundSetEntityMotionPacket to a guest about themselves.
|
||||
pub(super) fn send_guest_motion(&self, host: &LanHost, conn: u32, velocity: Option<Vec3>) {
|
||||
if let (Some(v), Some(g)) = (velocity, host.hub.guest(conn)) {
|
||||
g.send(&C::SetEntityMotion(play::SetEntityMotion {
|
||||
id: g.entity_id,
|
||||
velocity: [v.x, v.y, v.z],
|
||||
}));
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `/particle` uses) and `ClientboundExplodePacket` (`ServerLevel.explode`).
|
||||
use super::Game;
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_protocol::{
|
||||
entity_data::{Particle, ParticleData},
|
||||
packets::play::{self, Clientbound as C},
|
||||
@@ -154,7 +154,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleExplosion`: the blast's particles (and its
|
||||
/// sound, when the packet carries it).
|
||||
/// sound, when the packet carries it), then the player's push.
|
||||
pub(super) fn remote_explode(&mut self, p: &play::Explode) {
|
||||
if p.play_sound {
|
||||
let pitch = (1.0
|
||||
@@ -168,37 +168,88 @@ impl Game<'_> {
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
let (world, facts) = (&self.world, self.res.facts());
|
||||
self.particles
|
||||
.explosion(p.center, p.radius, p.block_count.max(0) as usize, &|b| {
|
||||
facts.is_air(world.block(b[0], b[1], b[2]))
|
||||
});
|
||||
if let Some(options) = self.received_particle(&p.particle, p.center) {
|
||||
self.particles.add(&options, p.center, [1.0, 0.0, 0.0]);
|
||||
}
|
||||
// A wind charge's burst throws none.
|
||||
if !p.block_particles.is_empty() {
|
||||
let (world, facts) = (&self.world, self.res.facts());
|
||||
self.particles.explosion_blocks(
|
||||
p.center,
|
||||
p.radius,
|
||||
p.block_count.max(0) as usize,
|
||||
&|b| facts.is_air(world.block(b[0], b[1], b[2])),
|
||||
);
|
||||
}
|
||||
// Entity.pushFromExplosion.
|
||||
if let (Some(push), Some(player)) = (p.knockback, self.player.as_mut()) {
|
||||
player.velocity = Vec3::new(
|
||||
player.velocity.x + push[0],
|
||||
player.velocity.y + push[1],
|
||||
player.velocity.z + push[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerLevel.explode`'s packet to each player within 64 blocks. The
|
||||
/// sound and the push reach them on their own (the level's sounds, the
|
||||
/// players' motion), so the packet carries neither.
|
||||
/// motion a hurt player is sent), so the packet carries neither.
|
||||
pub(super) fn lan_explode(&self, center: [f64; 3], radius: f32, block_count: usize) {
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let particle = |name: &str| {
|
||||
Some(Particle {
|
||||
id: host.hub.registry_id("minecraft:particle_type", name)?,
|
||||
data: None,
|
||||
})
|
||||
};
|
||||
let emitter = if radius < 2.0 {
|
||||
"minecraft:explosion"
|
||||
} else {
|
||||
"minecraft:explosion_emitter"
|
||||
};
|
||||
let (Some(at_center), Some(poof), Some(smoke)) = (
|
||||
particle(emitter),
|
||||
particle("minecraft:poof"),
|
||||
particle("minecraft:smoke"),
|
||||
) else {
|
||||
return;
|
||||
let sound = "minecraft:entity.generic.explode";
|
||||
if let Some(packet) =
|
||||
self.explosion_packet(center, radius, block_count, emitter, sound, true)
|
||||
{
|
||||
self.send_explosion(packet, &[]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A wind charge's burst (`AbstractWindCharge.explode`): gust emitters,
|
||||
/// no block particles, and for each player it pushed the push
|
||||
/// (`ServerExplosion.hitPlayers`), which only that client applies, as
|
||||
/// nothing hurt them.
|
||||
pub(super) fn lan_wind_burst(
|
||||
&self,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
sound: &str,
|
||||
block_count: usize,
|
||||
pushed: &[(u32, [f64; 3])],
|
||||
) {
|
||||
let emitter = if radius < 2.0 {
|
||||
"minecraft:gust_emitter_small"
|
||||
} else {
|
||||
"minecraft:gust_emitter_large"
|
||||
};
|
||||
if let Some(packet) =
|
||||
self.explosion_packet(center, radius, block_count, emitter, sound, false)
|
||||
{
|
||||
self.send_explosion(packet, pushed);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientboundExplodePacket` without a push, or None while not serving.
|
||||
/// The sound is the level's, which the players hear already, so it isn't
|
||||
/// played from the packet.
|
||||
fn explosion_packet(
|
||||
&self,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
block_count: usize,
|
||||
emitter: &str,
|
||||
sound: &str,
|
||||
block_particles: bool,
|
||||
) -> Option<play::Explode> {
|
||||
let hub = &self.lan.as_ref()?.hub;
|
||||
let particle = |name: &str| {
|
||||
Some(Particle {
|
||||
id: hub.registry_id("minecraft:particle_type", name)?,
|
||||
data: None,
|
||||
})
|
||||
};
|
||||
// Level.DEFAULT_EXPLOSION_BLOCK_PARTICLES.
|
||||
let info = |particle, scaling| play::ExplosionParticle {
|
||||
@@ -207,22 +258,49 @@ impl Game<'_> {
|
||||
speed: 1.0,
|
||||
weight: 1,
|
||||
};
|
||||
let packet = C::Explode(play::Explode {
|
||||
let block_particles = if block_particles {
|
||||
vec![
|
||||
info(particle("minecraft:poof")?, 0.5),
|
||||
info(particle("minecraft:smoke")?, 1.0),
|
||||
]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Some(play::Explode {
|
||||
center,
|
||||
radius,
|
||||
block_count: block_count as i32,
|
||||
knockback: None,
|
||||
particle: at_center,
|
||||
sound: match host
|
||||
.hub
|
||||
.registry_id("minecraft:sound_event", "minecraft:entity.generic.explode")
|
||||
{
|
||||
particle: particle(emitter)?,
|
||||
sound: match hub.registry_id("minecraft:sound_event", sound) {
|
||||
Some(id) => (id, None),
|
||||
None => (-1, Some(("minecraft:entity.generic.explode".into(), None))),
|
||||
None => (-1, Some((sound.into(), None))),
|
||||
},
|
||||
block_particles: vec![info(poof, 0.5), info(smoke, 1.0)],
|
||||
block_particles,
|
||||
play_sound: false,
|
||||
});
|
||||
host.hub.broadcast_near(center, 64.0, &packet, None);
|
||||
})
|
||||
}
|
||||
|
||||
/// `packet` to each player within 64 blocks of its centre, with the push
|
||||
/// `pushed` holds for them.
|
||||
fn send_explosion(&self, mut packet: play::Explode, pushed: &[(u32, [f64; 3])]) {
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let center = packet.center;
|
||||
let plain = C::Explode(packet.clone()).encode();
|
||||
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
|
||||
let d: f64 = (0..3).map(|k| (g.position[k] - center[k]).powi(2)).sum();
|
||||
if d >= 4096.0 {
|
||||
continue;
|
||||
}
|
||||
match pushed.iter().find(|(conn, _)| *conn == g.conn) {
|
||||
Some((_, push)) => {
|
||||
packet.knockback = Some(*push);
|
||||
g.send(&C::Explode(packet.clone()));
|
||||
}
|
||||
None => g.sender.send(plain.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,14 @@ pub(super) struct Strike {
|
||||
team: Option<horizon_client::scoreboard::Team>,
|
||||
}
|
||||
|
||||
impl Strike {
|
||||
/// Whether it has extra knockback, which stops the attacker's sprint
|
||||
/// once it lands (Player.causeExtraKnockback).
|
||||
pub(super) fn stops_sprint(&self) -> bool {
|
||||
self.knockback.0 > 0.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The guest on the host's crosshair and how far, within reach.
|
||||
pub(super) fn guest_hit(&self) -> Option<(u32, f64)> {
|
||||
@@ -74,7 +82,7 @@ impl Game<'_> {
|
||||
};
|
||||
if let Some(strike) = self.pvp_strike()
|
||||
&& self.pvp_land(&mut host, Victim::Guest(conn), &strike)
|
||||
&& strike.knockback.0 > 0.0
|
||||
&& strike.stops_sprint()
|
||||
{
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
@@ -240,7 +248,7 @@ impl Game<'_> {
|
||||
&& harm
|
||||
&& match victim {
|
||||
Victim::Host => land(self),
|
||||
Victim::Guest(conn) => self.guest_hit_by(host, conn, land).unwrap_or(false),
|
||||
Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or(false),
|
||||
};
|
||||
let sound = match (hurt, s.crit, s.scale > 0.9) {
|
||||
(false, ..) => "minecraft:entity.player.attack.nodamage",
|
||||
|
||||
@@ -588,17 +588,7 @@ impl Game<'_> {
|
||||
let [x, y, z] = gs.pos;
|
||||
let name = &self.res.facts().get(self.world.block(x, y, z)).name;
|
||||
let block = StationKind::from_block(name) == Some(gs.kind);
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
// The block interaction range (4.5, creative 5) and the buffer.
|
||||
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
|
||||
let near = (0..3)
|
||||
.map(|k| {
|
||||
let lo = f64::from(gs.pos[k]);
|
||||
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
|
||||
})
|
||||
.sum::<f64>()
|
||||
< reach * reach;
|
||||
if !(block && near) {
|
||||
if !(block && within_reach(g, gs.pos)) {
|
||||
let window = gs.window;
|
||||
self.lan_station_closed(host, conn, Some(window));
|
||||
host.hub.send_to(
|
||||
@@ -670,3 +660,18 @@ pub(super) fn station_inputs(gs: &GuestStation) -> Vec<ItemStack> {
|
||||
.map(|(_, _, inputs)| inputs.filter_map(|s| gs.menu.slot(s).cloned()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `Player.isWithinBlockInteractionRange(pos, 4)`: the guest's eyes within
|
||||
/// its block interaction range (4.5, creative 5) and a 4-block buffer of the
|
||||
/// block's box.
|
||||
pub(super) fn within_reach(g: &horizon_client::lan::guest::Guest, pos: [i32; 3]) -> bool {
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
|
||||
(0..3)
|
||||
.map(|k| {
|
||||
let lo = f64::from(pos[k]);
|
||||
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
|
||||
})
|
||||
.sum::<f64>()
|
||||
< reach * reach
|
||||
}
|
||||
@@ -205,7 +205,9 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let [x, y, z] = p.position;
|
||||
if !(x.is_finite() && y.is_finite() && z.is_finite())
|
||||
if !x.is_finite()
|
||||
|| !y.is_finite()
|
||||
|| !z.is_finite()
|
||||
|| !p.yaw.is_finite()
|
||||
|| !p.pitch.is_finite()
|
||||
{
|
||||
|
||||
@@ -202,10 +202,10 @@ impl Game<'_> {
|
||||
self.mobs[index].extra.leash = Some(Holder::Player);
|
||||
self.lead_sound("tied", at);
|
||||
// The lead is always used up.
|
||||
if let Some(stack) = self.inventory[self.selected].as_mut() {
|
||||
if let Some(stack) = self.held_mut().as_mut() {
|
||||
stack.count -= 1;
|
||||
if stack.count == 0 {
|
||||
self.inventory[self.selected] = None;
|
||||
*self.held_mut() = None;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -280,11 +280,12 @@ impl Game<'_> {
|
||||
}
|
||||
/// `thunderHit` for entities within 3 blocks (up to 9 above).
|
||||
fn thunder_hit(&mut self, b: &mut Bolt) {
|
||||
let inside = |p: [f64; 3]| {
|
||||
(p[0] - b.at[0]).abs() <= 3.0
|
||||
&& (p[2] - b.at[2]).abs() <= 3.0
|
||||
&& p[1] >= b.at[1] - 3.0
|
||||
&& p[1] <= b.at[1] + 9.0
|
||||
let at = b.at;
|
||||
let inside = move |p: [f64; 3]| {
|
||||
(p[0] - at[0]).abs() <= 3.0
|
||||
&& (p[2] - at[2]).abs() <= 3.0
|
||||
&& p[1] >= at[1] - 3.0
|
||||
&& p[1] <= at[1] + 9.0
|
||||
};
|
||||
let damage = Damage {
|
||||
kind: DamageKind::Generic,
|
||||
@@ -349,19 +350,24 @@ impl Game<'_> {
|
||||
if !events.is_empty() {
|
||||
let _ = self.handle_mob_events(events);
|
||||
}
|
||||
if let Some(p) = self.player.as_ref().map(|p| p.position)
|
||||
&& inside([p.x, p.y, p.z])
|
||||
{
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.fire_ticks += 1;
|
||||
if p.fire_ticks == 0
|
||||
&& let Err(error) = p.ignite_for_ticks(160)
|
||||
{
|
||||
self.error = Some(error);
|
||||
// Every player in the box, the server's players too.
|
||||
let strike = move |g: &mut Self| {
|
||||
if let Some(p) = g.player.as_ref().map(|p| p.position)
|
||||
&& inside([p.x, p.y, p.z])
|
||||
{
|
||||
if let Some(p) = g.player.as_mut() {
|
||||
p.fire_ticks += 1;
|
||||
if p.fire_ticks == 0
|
||||
&& let Err(error) = p.ignite_for_ticks(160)
|
||||
{
|
||||
g.error = Some(error);
|
||||
}
|
||||
}
|
||||
g.hurt_player_by("minecraft:lightning_bolt", 5.0);
|
||||
}
|
||||
self.hurt_player_by("minecraft:lightning_bolt", 5.0);
|
||||
}
|
||||
};
|
||||
strike(self);
|
||||
self.each_guest_hit(strike);
|
||||
// Dropped items burn up (ItemEntity health 5), save fire-resistant ones.
|
||||
self.dropped.retain(|e| {
|
||||
!inside([e.position.x, e.position.y, e.position.z])
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! shading, the dithered edge), the player marker, `data/minecraft/maps/<id>.dat` and
|
||||
//! `maps/last_id.dat`, the 128x128 texture in an atlas slot, the two-handed
|
||||
//! first-person view and maps in item frames.
|
||||
use super::Game;
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
blocks::{Fluid, MAP_COLORS},
|
||||
hud::{MAP_SLOTS, Rect},
|
||||
@@ -14,13 +14,48 @@ use horizon_client::{
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A decoration: sprite, map position (-128..127, half pixels) and rotation (0..15).
|
||||
/// A decoration: sprite, map position (-128..127, half pixels) and rotation
|
||||
/// (0..15), and the player it marks (an item's own decorations mark none).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct Decoration {
|
||||
pub sprite: &'static str,
|
||||
pub x: i8,
|
||||
pub y: i8,
|
||||
pub rot: u8,
|
||||
pub holder: Option<u32>,
|
||||
}
|
||||
/// The host among a map's holders (the guests are their connections).
|
||||
const HOST: u32 = u32::MAX;
|
||||
/// A player carrying a map, or seeing it framed
|
||||
/// (`MapItemSavedData.HoldingPlayer`): the column sweep's step, and what
|
||||
/// changed since the last `ClientboundMapItemDataPacket` it was sent.
|
||||
struct Holder {
|
||||
who: u32,
|
||||
/// The last tick it carried the map, and saw it framed.
|
||||
carried: i64,
|
||||
framed: i64,
|
||||
step: i32,
|
||||
/// The changed pixels' box (min x, min y, max x, max y).
|
||||
dirty: Option<[i32; 4]>,
|
||||
decorations: bool,
|
||||
tick: u32,
|
||||
}
|
||||
impl Holder {
|
||||
fn new(who: u32) -> Self {
|
||||
Self {
|
||||
who,
|
||||
carried: 0,
|
||||
framed: 0,
|
||||
step: 0,
|
||||
dirty: Some([0, 0, 127, 127]),
|
||||
decorations: true,
|
||||
tick: 0,
|
||||
}
|
||||
}
|
||||
/// Still carrying the map, or seeing it framed (frames tick every 10).
|
||||
fn holds(&self, now: i64) -> bool {
|
||||
self.carried + 1 >= now || self.framed + 10 >= now
|
||||
}
|
||||
}
|
||||
pub(super) struct MapData {
|
||||
pub center: [i32; 2],
|
||||
@@ -31,8 +66,7 @@ pub(super) struct MapData {
|
||||
pub unlimited: bool,
|
||||
pub colors: Vec<u8>,
|
||||
pub decorations: Vec<Decoration>,
|
||||
/// HoldingPlayer.step for the column sweep.
|
||||
step: i32,
|
||||
holders: Vec<Holder>,
|
||||
/// Changed since the last save / texture upload.
|
||||
dirty: bool,
|
||||
texture_dirty: bool,
|
||||
@@ -51,7 +85,7 @@ impl MapData {
|
||||
unlimited: false,
|
||||
colors: vec![0; 128 * 128],
|
||||
decorations: Vec::new(),
|
||||
step: 0,
|
||||
holders: Vec::new(),
|
||||
dirty: true,
|
||||
texture_dirty: true,
|
||||
}
|
||||
@@ -64,8 +98,128 @@ impl MapData {
|
||||
self.colors[i] = color;
|
||||
self.dirty = true;
|
||||
self.texture_dirty = true;
|
||||
// setColorsDirty: every holder's next packet carries it.
|
||||
for h in &mut self.holders {
|
||||
h.dirty = Some(match h.dirty {
|
||||
Some([x0, y0, x1, y1]) => [x0.min(x), y0.min(y), x1.max(x), y1.max(y)],
|
||||
None => [x, y, x, y],
|
||||
});
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `tickCarriedBy`'s holder for `who`, added when new.
|
||||
fn holder(&mut self, who: u32) -> &mut Holder {
|
||||
let i = match self.holders.iter().position(|h| h.who == who) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
self.holders.push(Holder::new(who));
|
||||
self.holders.len() - 1
|
||||
}
|
||||
};
|
||||
&mut self.holders[i]
|
||||
}
|
||||
/// The holders that no longer carry the map nor see it framed go, and
|
||||
/// their markers with them (`tickCarriedBy`'s removeDecoration).
|
||||
fn drop_holders(&mut self, now: i64) {
|
||||
self.holders.retain(|h| h.holds(now));
|
||||
let holders = &self.holders;
|
||||
let before = self.decorations.len();
|
||||
self.decorations
|
||||
.retain(|d| d.holder.is_none_or(|w| holders.iter().any(|h| h.who == w)));
|
||||
if self.decorations.len() != before {
|
||||
self.decorations_changed();
|
||||
}
|
||||
}
|
||||
/// setDecorationsDirty.
|
||||
fn decorations_changed(&mut self) {
|
||||
for h in &mut self.holders {
|
||||
h.decorations = true;
|
||||
}
|
||||
}
|
||||
/// `addDecoration` / `removeDecoration` for `holder`'s marker: in place,
|
||||
/// so that the order (the drawing order) stays.
|
||||
fn set_marker(&mut self, holder: u32, marker: Option<Decoration>) {
|
||||
let i = self
|
||||
.decorations
|
||||
.iter()
|
||||
.position(|d| d.holder == Some(holder));
|
||||
let changed = match (i, marker) {
|
||||
(Some(i), Some(d)) if self.decorations[i] == d => false,
|
||||
(Some(i), Some(d)) => {
|
||||
self.decorations[i] = d;
|
||||
true
|
||||
}
|
||||
(Some(i), None) => {
|
||||
self.decorations.remove(i);
|
||||
true
|
||||
}
|
||||
(None, Some(d)) => {
|
||||
self.decorations.push(d);
|
||||
true
|
||||
}
|
||||
(None, None) => false,
|
||||
};
|
||||
if changed {
|
||||
self.decorations_changed();
|
||||
}
|
||||
}
|
||||
/// `HoldingPlayer.nextUpdatePacket` for `who`: the changed pixels, and
|
||||
/// the decorations (at most every 5th call while they changed).
|
||||
fn update_packet(
|
||||
&mut self,
|
||||
id: i32,
|
||||
who: u32,
|
||||
kind: impl Fn(&str) -> Option<i32>,
|
||||
) -> Option<horizon_protocol::packets::play::MapItemData> {
|
||||
use horizon_protocol::packets::play::{MapDecoration, MapItemData, MapPatch};
|
||||
let h = self.holders.iter_mut().find(|h| h.who == who)?;
|
||||
let patch = h.dirty.take().map(|[x0, y0, x1, y1]| {
|
||||
let (width, height) = (x1 + 1 - x0, y1 + 1 - y0);
|
||||
let mut colors = Vec::with_capacity((width * height) as usize);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
colors.push(self.colors[(x0 + x + (y0 + y) * 128) as usize]);
|
||||
}
|
||||
}
|
||||
MapPatch {
|
||||
start_x: x0,
|
||||
start_y: y0,
|
||||
width,
|
||||
height,
|
||||
colors,
|
||||
}
|
||||
});
|
||||
let decorations = if h.decorations && {
|
||||
let due = h.tick % 5 == 0;
|
||||
h.tick += 1;
|
||||
due
|
||||
} {
|
||||
h.decorations = false;
|
||||
Some(
|
||||
self.decorations
|
||||
.iter()
|
||||
.filter_map(|d| {
|
||||
Some(MapDecoration {
|
||||
kind: kind(decoration_type(d.sprite)?)?,
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
rot: d.rot as i8,
|
||||
name: None,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(patch.is_some() || decorations.is_some()).then_some(MapItemData {
|
||||
map_id: id,
|
||||
scale: self.scale as i8,
|
||||
locked: self.locked,
|
||||
decorations,
|
||||
patch,
|
||||
})
|
||||
}
|
||||
fn to_nbt(&self) -> Tag {
|
||||
let flag = |b: bool| Tag::Byte(i8::from(b));
|
||||
Tag::compound([
|
||||
@@ -112,7 +266,7 @@ impl MapData {
|
||||
unlimited: int("unlimitedTracking").unwrap_or(0) != 0,
|
||||
colors,
|
||||
decorations: Vec::new(),
|
||||
step: 0,
|
||||
holders: Vec::new(),
|
||||
dirty: false,
|
||||
texture_dirty: true,
|
||||
})
|
||||
@@ -143,36 +297,48 @@ fn packed_rgba(packed: u8) -> [u8; 4] {
|
||||
pub(super) fn map_id(stack: &ItemStack) -> Option<i32> {
|
||||
stack.data().and_then(|d| d.map_id)
|
||||
}
|
||||
/// `MapDecorationTypes`: the sprite of a decoration type that a
|
||||
/// `minecraft:map_decorations` entry can name.
|
||||
/// `MapDecorationTypes` Horizon draws: (type, sprite).
|
||||
const DECORATION_TYPES: [(&str, &str); 24] = [
|
||||
("player", "map/decorations/player"),
|
||||
("frame", "map/decorations/frame"),
|
||||
("red_marker", "map/decorations/red_marker"),
|
||||
("blue_marker", "map/decorations/blue_marker"),
|
||||
("target_x", "map/decorations/target_x"),
|
||||
("target_point", "map/decorations/target_point"),
|
||||
("player_off_map", "map/decorations/player_off_map"),
|
||||
("player_off_limits", "map/decorations/player_off_limits"),
|
||||
("mansion", "map/decorations/woodland_mansion"),
|
||||
("monument", "map/decorations/ocean_monument"),
|
||||
("red_x", "map/decorations/red_x"),
|
||||
("village_desert", "map/decorations/desert_village"),
|
||||
("village_plains", "map/decorations/plains_village"),
|
||||
("village_savanna", "map/decorations/savanna_village"),
|
||||
("village_snowy", "map/decorations/snowy_village"),
|
||||
("village_taiga", "map/decorations/taiga_village"),
|
||||
("jungle_temple", "map/decorations/jungle_temple"),
|
||||
("swamp_hut", "map/decorations/swamp_hut"),
|
||||
("trial_chambers", "map/decorations/trial_chambers"),
|
||||
("abandoned_camp", "map/decorations/abandoned_camp"),
|
||||
("ancient_city", "map/decorations/ancient_city"),
|
||||
("desert_pyramid", "map/decorations/desert_pyramid"),
|
||||
("mineshaft", "map/decorations/mineshaft"),
|
||||
("ocean_ruin_warm", "map/decorations/warm_ocean_ruins"),
|
||||
];
|
||||
/// The sprite of a decoration type that a `minecraft:map_decorations`
|
||||
/// entry or a server's packet names.
|
||||
fn decoration_sprite(id: &str) -> Option<&'static str> {
|
||||
Some(match id.strip_prefix("minecraft:").unwrap_or(id) {
|
||||
"player" => "map/decorations/player",
|
||||
"frame" => "map/decorations/frame",
|
||||
"red_marker" => "map/decorations/red_marker",
|
||||
"blue_marker" => "map/decorations/blue_marker",
|
||||
"target_x" => "map/decorations/target_x",
|
||||
"target_point" => "map/decorations/target_point",
|
||||
"player_off_map" => "map/decorations/player_off_map",
|
||||
"player_off_limits" => "map/decorations/player_off_limits",
|
||||
"mansion" => "map/decorations/woodland_mansion",
|
||||
"monument" => "map/decorations/ocean_monument",
|
||||
"red_x" => "map/decorations/red_x",
|
||||
"village_desert" => "map/decorations/desert_village",
|
||||
"village_plains" => "map/decorations/plains_village",
|
||||
"village_savanna" => "map/decorations/savanna_village",
|
||||
"village_snowy" => "map/decorations/snowy_village",
|
||||
"village_taiga" => "map/decorations/taiga_village",
|
||||
"jungle_temple" => "map/decorations/jungle_temple",
|
||||
"swamp_hut" => "map/decorations/swamp_hut",
|
||||
"trial_chambers" => "map/decorations/trial_chambers",
|
||||
"abandoned_camp" => "map/decorations/abandoned_camp",
|
||||
"ancient_city" => "map/decorations/ancient_city",
|
||||
"desert_pyramid" => "map/decorations/desert_pyramid",
|
||||
"mineshaft" => "map/decorations/mineshaft",
|
||||
"ocean_ruin_warm" => "map/decorations/warm_ocean_ruins",
|
||||
_ => return None,
|
||||
})
|
||||
let name = id.strip_prefix("minecraft:").unwrap_or(id);
|
||||
DECORATION_TYPES
|
||||
.iter()
|
||||
.find(|(n, _)| *n == name)
|
||||
.map(|(_, sprite)| *sprite)
|
||||
}
|
||||
/// The type a decoration's sprite stands for, as its registry names it.
|
||||
fn decoration_type(sprite: &str) -> Option<&'static str> {
|
||||
DECORATION_TYPES
|
||||
.iter()
|
||||
.find(|(_, s)| *s == sprite)
|
||||
.map(|(name, _)| *name)
|
||||
}
|
||||
/// `minecraft:map_decorations` of a map item: (sprite, x, z, rotation), in
|
||||
/// the component's order.
|
||||
@@ -275,12 +441,29 @@ impl Game<'_> {
|
||||
}
|
||||
self.maps.data.get_mut(&id)
|
||||
}
|
||||
/// `ClientboundMapItemDataPacket.handle`: the server's colours for a map
|
||||
/// this client does not own (lobby map art, a framed map on a wall).
|
||||
/// `ClientboundMapItemDataPacket.handle`: the server's colours and
|
||||
/// decorations for a map (one carried or framed on a server).
|
||||
pub(super) fn remote_map_item_data(
|
||||
&mut self,
|
||||
p: &horizon_protocol::packets::play::MapItemData,
|
||||
) {
|
||||
let decorations = p.decorations.as_ref().map(|list| {
|
||||
let types = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.builtin.get("minecraft:map_decoration_type"));
|
||||
list.iter()
|
||||
.filter_map(|d| {
|
||||
Some(Decoration {
|
||||
sprite: decoration_sprite(types?.name(d.kind)?)?,
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
rot: (d.rot & 15) as u8,
|
||||
holder: None,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let dimension = self.dimension_id();
|
||||
let map = self
|
||||
.maps
|
||||
@@ -288,6 +471,9 @@ impl Game<'_> {
|
||||
.entry(p.map_id)
|
||||
.or_insert_with(|| MapData::fresh(0.0, 0.0, p.scale.max(0) as u8, dimension));
|
||||
map.locked = p.locked;
|
||||
if let Some(decorations) = decorations {
|
||||
map.decorations = decorations;
|
||||
}
|
||||
let Some(patch) = &p.patch else {
|
||||
map.dirty = false;
|
||||
return;
|
||||
@@ -566,10 +752,14 @@ impl Game<'_> {
|
||||
.collect();
|
||||
let (yaw, dim) = (self.yaw, self.dimension_id());
|
||||
let (nether, game_time) = (self.dim == super::Dim::Nether, self.world.game_time);
|
||||
// The guest whose turn it is, else the host.
|
||||
let me = self.lan_acting.unwrap_or(HOST);
|
||||
for (id, targets) in carried {
|
||||
let Some(m) = self.map(id) else { continue };
|
||||
m.holder(me).carried = game_time;
|
||||
m.drop_holders(game_time);
|
||||
// tickCarriedBy: the player marker while tracking in this dimension.
|
||||
m.decorations.clear();
|
||||
let mut marker = None;
|
||||
if m.tracking && m.dimension == dim {
|
||||
let s = f64::from(1 << m.scale);
|
||||
let dx = ((p.x - f64::from(m.center[0])) / s) as f32;
|
||||
@@ -596,18 +786,19 @@ impl Game<'_> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(sprite) = sprite {
|
||||
m.decorations.push(Decoration {
|
||||
sprite,
|
||||
x: clamp(dx),
|
||||
y: clamp(dz),
|
||||
rot,
|
||||
});
|
||||
}
|
||||
marker = sprite.map(|sprite| Decoration {
|
||||
sprite,
|
||||
x: clamp(dx),
|
||||
y: clamp(dz),
|
||||
rot,
|
||||
holder: Some(me),
|
||||
});
|
||||
}
|
||||
m.set_marker(me, marker);
|
||||
// The item's MAP_DECORATIONS (an explorer map's target), added
|
||||
// after the players as addDecoration would.
|
||||
let s = (1 << m.scale) as f32;
|
||||
let mut items: Vec<Decoration> = Vec::new();
|
||||
for (sprite, x, z, rotation) in targets {
|
||||
let dx = (x - f64::from(m.center[0])) as f32 / s;
|
||||
let dz = (z - f64::from(m.center[1])) as f32 / s;
|
||||
@@ -620,16 +811,95 @@ impl Game<'_> {
|
||||
x: clamp_map_coordinate(dx),
|
||||
y: clamp_map_coordinate(dz),
|
||||
rot: decoration_rotation(nether, game_time, f64::from(rotation)),
|
||||
holder: None,
|
||||
};
|
||||
if !m.decorations.contains(&d) {
|
||||
m.decorations.push(d);
|
||||
if !items.contains(&d) {
|
||||
items.push(d);
|
||||
}
|
||||
}
|
||||
if !m
|
||||
.decorations
|
||||
.iter()
|
||||
.filter(|d| d.holder.is_none())
|
||||
.eq(items.iter())
|
||||
{
|
||||
m.decorations.retain(|d| d.holder.is_some());
|
||||
m.decorations.extend(items);
|
||||
m.decorations_changed();
|
||||
}
|
||||
}
|
||||
for id in held {
|
||||
self.update_map(id);
|
||||
}
|
||||
}
|
||||
/// The maps the player carries, for `status`: how many of their pixels
|
||||
/// are drawn, and their decorations.
|
||||
pub(super) fn maps_status(&self) -> Vec<serde_json::Value> {
|
||||
self.inventory
|
||||
.iter()
|
||||
.chain(std::iter::once(&self.offhand))
|
||||
.flatten()
|
||||
.filter_map(map_id)
|
||||
.filter_map(|id| {
|
||||
let m = self.maps.data.get(&id)?;
|
||||
Some(serde_json::json!({
|
||||
"id": id,
|
||||
"pixels": m.colors.iter().filter(|&&c| c != 0).count(),
|
||||
"decorations": m.decorations.iter().map(|d| d.sprite).collect::<Vec<_>>(),
|
||||
}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `ServerPlayer.synchronizeSpecialItemUpdates` for each map the guest
|
||||
/// carries, and every 10 ticks (`ServerEntity.sendChanges`) for each map
|
||||
/// framed in the level: a `ClientboundMapItemDataPacket` when something
|
||||
/// changed since the guest's last one.
|
||||
pub(super) fn lan_map_updates(&mut self, host: &LanHost, conn: u32) {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let mut ids: Vec<i32> = g
|
||||
.inventory
|
||||
.iter()
|
||||
.chain(std::iter::once(&g.offhand))
|
||||
.flatten()
|
||||
.filter_map(map_id)
|
||||
.collect();
|
||||
let now = self.world.game_time;
|
||||
if now % 10 == 0 && host.hub.here(g) {
|
||||
let framed: Vec<i32> = self
|
||||
.item_frames
|
||||
.iter()
|
||||
.filter_map(|f| f.item.as_ref().and_then(map_id))
|
||||
.collect();
|
||||
for id in framed {
|
||||
if let Some(m) = self.map(id) {
|
||||
m.holder(conn).framed = now;
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let kind = |name: &str| {
|
||||
host.hub.registry_id(
|
||||
"minecraft:map_decoration_type",
|
||||
&format!("minecraft:{name}"),
|
||||
)
|
||||
};
|
||||
for id in ids {
|
||||
if let Some(p) = self
|
||||
.maps
|
||||
.data
|
||||
.get_mut(&id)
|
||||
.and_then(|m| m.update_packet(id, conn, kind))
|
||||
{
|
||||
g.send(&horizon_protocol::packets::play::Clientbound::MapItemData(
|
||||
p,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `MapItem.update` for the player holding map `id`.
|
||||
fn update_map(&mut self, id: i32) {
|
||||
let dim = self.dimension_id();
|
||||
@@ -651,8 +921,9 @@ impl Game<'_> {
|
||||
if nether {
|
||||
radius /= 2;
|
||||
}
|
||||
m.step += 1;
|
||||
let step = m.step;
|
||||
let h = m.holder(self.lan_acting.unwrap_or(HOST));
|
||||
h.step += 1;
|
||||
let step = h.step;
|
||||
let mut changes: Vec<(i32, i32, u8)> = Vec::new();
|
||||
let mut consecutive = false;
|
||||
for img_x in px - radius + 1..px + radius {
|
||||
@@ -918,21 +1189,17 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `renderTwoHandedMap`: the held map (main hand, empty off hand) in view space.
|
||||
pub(super) fn held_map_geometry(
|
||||
/// `renderTwoHandedMap`: map `id` held in both hands, in view space (the
|
||||
/// arms under it are not drawn).
|
||||
pub(super) fn two_handed_map_geometry(
|
||||
&mut self,
|
||||
to_world: glam::Mat4,
|
||||
attack: f32,
|
||||
inverse_height: f32,
|
||||
id: i32,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) -> bool {
|
||||
let Some(id) = self.inventory[self.selected].as_ref().and_then(map_id) else {
|
||||
return false;
|
||||
};
|
||||
if self.offhand.is_some() {
|
||||
return false;
|
||||
}
|
||||
) {
|
||||
let deg = |d: f32| d.to_radians();
|
||||
let sqrt_attack = attack.sqrt();
|
||||
let y_swing = -0.2 * (attack * std::f32::consts::PI).sin();
|
||||
@@ -949,10 +1216,16 @@ impl Game<'_> {
|
||||
))
|
||||
* glam::Mat4::from_rotation_x(deg(tilt * -85.0));
|
||||
let swing = (sqrt_attack * std::f32::consts::PI).sin();
|
||||
// renderMap: background (with 7-pixel border) then the map.
|
||||
let m = base
|
||||
* glam::Mat4::from_rotation_x(deg(swing * 20.0))
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(2.0))
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(2.0));
|
||||
self.held_map(m, id, light, out);
|
||||
}
|
||||
/// `renderMap`: map `id` at pose `m`, its background (with a 7-pixel
|
||||
/// border) then the map.
|
||||
pub(super) fn held_map(&mut self, m: glam::Mat4, id: i32, light: u32, out: &mut Vec<Vertex>) {
|
||||
let deg = |d: f32| d.to_radians();
|
||||
let m = m
|
||||
* glam::Mat4::from_rotation_y(deg(180.0))
|
||||
* glam::Mat4::from_rotation_z(deg(180.0))
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(0.38))
|
||||
@@ -984,7 +1257,6 @@ impl Game<'_> {
|
||||
if has_data {
|
||||
self.map_vertices(id, m, light, out);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -686,40 +686,53 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AbstractMinecart.push(player)` when the player walks into a cart.
|
||||
/// `AbstractMinecart.push` from each player walking into a cart
|
||||
/// (`LivingEntity.pushEntities`): the window's player and every server
|
||||
/// player (single player's own on its server thread, LAN guests). A
|
||||
/// player in a vehicle pushes nothing here. The quarter push the player
|
||||
/// gets back only moves the server's copy of it (`Entity.push` marks
|
||||
/// `needsSync`, which `ServerEntity` sends to the other players only),
|
||||
/// so no player feels it.
|
||||
fn push_carts(&mut self) {
|
||||
let player = self.player.as_ref().map(|p| p.position);
|
||||
let riding = self.riding;
|
||||
for i in 0..self.carts.len() {
|
||||
if Some(self.carts[i].id) == riding {
|
||||
continue;
|
||||
if self.carts.is_empty() {
|
||||
return;
|
||||
}
|
||||
// (feet, box)
|
||||
let mut pushers: Vec<(Vec3, Aabb)> = Vec::new();
|
||||
if self.riding.is_none()
|
||||
&& let Some(p) = self.player.as_ref()
|
||||
{
|
||||
pushers.push((p.position, p.bounds()));
|
||||
}
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
for g in host.hub.guests.iter().filter(|g| {
|
||||
host.hub.here(g) && g.loaded && !g.dead && !g.spectator() && g.vehicle.is_none()
|
||||
}) {
|
||||
let [x, y, z] = g.position;
|
||||
let height = if g.sneaking { 1.5 } else { 1.8 };
|
||||
let bb = Aabb::new(
|
||||
Vec3::new(x - 0.3, y, z - 0.3),
|
||||
Vec3::new(x + 0.3, y + height, z + 0.3),
|
||||
);
|
||||
pushers.push((Vec3::new(x, y, z), bb));
|
||||
}
|
||||
if let Some(pp) = player {
|
||||
let bb = self.carts[i].bounds();
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
let touching = pp.x + 0.3 > lo.x
|
||||
&& pp.x - 0.3 < hi.x
|
||||
&& pp.z + 0.3 > lo.z
|
||||
&& pp.z - 0.3 < hi.z
|
||||
&& pp.y + 1.8 > lo.y
|
||||
&& pp.y < hi.y;
|
||||
if touching && riding.is_none() {
|
||||
let c = &mut self.carts[i];
|
||||
let (mut xa, mut za) = (pp.x - c.position.x, pp.z - c.position.z);
|
||||
let dd = xa * xa + za * za;
|
||||
if dd >= 1.0e-4 {
|
||||
let d = dd.sqrt();
|
||||
let pow = (1.0 / d).min(1.0);
|
||||
xa = xa / d * pow * f64::from(0.1f32) * 0.5;
|
||||
za = za / d * pow * f64::from(0.1f32) * 0.5;
|
||||
c.velocity.x -= xa;
|
||||
c.velocity.z -= za;
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.velocity.x += xa / 4.0;
|
||||
p.velocity.z += za / 4.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (feet, bb) in pushers {
|
||||
for c in &mut self.carts {
|
||||
if !bb.intersects(c.bounds()) {
|
||||
continue;
|
||||
}
|
||||
let (mut xa, mut za) = (feet.x - c.position.x, feet.z - c.position.z);
|
||||
let dd = xa * xa + za * za;
|
||||
if dd < f64::from(1.0e-4f32) {
|
||||
continue;
|
||||
}
|
||||
let d = dd.sqrt();
|
||||
let pow = (1.0 / d).min(1.0);
|
||||
xa = xa / d * pow * f64::from(0.1f32) * 0.5;
|
||||
za = za / d * pow * f64::from(0.1f32) * 0.5;
|
||||
c.velocity.x -= xa;
|
||||
c.velocity.z -= za;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -797,7 +810,7 @@ impl Game<'_> {
|
||||
let ridden = self.guest_vehicles();
|
||||
// By id: an exploding cart can destroy or prime others mid-loop.
|
||||
let ids: Vec<u32> = self.carts.iter().map(|c| c.id).collect();
|
||||
for id in ids {
|
||||
for &id in &ids {
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
continue;
|
||||
};
|
||||
@@ -823,6 +836,33 @@ impl Game<'_> {
|
||||
self.carts.remove(i);
|
||||
}
|
||||
}
|
||||
// On the server for every cart, the ridden ones too.
|
||||
for id in ids {
|
||||
if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
let p = self.carts[i].position;
|
||||
if ticking(p.x.floor() as i32, p.z.floor() as i32) {
|
||||
self.cart_lava_tick(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `lavaHurt` twice a tick in lava: from `LavaFluid.entityInside` in the
|
||||
/// behaviour's `applyEffectsFromBlocks`, then from `AbstractMinecart.tick`'s
|
||||
/// own `isInLava`. The second breaks the cart, or primes a TNT minecart
|
||||
/// (`MinecartTNT.destroy` for a fire source). A cart has no `baseTick`,
|
||||
/// so the fire `lavaIgnite` lights never burns it.
|
||||
fn cart_lava_tick(&mut self, id: u32) {
|
||||
for _ in 0..2 {
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
return;
|
||||
};
|
||||
if !self.in_lava(self.carts[i].bounds()) {
|
||||
return;
|
||||
}
|
||||
let p = self.carts[i].position;
|
||||
self.hurt_cart(i, 4.0, false, true);
|
||||
self.lava_hurt_sound([p.x, p.y, p.z]);
|
||||
}
|
||||
}
|
||||
/// `ClientPacketListener.postAddEntitySoundInstance` for the carts that
|
||||
/// joined the level since the last tick, and `LocalPlayer.startRiding`'s
|
||||
|
||||
@@ -2,43 +2,76 @@
|
||||
//! their tick, frame, chat and command hooks, their HUD panels and what
|
||||
//! they print. With no mod on, `horizon_mods::Mods` holds no engine and
|
||||
//! every hook here returns after one check.
|
||||
use super::{Game, Screen};
|
||||
//!
|
||||
//! Code mods run only in builds with the `mods` feature (release builds):
|
||||
//! without it the game links no wasmtime, and every hook does nothing.
|
||||
//! Content mods (`content_root`) load in every build.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
command::{Dispatcher, vanilla},
|
||||
extras::Corner,
|
||||
extras_hud::{self, Corners},
|
||||
extras_hud::Corners,
|
||||
hud::HudBatch,
|
||||
mod_list::{self, Installed},
|
||||
text::{self, Text},
|
||||
mod_list,
|
||||
};
|
||||
use horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
time::{Duration, Instant},
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
#[cfg(feature = "mods")]
|
||||
use {
|
||||
super::Screen,
|
||||
horizon_client::{
|
||||
extras::Corner,
|
||||
extras_hud,
|
||||
mod_list::Installed,
|
||||
text::{self, Text},
|
||||
},
|
||||
horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status},
|
||||
std::time::Instant,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ModsState {
|
||||
pub(super) mods: Mods,
|
||||
#[cfg(feature = "mods")]
|
||||
mods: Mods,
|
||||
/// `--dev-mod PATH`: always on, reloaded when rebuilt.
|
||||
pub(super) dev: Vec<PathBuf>,
|
||||
/// When dev mods were last checked for a rebuild.
|
||||
#[cfg(feature = "mods")]
|
||||
checked: Option<Instant>,
|
||||
/// The chat's command tree while a running mod owns commands.
|
||||
#[cfg(feature = "mods")]
|
||||
tree: Option<Dispatcher>,
|
||||
}
|
||||
impl ModsState {
|
||||
/// The chat's command tree: vanilla's, plus the running mods' commands.
|
||||
pub(super) fn dispatcher(&self) -> &Dispatcher {
|
||||
self.tree
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| vanilla::game_dispatcher())
|
||||
#[cfg(feature = "mods")]
|
||||
if let Some(tree) = &self.tree {
|
||||
return tree;
|
||||
}
|
||||
vanilla::game_dispatcher()
|
||||
}
|
||||
/// Whether a mod runs: the chat keeps its lines for them.
|
||||
pub(super) fn running(&self) -> bool {
|
||||
#[cfg(feature = "mods")]
|
||||
if self.mods.is_running() {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
/// Whether a mod draws a HUD panel.
|
||||
pub(super) fn has_panels(&self) -> bool {
|
||||
#[cfg(feature = "mods")]
|
||||
if self.mods.panels().next().is_some() {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
/// How often dev mods are checked for a rebuild.
|
||||
#[cfg(feature = "mods")]
|
||||
const RELOAD_CHECK: Duration = Duration::from_secs(1);
|
||||
|
||||
/// A vanilla command name: mods may not take it.
|
||||
#[cfg(feature = "mods")]
|
||||
fn vanilla_command(name: &str) -> bool {
|
||||
let d = vanilla::game_dispatcher();
|
||||
d.nodes[0]
|
||||
@@ -112,6 +145,30 @@ pub(super) fn content_root(
|
||||
}
|
||||
}
|
||||
|
||||
/// A build without the `mods` feature: no mod runs.
|
||||
#[cfg(not(feature = "mods"))]
|
||||
impl Game<'_> {
|
||||
/// Says why the installed and dev mods don't run.
|
||||
pub(super) fn start_mods(&mut self) {
|
||||
let dir = Self::options_path().with_file_name(mod_list::DIR);
|
||||
let found = horizon_mods::find(&dir).len() + self.mods.dev.len();
|
||||
if found > 0 {
|
||||
log::warn!(
|
||||
"Horizon: {found} mod(s) not started: this build has no mod runtime (build with --features mods)"
|
||||
);
|
||||
}
|
||||
}
|
||||
pub(super) fn sync_mods(&mut self) {}
|
||||
pub(super) fn tick_mods(&mut self) {}
|
||||
pub(super) fn mods_frame(&mut self, _elapsed: Duration) {}
|
||||
pub(super) fn mod_command(&mut self, _command: &str) -> bool {
|
||||
false
|
||||
}
|
||||
pub(super) fn mods_chat(&mut self, _line: &str) {}
|
||||
pub(super) fn draw_mod_panels(&self, _batch: &mut HudBatch, _corners: &mut Corners) {}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
impl Game<'_> {
|
||||
/// Reads `horizon-mods/` and the dev mods, and starts the ones turned on.
|
||||
pub(super) fn start_mods(&mut self) {
|
||||
|
||||
@@ -104,7 +104,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `AbstractHorse.mobInteract`. Returns whether the click was used.
|
||||
pub(super) fn interact_mount(&mut self, index: usize, item: &str) -> bool {
|
||||
let held = self.inventory[self.selected].clone();
|
||||
let held = self.held().cloned();
|
||||
let m = &self.mobs[index];
|
||||
let species = m.species();
|
||||
// A server player rides it already (`isVehicle`: no seat, no feeding).
|
||||
|
||||
@@ -237,15 +237,7 @@ impl Game<'_> {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::OpenShaderPackFolder => {
|
||||
let dir = horizon_client::shader_pack::folder();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
|
||||
Event::ReloadShaderPack => {
|
||||
self.load_shader_pack();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -294,14 +286,6 @@ impl Game<'_> {
|
||||
font.reset_cache();
|
||||
self.ui.chat.rescale(&self.gui.atlas);
|
||||
}
|
||||
// A new renderer (resource reload, first window) takes the loaded pack.
|
||||
let stale = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.shader_pack_id() != self.shader_packs.id());
|
||||
if stale && let Err(e) = self.apply_shader_pack() {
|
||||
log::warn!("Horizon: shader pack: {e}");
|
||||
}
|
||||
}
|
||||
/// `Window.updateFullscreenState`: a fullscreen change made by the window
|
||||
/// system is adopted by the option (`Minecraft.fullscreenStateChanged`).
|
||||
|
||||
@@ -38,7 +38,7 @@ use horizon_protocol::{
|
||||
play::{self, Clientbound as C, Serverbound as S},
|
||||
},
|
||||
registry::{Builtin, DimensionType, Dynamic, ceil_log2},
|
||||
text::{Language, format_java, plain},
|
||||
text::{Language, plain},
|
||||
};
|
||||
use horizon_world::{
|
||||
nbt::Tag,
|
||||
@@ -544,7 +544,7 @@ impl Game<'_> {
|
||||
/// (`LivingEntity.forceAddEffect`): the effect replaces any of its kind.
|
||||
/// Other entities' effects are not shown.
|
||||
fn remote_effect(&mut self, p: &play::UpdateMobEffect) {
|
||||
if !self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
if self.remote.as_ref().is_none_or(|r| r.entity_id != p.id) {
|
||||
return;
|
||||
}
|
||||
let Some(id) = self.remote_effect_id(p.effect) else {
|
||||
@@ -641,6 +641,22 @@ impl Game<'_> {
|
||||
("mob_factor", Tag::Float(v[5].to_bits())),
|
||||
]),
|
||||
Component::Profile(p) => profile_tag(p),
|
||||
// ItemStackTemplate.CODEC: id, count and components.
|
||||
Component::Stacks(stacks) => Tag::List(
|
||||
stacks
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let mut entries = vec![
|
||||
("id", Tag::String(self.item_name(t.item).as_ref().into())),
|
||||
("count", Tag::Int(t.count)),
|
||||
];
|
||||
if let Some(c) = self.protocol_components(t) {
|
||||
entries.push(("components", c));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Component::CustomModelData {
|
||||
floats,
|
||||
flags,
|
||||
@@ -694,11 +710,14 @@ impl Game<'_> {
|
||||
}
|
||||
Some(stack)
|
||||
}
|
||||
pub(super) fn chat_line(&mut self, text: String) {
|
||||
/// A line in the connection's chat, with its styles, click and hover
|
||||
/// events (ChatComponent.addMessage).
|
||||
pub(super) fn chat_line(&mut self, content: horizon_client::text::Text) {
|
||||
let text = content.plain();
|
||||
log::info!("[chat] {text}");
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
horizon_client::text::Text::literal(text.clone()),
|
||||
content,
|
||||
None,
|
||||
self.ticks,
|
||||
self.screen == Screen::Chat,
|
||||
@@ -1971,6 +1990,15 @@ impl Game<'_> {
|
||||
}
|
||||
self.sleeping = *bed;
|
||||
}
|
||||
// Entity.DATA_SHARED_FLAGS_ID's sprinting bit: the
|
||||
// server stops the sprint after a hit's extra
|
||||
// knockback (the flag only, as assignValues sets
|
||||
// it: the speed modifier and wasSprinting stay).
|
||||
(0, Value::Byte(flags)) => {
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.sprinting = flags & 0x08 != 0;
|
||||
}
|
||||
}
|
||||
// Entity.DATA_AIR_SUPPLY_ID and
|
||||
// Player.DATA_PLAYER_ABSORPTION_ID: the bubbles
|
||||
// and golden hearts the server's player has.
|
||||
@@ -2381,42 +2409,27 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
C::SystemChat(p) => {
|
||||
let text = plain(
|
||||
&p.content,
|
||||
&self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map(|r| r.lang.clone())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
// ChatListener.handleOverlay.
|
||||
// The component as sent, translated in the client's own
|
||||
// language. ChatListener.handleOverlay for the action bar.
|
||||
let text = remote_text(&p.content);
|
||||
if p.overlay {
|
||||
self.titles.set_overlay(remote_text(&p.content), false);
|
||||
self.titles.set_overlay(text, false);
|
||||
} else {
|
||||
self.chat_line(text);
|
||||
}
|
||||
}
|
||||
C::PlayerChat(p) => {
|
||||
let lang = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map(|r| r.lang.clone())
|
||||
.unwrap_or_default();
|
||||
use horizon_client::scoreboard::tag_to_json;
|
||||
let content = p
|
||||
.unsigned_content
|
||||
.as_ref()
|
||||
.map_or_else(|| p.content.clone(), |t| plain(t, &lang));
|
||||
let line = self.format_chat(&p.chat_type, &content);
|
||||
.map_or_else(|| serde_json::Value::from(p.content.as_str()), tag_to_json);
|
||||
let line = self.format_chat(&p.chat_type, content);
|
||||
self.chat_line(line);
|
||||
}
|
||||
C::DisguisedChat(p) => {
|
||||
let lang = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map(|r| r.lang.clone())
|
||||
.unwrap_or_default();
|
||||
let content = plain(&p.message, &lang);
|
||||
let line = self.format_chat(&p.chat_type, &content);
|
||||
let content = horizon_client::scoreboard::tag_to_json(&p.message);
|
||||
let line = self.format_chat(&p.chat_type, content);
|
||||
self.chat_line(line);
|
||||
}
|
||||
C::PlayerInfoUpdate(p) => {
|
||||
@@ -2569,6 +2582,8 @@ impl Game<'_> {
|
||||
self.drop_remote_chunks_out_of_range();
|
||||
}
|
||||
C::BlockEntityData(p) => self.remote_block_entity_data(p.pos, p.kind, &p.tag),
|
||||
C::OpenSignEditor(p) => self.remote_open_sign_editor(p),
|
||||
C::OpenBook(p) => self.remote_open_book(p.hand),
|
||||
// handleSetSpawn: ClientLevel.setRespawnData, which spawn compasses point at.
|
||||
C::SetDefaultSpawnPosition(p) => {
|
||||
let [x, y, z] = p.pos.pos;
|
||||
@@ -2848,11 +2863,17 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
/// ChatType decoration: `chat.translation_key` with sender/content parameters.
|
||||
fn format_chat(&self, bound: &play::ChatTypeBound, content: &str) -> String {
|
||||
/// ChatType.Bound.decorate: the chat type's translation, with the
|
||||
/// sender, the target and the message as its parameters, in its style.
|
||||
fn format_chat(
|
||||
&self,
|
||||
bound: &play::ChatTypeBound,
|
||||
content: serde_json::Value,
|
||||
) -> horizon_client::text::Text {
|
||||
use horizon_client::scoreboard::{comp_text, tag_to_json};
|
||||
let Some(r) = self.remote.as_ref() else {
|
||||
return content.to_owned();
|
||||
return comp_text(&content);
|
||||
};
|
||||
let sender = plain(&bound.name, &r.lang);
|
||||
let decoration = r
|
||||
.registries
|
||||
.data("minecraft:chat_type", bound.chat_type)
|
||||
@@ -2861,30 +2882,31 @@ impl Game<'_> {
|
||||
Some(Tag::String(s)) => s.to_string_lossy(),
|
||||
_ => "chat.type.text".into(),
|
||||
};
|
||||
let params: Vec<String> = match decoration.and_then(|d| d.get("parameters")) {
|
||||
let parameter = |name: &str| match name {
|
||||
"sender" => tag_to_json(&bound.name),
|
||||
"target" => bound
|
||||
.target
|
||||
.as_ref()
|
||||
.map_or_else(|| serde_json::Value::from(""), tag_to_json),
|
||||
_ => content.clone(),
|
||||
};
|
||||
let with: Vec<serde_json::Value> = match decoration.and_then(|d| d.get("parameters")) {
|
||||
Some(Tag::List(list)) => list
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
Tag::String(s) => match s.to_string_lossy().as_str() {
|
||||
"sender" => sender.clone(),
|
||||
"target" => bound
|
||||
.target
|
||||
.as_ref()
|
||||
.map(|t| plain(t, &r.lang))
|
||||
.unwrap_or_default(),
|
||||
_ => content.to_owned(),
|
||||
},
|
||||
_ => String::new(),
|
||||
Tag::String(s) => parameter(&s.to_string_lossy()),
|
||||
_ => serde_json::Value::from(""),
|
||||
})
|
||||
.collect(),
|
||||
_ => vec![sender, content.to_owned()],
|
||||
_ => vec![parameter("sender"), parameter("content")],
|
||||
};
|
||||
let pattern = r
|
||||
.lang
|
||||
.get(&key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "<%s> %s".into());
|
||||
format_java(&pattern, ¶ms)
|
||||
let mut component = match decoration.and_then(|d| d.get("style")) {
|
||||
Some(style @ Tag::Compound(_)) => tag_to_json(style),
|
||||
_ => serde_json::json!({}),
|
||||
};
|
||||
component["translate"] = key.into();
|
||||
component["with"] = with.into();
|
||||
comp_text(&component)
|
||||
}
|
||||
fn remote_item_data(&mut self, id: i32, stack: &Option<Stack>) {
|
||||
let Some(stack) = self.to_stack(stack) else {
|
||||
@@ -3059,7 +3081,7 @@ impl Game<'_> {
|
||||
self.sleep_timer = super::tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
|
||||
// Gui.tick: the titles, the action bar and the held item's name.
|
||||
self.tick_hud();
|
||||
let ghast_collisions = self.happy_ghast_player_collisions();
|
||||
let entity_collisions = self.player_entity_collisions();
|
||||
let pushed = self.remote_pushes();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
@@ -3126,14 +3148,11 @@ impl Game<'_> {
|
||||
[player.position.x, player.position.y, player.position.z],
|
||||
);
|
||||
let was_flying = player.flying;
|
||||
player.yaw = self.yaw;
|
||||
// As the window's own tick has them: the helmet's Respiration
|
||||
// (decreaseAirSupply) and the dimension's FAST_LAVA, the Nether's
|
||||
// stronger lava current.
|
||||
player.respiration = self.armor[0]
|
||||
.as_ref()
|
||||
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
|
||||
player.fluids.fast_lava = self.dim == Dim::Nether;
|
||||
self.sync_player_body();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let was_gliding = player.fall_flying;
|
||||
let dead = self.screen == Screen::Dead;
|
||||
// LivingEntity.isImmobile: a sleeper lies still in the bed.
|
||||
if let Some(head) = self.sleeping {
|
||||
@@ -3146,7 +3165,7 @@ impl Game<'_> {
|
||||
} else if !dead && !riding {
|
||||
player.velocity = player.velocity.add_vector(pushed);
|
||||
MotionWorld {
|
||||
entities: &ghast_collisions,
|
||||
entities: &entity_collisions,
|
||||
player,
|
||||
scene: &mut self.world,
|
||||
assets: &self.pack.assets,
|
||||
@@ -3158,6 +3177,9 @@ impl Game<'_> {
|
||||
// LocalPlayer.onUpdateAbilities: flight turned on or off (a double
|
||||
// jump, a spectator, landing) is sent to the server.
|
||||
let flying = player.flying;
|
||||
// LocalPlayer.aiStep: a glide this client started is sent to the
|
||||
// server (ServerboundPlayerCommandPacket START_FALL_FLYING).
|
||||
let started_gliding = player.fall_flying && !was_gliding;
|
||||
let events = std::mem::take(&mut player.events);
|
||||
self.previous_eye = self.eye;
|
||||
self.eye += (player.dimensions.eye_height - self.eye) * 0.5;
|
||||
@@ -3197,6 +3219,14 @@ impl Game<'_> {
|
||||
flags: if flying { 2 } else { 0 },
|
||||
}));
|
||||
}
|
||||
if started_gliding {
|
||||
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
|
||||
self.send(S::PlayerCommand(play::PlayerCommand {
|
||||
id,
|
||||
action: 6,
|
||||
data: 0,
|
||||
}));
|
||||
}
|
||||
self.remote_send_state(input);
|
||||
Ok(())
|
||||
}
|
||||
@@ -3739,7 +3769,10 @@ impl Game<'_> {
|
||||
horizon_client::interaction_input::use_on_target(&mut RemoteUseWorld(self), entity);
|
||||
}
|
||||
|
||||
fn remote_use_entity(&mut self) -> bool {
|
||||
/// `Minecraft.startUseItem`'s entity turn for `hand`: Interact goes to
|
||||
/// the server, and whether the click ends there is what this client's
|
||||
/// copy of the entity answers (`MultiPlayerGameMode.interact`).
|
||||
fn remote_use_entity(&mut self, hand: Hand) -> bool {
|
||||
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).
|
||||
@@ -3747,6 +3780,10 @@ impl Game<'_> {
|
||||
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
|
||||
&& self.before_block(hit.distance)
|
||||
{
|
||||
// The main hand's turn takes it; there is no off hand turn.
|
||||
if hand == Hand::Off {
|
||||
return false;
|
||||
}
|
||||
self.send(S::Interact(play::Interact {
|
||||
id: hit.id,
|
||||
hand: 0,
|
||||
@@ -3762,18 +3799,18 @@ impl Game<'_> {
|
||||
let id = self.mobs[i].id as i32;
|
||||
self.send(S::Interact(play::Interact {
|
||||
id,
|
||||
hand: 0,
|
||||
hand: hand_id(hand),
|
||||
location: [0.0, 1.0, 0.0],
|
||||
secondary: sneaking,
|
||||
}));
|
||||
// Player.interactOn: another player passes, and the held item is
|
||||
// used instead (Minecraft.startUseItem).
|
||||
// Player.interactOn: another player passes, and so does a mob
|
||||
// the held item means nothing to: the item is used instead.
|
||||
let player = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.level_players.contains_key(&id));
|
||||
if !player {
|
||||
self.swing_arm();
|
||||
if !player && self.remote_mob_takes(i, hand) {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -4008,21 +4045,60 @@ impl Game<'_> {
|
||||
return false;
|
||||
};
|
||||
// MultiPlayerGameMode.useItem runs the use here too: food starts
|
||||
// being eaten (Consumable.startConsuming).
|
||||
// being eaten (Consumable.startConsuming), a book and quill opens.
|
||||
self.with_hand(hand, |g| g.start_eating(&info));
|
||||
// BowItem.use: startUsingItem here too (with an arrow, or in creative), so that letting
|
||||
// go sends RELEASE_USE_ITEM (Minecraft.handleKeybinds) and the server shoots.
|
||||
if &*held.item == "minecraft:bow" {
|
||||
self.with_hand(hand, |g| {
|
||||
if g.using.is_none() && (g.creative || g.projectile_slot("minecraft:bow").is_some())
|
||||
{
|
||||
g.using = Some((g.held_slot(), 0));
|
||||
}
|
||||
});
|
||||
if self.with_hand(hand, |g| g.remote_use_book(&held)) {
|
||||
return true;
|
||||
}
|
||||
self.with_hand(hand, |g| g.remote_start_held_use(&held));
|
||||
item_has_use(&info)
|
||||
}
|
||||
|
||||
/// `Item.use` runs on the client too (MultiPlayerGameMode.useItem): an
|
||||
/// item held in use until the button lets go starts being used here, so
|
||||
/// that letting go sends RELEASE_USE_ITEM (Minecraft.handleKeybinds) and
|
||||
/// the server ends the use: a bow or a trident is let go, a crossbow's
|
||||
/// charge ends, a shield is lowered, a spyglass stops scoping. Their
|
||||
/// sounds, loaded arrows and thrown tridents come from the server.
|
||||
fn remote_start_held_use(&mut self, held: &ItemStack) {
|
||||
if self.using.is_some() {
|
||||
return;
|
||||
}
|
||||
let start = match &*held.item {
|
||||
// BowItem.use: with an arrow, or in creative.
|
||||
"minecraft:bow" => self.creative || self.projectile_slot("minecraft:bow").is_some(),
|
||||
// CrossbowItem.use: charging when not charged and with ammunition;
|
||||
// a charged one shoots on the server.
|
||||
"minecraft:crossbow" => {
|
||||
held.data().is_none_or(|d| d.charged.is_empty())
|
||||
&& (self.creative || self.projectile_slot("minecraft:crossbow").is_some())
|
||||
}
|
||||
// TridentItem.use: not about to break, Riptide only in water or rain.
|
||||
"minecraft:trident" => {
|
||||
self.use_trident(held);
|
||||
return;
|
||||
}
|
||||
// SpyglassItem.use (its sound is the server's).
|
||||
"minecraft:spyglass" => true,
|
||||
// ShieldItem.use: anything that blocks attacks, off cooldown.
|
||||
_ => {
|
||||
self.raise_shield(self.held_slot());
|
||||
return;
|
||||
}
|
||||
};
|
||||
if start {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
}
|
||||
}
|
||||
/// Whether `stack` stays in use until the button lets go (bows,
|
||||
/// crossbows, tridents, spyglasses, shields): its use only counts ticks here.
|
||||
fn held_until_released(&self, stack: &ItemStack) -> bool {
|
||||
matches!(
|
||||
&*stack.item,
|
||||
"minecraft:bow" | "minecraft:crossbow" | "minecraft:trident" | "minecraft:spyglass"
|
||||
) || self.blocks_attacks(stack).is_some()
|
||||
}
|
||||
|
||||
/// The rest of Minecraft.startUseItem once the main hand's target passed:
|
||||
/// the main hand's item, then the off hand's turn (its block, its item).
|
||||
/// The server does each packet's part with that packet's hand only.
|
||||
@@ -4030,12 +4106,19 @@ impl Game<'_> {
|
||||
if self.remote_use_item(Hand::Main) {
|
||||
return;
|
||||
}
|
||||
if !self.entity_targeted() && self.remote_use_block(Hand::Off) {
|
||||
// The off hand's turn: the entity again, or the block.
|
||||
let off_target = if self.entity_targeted() {
|
||||
self.remote_use_entity(Hand::Off)
|
||||
} else {
|
||||
self.remote_use_block(Hand::Off)
|
||||
};
|
||||
if off_target {
|
||||
return;
|
||||
}
|
||||
self.remote_use_item(Hand::Off);
|
||||
}
|
||||
/// `LivingEntity.updatingUsingItem` on a server client: a drawn bow, and a
|
||||
/// `LivingEntity.updatingUsingItem` on a server client: an item held in
|
||||
/// use (`held_until_released`), and a
|
||||
/// consumable's eating animation and its crumbs. The server eats it and
|
||||
/// sends the stack, the hunger and the sounds.
|
||||
fn remote_tick_using(&mut self) {
|
||||
@@ -4043,8 +4126,9 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let stack = self.stack_in(slot).cloned();
|
||||
// A drawn bow: held until the button lets go (or another slot is picked).
|
||||
if stack.as_ref().is_some_and(|s| &*s.item == "minecraft:bow") {
|
||||
// A drawn bow, a charging crossbow, a raised shield...: held until
|
||||
// the button lets go (or another slot is picked).
|
||||
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));
|
||||
return;
|
||||
@@ -4234,8 +4318,7 @@ impl Game<'_> {
|
||||
/// Keys with a multiplayer meaning; returns true when consumed.
|
||||
pub(super) fn remote_key(&mut self, key: KeyCode) -> Result<bool> {
|
||||
if self.screen != Screen::Playing {
|
||||
if key == KeyCode::KeyE && self.screen == Screen::Inventory
|
||||
|| key == KeyCode::Escape && self.screen == Screen::Inventory
|
||||
if (key == KeyCode::KeyE || key == KeyCode::Escape) && self.screen == Screen::Inventory
|
||||
{
|
||||
self.remote_close_container();
|
||||
self.screen = Screen::Playing;
|
||||
@@ -4679,7 +4762,9 @@ impl Game<'_> {
|
||||
self.send(S::ContainerClose(play::ContainerClose {
|
||||
container: p.container,
|
||||
}));
|
||||
self.chat_line(format!("(Horizon cannot show {menu} screens yet)"));
|
||||
self.chat_line(horizon_client::text::Text::literal(format!(
|
||||
"(Horizon cannot show {menu} screens yet)"
|
||||
)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -5135,7 +5220,7 @@ struct RemoteUseWorld<'g, 'a>(&'g mut Game<'a>);
|
||||
|
||||
impl horizon_client::interaction_input::TargetUseWorld for RemoteUseWorld<'_, '_> {
|
||||
fn use_entity(&mut self) -> bool {
|
||||
self.0.remote_use_entity()
|
||||
self.0.remote_use_entity(Hand::Main)
|
||||
}
|
||||
fn use_block(&mut self) -> bool {
|
||||
self.0.remote_use_block(Hand::Main)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! A server's client right-clicking a mob: vanilla's client runs
|
||||
//! `Player.interactOn` on its own level (`MultiPlayerGameMode.interact`) to
|
||||
//! learn whether the click ends on the mob or goes on to the held item
|
||||
//! (`Minecraft.startUseItem` then sends UseItem). The server does the real
|
||||
//! interaction; this is what the client's copy of the mob answers.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
first_person::Hand,
|
||||
mobs::{Species, equine, llama},
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether mob `i` takes a right click with `hand`'s item on this
|
||||
/// client (`Mob.interact`'s result consumes the action).
|
||||
pub(super) fn remote_mob_takes(&self, i: usize, hand: Hand) -> bool {
|
||||
let Some(m) = self.mobs.get(i).filter(|m| m.alive()) else {
|
||||
return false;
|
||||
};
|
||||
let stack = match hand {
|
||||
Hand::Main => self.stack_in(self.selected),
|
||||
Hand::Off => self.offhand.as_ref(),
|
||||
};
|
||||
let item = stack.map_or("", |s| &*s.item);
|
||||
let baby = m.is_baby();
|
||||
// Mob.checkAndHandleImportantInteractions: a lead, a named name tag,
|
||||
// a spawn egg of the mob's own kind.
|
||||
if item == "minecraft:lead"
|
||||
|| item == "minecraft:name_tag" && stack.is_some_and(|s| s.custom_name().is_some())
|
||||
|| item.strip_suffix("_spawn_egg") == Some(m.kind.id.as_str())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Animal.mobInteract: its food is CONSUME on a client.
|
||||
if m.kind.flags.animal && m.kind.food.iter().any(|f| f.as_str() == item) {
|
||||
return true;
|
||||
}
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
|
||||
match m.species() {
|
||||
// Villager / WanderingTrader.mobInteract: trading, or a baby's head shake.
|
||||
Species::Villager | Species::WanderingTrader => item != "minecraft:villager_spawn_egg",
|
||||
// AbstractHorse, Camel: ridden (or their inventory opened); a baby only eats.
|
||||
s if equine::is_equine(s)
|
||||
|| llama::is_llama(s)
|
||||
|| matches!(s, Species::Camel | Species::CamelHusk) =>
|
||||
{
|
||||
!baby
|
||||
}
|
||||
// Pig / Strider.mobInteract: a saddled one is ridden.
|
||||
Species::Pig | Species::Strider => m.extra.saddled && !sneaking,
|
||||
// HappyGhast.mobInteract: a harnessed adult is ridden.
|
||||
Species::HappyGhast => !baby && m.extra.head.is_some(),
|
||||
// TamableAnimal (wolf, cat, parrot) on a client: CONSUME once tame.
|
||||
Species::Wolf | Species::Cat | Species::Parrot => m.extra.tame,
|
||||
// Allay.mobInteract: gives an item or takes it back.
|
||||
Species::Allay => !item.is_empty() || m.extra.main_hand.is_some(),
|
||||
Species::Cow | Species::Goat => item == "minecraft:bucket" && !baby,
|
||||
Species::Mooshroom => {
|
||||
matches!(
|
||||
item,
|
||||
"minecraft:bucket" | "minecraft:bowl" | "minecraft:shears"
|
||||
) && !baby
|
||||
}
|
||||
Species::Sheep => item == "minecraft:shears" || item.ends_with("_dye"),
|
||||
// Bucketable.bucketMobPickup.
|
||||
Species::Cod
|
||||
| Species::Salmon
|
||||
| Species::TropicalFish
|
||||
| Species::Pufferfish
|
||||
| Species::Axolotl
|
||||
| Species::Tadpole => item == "minecraft:water_bucket",
|
||||
Species::Armadillo => item == "minecraft:brush",
|
||||
// ZombieVillager.mobInteract: CONSUME for a golden apple.
|
||||
Species::ZombieVillager => item == "minecraft:golden_apple",
|
||||
// Creeper.mobInteract: CREEPER_IGNITERS.
|
||||
Species::Creeper => {
|
||||
matches!(item, "minecraft:flint_and_steel" | "minecraft:fire_charge")
|
||||
}
|
||||
// PiglinAi.mobInteract: an adult admires what it loves.
|
||||
Species::Piglin => {
|
||||
!baby
|
||||
&& self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:piglin_loved")
|
||||
.is_some_and(|tag| tag.contains(item))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,8 @@ impl Game<'_> {
|
||||
self.end
|
||||
.add_remote_eye(id, position, [velocity.x, velocity.y, velocity.z])
|
||||
}
|
||||
// FishingHook.recreateFromPacket: the owner's id is the data.
|
||||
"minecraft:fishing_bobber" => self.spawn_remote_hook(id, at, data),
|
||||
"minecraft:end_crystal" => self.crystals.push(super::dragon::Crystal {
|
||||
id,
|
||||
position: at,
|
||||
@@ -114,6 +116,7 @@ impl Game<'_> {
|
||||
/// `handleSetEntityData` for these types: what changes how they look.
|
||||
pub(super) fn remote_projectile_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
|
||||
let id = id as u32;
|
||||
self.remote_hook_data(id, values);
|
||||
if self.potions.has_thrown(id) {
|
||||
// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it is.
|
||||
for (index, _, value) in values {
|
||||
@@ -205,6 +208,7 @@ impl Game<'_> {
|
||||
c.position = step(c.id, c.position);
|
||||
}
|
||||
self.potions.step_remote_thrown(&mut step);
|
||||
self.step_remote_hooks(&mut step);
|
||||
self.end.step_remote_eyes(|id, at| {
|
||||
let at = step(id, Vec3::new(at[0], at[1], at[2]));
|
||||
[at.x, at.y, at.z]
|
||||
@@ -220,6 +224,7 @@ impl Game<'_> {
|
||||
self.crystals.retain(|c| c.id != id);
|
||||
self.potions.remove_remote_thrown(id);
|
||||
self.end.remove_remote_eye(id);
|
||||
self.remove_remote_hook(id);
|
||||
}
|
||||
|
||||
/// Every one a server sent (a new connection, level or dimension).
|
||||
@@ -232,6 +237,7 @@ impl Game<'_> {
|
||||
self.crystals.clear();
|
||||
self.potions.clear_thrown();
|
||||
self.end.clear_remote_eyes();
|
||||
self.clear_remote_hooks();
|
||||
}
|
||||
|
||||
/// Their kind and place, for `status`.
|
||||
@@ -260,6 +266,7 @@ impl Game<'_> {
|
||||
for (id, p) in self.end.eye_positions() {
|
||||
out.push(json!({"id": id, "kind": "minecraft:eye_of_ender", "position": p}));
|
||||
}
|
||||
out.extend(self.hooks_status());
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -104,45 +104,6 @@ impl Game<'_> {
|
||||
},
|
||||
)
|
||||
}
|
||||
/// What a shader pack's `Frame` uniform reads of the game this frame.
|
||||
pub(super) fn shader_inputs(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
partial_tick: f32,
|
||||
) -> horizon_client::gfx::ShaderInputs {
|
||||
let (rain, thunder) = if self.dim == Dim::Overworld {
|
||||
self.world.weather.levels(self.clock.fraction() as f32)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
let at = [camera.x, camera.y, camera.z];
|
||||
let day = self.world.day_time;
|
||||
let has_sky_light = self.dim == Dim::Overworld;
|
||||
// CommonUniforms.getEyeBrightness: the camera entity's eye block.
|
||||
let eye = self.eye_position().unwrap_or(at).map(|v| v.floor() as i32);
|
||||
let light = self.world.light(eye[0], eye[1], eye[2]);
|
||||
let eye_light = [light & 15, if has_sky_light { light >> 4 } else { 0 }];
|
||||
horizon_client::gfx::ShaderInputs {
|
||||
rain,
|
||||
thunder,
|
||||
submerged: match fog_type(&self.world, self.res.facts(), at, self.forward()) {
|
||||
FogType::Atmospheric => 0,
|
||||
FogType::Water => 1,
|
||||
FogType::Lava => 2,
|
||||
FogType::PowderSnow => 3,
|
||||
},
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => 0,
|
||||
Dim::Nether => -1,
|
||||
Dim::End => 1,
|
||||
},
|
||||
has_sky_light,
|
||||
moon_phase: day.div_euclid(24_000).rem_euclid(8) as u8,
|
||||
day_fraction: (day.rem_euclid(24_000) as f32 + partial_tick) / 24_000.0,
|
||||
partial_tick,
|
||||
eye_light,
|
||||
}
|
||||
}
|
||||
/// `SkyRenderer.shouldRenderDarkDisc`: eye below the horizon (y 63) and not under water.
|
||||
pub(super) fn dark_disc(&self, camera: glam::DVec3) -> bool {
|
||||
camera.y < 63.0
|
||||
|
||||
@@ -1054,6 +1054,7 @@ impl Game<'_> {
|
||||
let r = &mut self.world.random;
|
||||
let bonus = r.triangle(0.0, 0.11485000000000001);
|
||||
mob.attrs.follow_range += mob.kind.follow_range * bonus;
|
||||
mob.update_max_visited_nodes();
|
||||
let _left_handed = r.next_float() < 0.05;
|
||||
if !self.warden_fits(&mob) {
|
||||
continue;
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
//! The selected Horizon shader pack (`shaderPack` in `horizon-graphics.txt`,
|
||||
//! docs/shader-packs.md): loaded from `shaderpacks/`, given to the renderer,
|
||||
//! and loaded again when the selection changes, on F3 + T, or when its files
|
||||
//! change (checked twice a second). A pack that fails leaves the last good
|
||||
//! one (or the vanilla picture) on and says why in chat.
|
||||
use super::Game;
|
||||
use horizon_client::shader_pack::{self, ShaderPack, Stamp};
|
||||
use horizon_client::text::Text;
|
||||
use horizon_shaderpack::properties::SkyDirectives;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ShaderPacks {
|
||||
/// The pack the renderer runs.
|
||||
pack: Option<ShaderPack>,
|
||||
/// The selection and its files at the last load, success or not.
|
||||
selected: String,
|
||||
stamp: Stamp,
|
||||
checked: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ShaderPacks {
|
||||
/// The loaded pack's folder name.
|
||||
pub(super) fn id(&self) -> Option<&str> {
|
||||
self.pack.as_ref().map(|p| p.id.as_str())
|
||||
}
|
||||
/// What a loaded Java pack changes in the vanilla sky of `dimension`
|
||||
/// (0 overworld, -1 the Nether, 1 the End): its `sunPathRotation`, and
|
||||
/// whether it keeps the clouds, sun and moon (Iris's sky mixins).
|
||||
pub(super) fn java_sky(&self, dimension: i8) -> Option<(f32, SkyDirectives)> {
|
||||
let java = self.pack.as_ref()?.java.as_ref()?;
|
||||
let folder = &java.folders[java.folder_for(dimension)?];
|
||||
Some((folder.directives.sun_path_rotation, java.sky))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn dir() -> PathBuf {
|
||||
shader_pack::folder()
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Load the selected pack and give it to the renderer.
|
||||
pub(super) fn load_shader_pack(&mut self) {
|
||||
let id = self.settings.horizon.shader_pack.clone();
|
||||
let dir = dir();
|
||||
let state = &mut self.shader_packs;
|
||||
state.selected = id.clone();
|
||||
state.checked = Some(Instant::now());
|
||||
let pack = if id.is_empty() {
|
||||
state.stamp = Stamp::default();
|
||||
None
|
||||
} else {
|
||||
state.stamp = Stamp::of(&dir.join(&id));
|
||||
// Java packs are told which GPU they run on.
|
||||
let (vendor, renderer) = self.renderer.as_ref().map_or(("Unknown", ""), |r| {
|
||||
let name = r.info.name.as_str();
|
||||
(
|
||||
horizon_client::java_pack::vendor_name(r.info.vendor, name),
|
||||
name,
|
||||
)
|
||||
});
|
||||
let values = shader_pack::load_values(&dir, &id);
|
||||
match ShaderPack::load_any(&dir.join(&id), &values, vendor, renderer) {
|
||||
Ok(pack) => Some(pack),
|
||||
Err(e) => return self.shader_pack_error(&id, &e),
|
||||
}
|
||||
};
|
||||
if let Some(r) = self.renderer.as_mut()
|
||||
&& let Err(e) = r.set_shader_pack(pack.as_ref())
|
||||
{
|
||||
return self.shader_pack_error(&id, &e.to_string());
|
||||
}
|
||||
self.shader_packs.pack = pack;
|
||||
self.ui.options.shader_pack_error = None;
|
||||
}
|
||||
/// Give the loaded pack to the renderer (also after it is rebuilt).
|
||||
pub(super) fn apply_shader_pack(&mut self) -> horizon_client::Result<()> {
|
||||
match self.renderer.as_mut() {
|
||||
Some(r) => r.set_shader_pack(self.shader_packs.pack.as_ref()),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
/// Every frame: a new selection or edited pack files load it again.
|
||||
pub(super) fn poll_shader_pack(&mut self) {
|
||||
let state = &mut self.shader_packs;
|
||||
if self.settings.horizon.shader_pack != state.selected {
|
||||
return self.load_shader_pack();
|
||||
}
|
||||
if state.selected.is_empty()
|
||||
|| state
|
||||
.checked
|
||||
.is_some_and(|t| t.elapsed() < Duration::from_millis(500))
|
||||
{
|
||||
return;
|
||||
}
|
||||
state.checked = Some(Instant::now());
|
||||
if Stamp::of(&dir().join(&state.selected)) != state.stamp {
|
||||
self.load_shader_pack();
|
||||
}
|
||||
}
|
||||
/// A file dropped on the window: a shader pack while the Shader Packs
|
||||
/// page shows (`OptionsUi::drop_shader_pack`).
|
||||
pub(super) fn drop_shader_pack(&mut self, path: &std::path::Path) {
|
||||
match self.ui.options.drop_shader_pack(&mut self.settings, path) {
|
||||
Some(Ok(())) => {
|
||||
self.settings.save(&Self::options_path());
|
||||
self.load_shader_pack();
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::warn!("Horizon: shader pack {}: {e}", path.display());
|
||||
self.system_message(Text::literal(format!("Shader pack: {e}")));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
fn shader_pack_error(&mut self, id: &str, error: &str) {
|
||||
log::warn!("Horizon: shader pack {id}: {error}");
|
||||
// The Shader Packs page says why on the pack's row: the first line,
|
||||
// since translator errors go on with a drawn source excerpt.
|
||||
let first = error.lines().map(str::trim).find(|l| !l.is_empty());
|
||||
self.ui.options.shader_pack_error =
|
||||
Some((id.to_owned(), first.unwrap_or(error).to_owned()));
|
||||
self.system_message(Text::literal(format!("Shader pack {id}: {error}")));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
//! The sign editing screen (`SignEditScreen` / `HangingSignEditScreen`):
|
||||
//! opened when a sign is placed, four lines limited to the sign's line
|
||||
//! width, Up/Down/Enter move between lines, Escape or Done closes.
|
||||
use super::Game;
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
hud::{HudBatch, rgba},
|
||||
signs::wood_type,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
use std::time::Instant;
|
||||
use winit::{
|
||||
event::{ElementState, KeyEvent},
|
||||
@@ -33,14 +34,95 @@ impl Game<'_> {
|
||||
self.right = false;
|
||||
self.capture(false);
|
||||
}
|
||||
/// `AbstractSignEditScreen.removed`: a server's client sends what it
|
||||
/// wrote (ServerboundSignUpdatePacket); the window's own world keeps it
|
||||
/// and sends it to its guests (`sendBlockUpdated`).
|
||||
pub(super) fn close_sign_editor(&mut self) {
|
||||
if let Some(edit) = self.sign_edit.take() {
|
||||
self.world
|
||||
.modified
|
||||
.insert((edit.pos.0 >> 4, edit.pos.2 >> 4));
|
||||
if self.remote.is_some() {
|
||||
if let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&edit.pos) {
|
||||
let side = if edit.front { &sign.front } else { &sign.back };
|
||||
self.send(S::SignUpdate(play::SignUpdate {
|
||||
pos: [edit.pos.0, edit.pos.1, edit.pos.2],
|
||||
lines: side.lines.clone(),
|
||||
front: edit.front,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
self.mark_block_entity_updated(edit.pos);
|
||||
}
|
||||
}
|
||||
self.capture(true);
|
||||
}
|
||||
/// `ServerPlayer.openTextEdit` for the guest whose action opened a sign:
|
||||
/// the block, then the editor, on its client, and the guest may write on it.
|
||||
pub(super) fn lan_open_sign_editor(&mut self, host: &mut LanHost, conn: u32, edit: SignEditor) {
|
||||
let pos = [edit.pos.0, edit.pos.1, edit.pos.2];
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]).0 as i32;
|
||||
host.hub
|
||||
.send_to(conn, &C::BlockUpdate(play::BlockUpdate { pos, state }));
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::OpenSignEditor(play::OpenSignEditor {
|
||||
pos,
|
||||
front: edit.front,
|
||||
}),
|
||||
);
|
||||
host.play.entry(conn).or_default().sign_editing = Some(pos);
|
||||
}
|
||||
/// `ServerGamePacketListenerImpl.handleSignUpdate`: the lines, formatting
|
||||
/// codes stripped, go on the sign the guest may write on unless it is
|
||||
/// waxed (`SignBlockEntity.updateSignText`); a guest gone out of reach
|
||||
/// may no longer write (`playerIsTooFarAwayToEdit`).
|
||||
pub(super) fn lan_sign_update(&mut self, host: &mut LanHost, conn: u32, p: play::SignUpdate) {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let near = super::lan_stations::within_reach(g, p.pos);
|
||||
let Some(play) = host.play.get_mut(&conn) else {
|
||||
return;
|
||||
};
|
||||
if !near {
|
||||
play.sign_editing = None;
|
||||
}
|
||||
if play.sign_editing != Some(p.pos) {
|
||||
log::warn!("{} just tried to change non-editable sign", g.name);
|
||||
return;
|
||||
}
|
||||
let key = (p.pos[0], p.pos[1], p.pos[2]);
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) else {
|
||||
return;
|
||||
};
|
||||
if sign.waxed {
|
||||
return;
|
||||
}
|
||||
let side = if p.front {
|
||||
&mut sign.front
|
||||
} else {
|
||||
&mut sign.back
|
||||
};
|
||||
for (line, new) in side.lines.iter_mut().zip(&p.lines) {
|
||||
*line = horizon_client::clipboard::strip_formatting(new);
|
||||
}
|
||||
play.sign_editing = None;
|
||||
self.mark_block_entity_updated(key);
|
||||
}
|
||||
/// `ClientPacketListener.handleOpenSignEditor`: the editor on a sign this
|
||||
/// client has.
|
||||
pub(super) fn remote_open_sign_editor(&mut self, p: play::OpenSignEditor) {
|
||||
let key = (p.pos[0], p.pos[1], p.pos[2]);
|
||||
if matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
Some(BlockEntity::Sign(_))
|
||||
) {
|
||||
self.open_sign_editor(key, p.front);
|
||||
} else {
|
||||
log::warn!(
|
||||
"ignoring openTextEdit on a block that is no sign at {:?}",
|
||||
p.pos
|
||||
);
|
||||
}
|
||||
}
|
||||
pub(super) fn sign_edit_key(&mut self, event: &KeyEvent) {
|
||||
if event.state != ElementState::Pressed {
|
||||
return;
|
||||
|
||||
@@ -367,11 +367,6 @@ impl Loading {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
packs::open_folder(&dir);
|
||||
}
|
||||
Event::OpenShaderPackFolder => {
|
||||
let dir = horizon_client::shader_pack::folder();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
packs::open_folder(&dir);
|
||||
}
|
||||
Event::Credits => {
|
||||
if let Some(gui) = &self.gui {
|
||||
let name = self.options.as_ref().map_or_else(
|
||||
@@ -387,9 +382,6 @@ impl Loading {
|
||||
self.menu.win = Some((screen, Instant::now()));
|
||||
}
|
||||
}
|
||||
Event::ReloadShaderPack => {
|
||||
self.save_menu_settings();
|
||||
}
|
||||
// The startup menu runs before the game opens a control socket.
|
||||
Event::ButtonClick | Event::CopyAgentDetails => {}
|
||||
}
|
||||
@@ -455,17 +447,6 @@ impl Loading {
|
||||
}
|
||||
self.apply_menu_settings();
|
||||
}
|
||||
WindowEvent::DroppedFile(path) => {
|
||||
match self
|
||||
.menu
|
||||
.options
|
||||
.drop_shader_pack(&mut self.settings, &path)
|
||||
{
|
||||
Some(Ok(())) => self.save_menu_settings(),
|
||||
Some(Err(e)) => log::warn!("Horizon: shader pack {}: {e}", path.display()),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
WindowEvent::RedrawRequested => self.draw(),
|
||||
WindowEvent::ModifiersChanged(modifiers) => self.menu.modifiers = modifiers.state(),
|
||||
WindowEvent::Focused(false) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ pub(super) const HELP: &[&str] = &[
|
||||
"use X Y Z open the block's menu as if right-clicked",
|
||||
"click-slot ID [right] [shift] click a menu slot (0-35 inventory, 300+ container, 600+ station, 650 result)",
|
||||
"click-gui X Y [right] [shift] click at GUI pixel X Y relative to the menu's top-left corner",
|
||||
"book [next|back|take|done] the open book or lectern: its page, or press one of its buttons",
|
||||
"book [next|back|take|sign|done] the open book or lectern: its page, or press one of its buttons",
|
||||
"type TEXT | type-back type into the anvil name box / delete one character",
|
||||
"enchant ID LEVEL enchant the held item (books store the enchantment)",
|
||||
"potion ITEM POTION give a potion item (potion, splash_potion, lingering_potion, tipped_arrow)",
|
||||
|
||||
@@ -5,34 +5,13 @@ use super::Game;
|
||||
use horizon_client::{
|
||||
gossip::{self, GossipType},
|
||||
items::ItemStack,
|
||||
mobs::{Mob, Species, spawning, villager},
|
||||
mobs::{Mob, Species, spawning, villager, villager_brain},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
fn block_of(p: Vec3) -> [i32; 3] {
|
||||
[p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32]
|
||||
}
|
||||
/// `SpawnUtil.Strategy.LEGACY_IRON_GOLEM`: what a golem will not land on.
|
||||
fn golem_floor_ok(name: &str) -> bool {
|
||||
!matches!(
|
||||
name,
|
||||
"minecraft:cobweb"
|
||||
| "minecraft:cactus"
|
||||
| "minecraft:glass_pane"
|
||||
| "minecraft:conduit"
|
||||
| "minecraft:ice"
|
||||
| "minecraft:tnt"
|
||||
| "minecraft:glowstone"
|
||||
| "minecraft:beacon"
|
||||
| "minecraft:sea_lantern"
|
||||
| "minecraft:frosted_ice"
|
||||
| "minecraft:tinted_glass"
|
||||
| "minecraft:glass"
|
||||
) && !name.ends_with("_stained_glass_pane")
|
||||
&& !name.ends_with("_stained_glass")
|
||||
&& !name.ends_with("leaves")
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn villager_index(&self, id: u32) -> Option<usize> {
|
||||
self.mobs
|
||||
@@ -76,88 +55,46 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let now = self.world.game_time;
|
||||
if !villager::ext(&self.mobs[i]).is_some_and(|e| e.wants_golem(now)) {
|
||||
let Some(near) = villager::golem_summoners(&self.mobs, i, now, needed) else {
|
||||
return;
|
||||
}
|
||||
let at = self.mobs[i].position;
|
||||
let nearby: Vec<usize> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| {
|
||||
m.species() == Species::Villager
|
||||
&& m.alive()
|
||||
&& (m.position.x - at.x).abs() <= 10.0
|
||||
&& (m.position.y - at.y).abs() <= 10.0
|
||||
&& (m.position.z - at.z).abs() <= 10.0
|
||||
})
|
||||
.map(|(j, _)| j)
|
||||
.collect();
|
||||
let wanting = nearby
|
||||
.iter()
|
||||
.filter(|j| villager::ext(&self.mobs[**j]).is_some_and(|e| e.wants_golem(now)))
|
||||
.take(5)
|
||||
.count();
|
||||
if wanting < needed {
|
||||
return;
|
||||
}
|
||||
if self.try_spawn_iron_golem(block_of(at)) {
|
||||
for j in nearby {
|
||||
villager::ext_mut(&mut self.mobs[j]).golem_until = now + 599;
|
||||
};
|
||||
if self.try_spawn_iron_golem(block_of(self.mobs[i].position)) {
|
||||
for j in near {
|
||||
villager_brain::golem_detected(&mut self.mobs[j], now);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `SpawnUtil.trySpawnMob(IRON_GOLEM, ..., 10 attempts, 8 across, 6 up
|
||||
/// and down, LEGACY_IRON_GOLEM)`.
|
||||
/// `SpawnUtil.trySpawnMob(IRON_GOLEM, ...)`: a golem near `start`, with
|
||||
/// the ambient sound `trySpawnMob` plays.
|
||||
fn try_spawn_iron_golem(&mut self, start: [i32; 3]) -> bool {
|
||||
for _ in 0..10 {
|
||||
let r = &mut self.world.random;
|
||||
let dx = r.next_int_bound(17).unwrap_or(8) - 8;
|
||||
let dz = r.next_int_bound(17).unwrap_or(8) - 8;
|
||||
let (x, z) = (start[0] + dx, start[2] + dz);
|
||||
let mut y = start[1] + 6;
|
||||
let mut above = self.world.block(x, y, z);
|
||||
let mut found = None;
|
||||
for _ in 0..=12 {
|
||||
y -= 1;
|
||||
let here = self.world.block(x, y, z);
|
||||
let f = self.res.facts().get(here);
|
||||
let above_f = self.res.facts().get(above);
|
||||
let air_or_liquid = self.res.facts().is_air(above)
|
||||
|| above_f.fluid != horizon_client::blocks::Fluid::None;
|
||||
let solid =
|
||||
self.res.mob_data().state(here).flags & horizon_client::mobs::data::SOLID != 0
|
||||
|| f.name.as_ref() == "minecraft:powder_snow";
|
||||
if golem_floor_ok(&f.name) && air_or_liquid && solid {
|
||||
found = Some(y + 1);
|
||||
break;
|
||||
}
|
||||
above = here;
|
||||
}
|
||||
let Some(y) = found else { continue };
|
||||
// IronGolem.checkSpawnRules: a floor and three free blocks.
|
||||
let free = |g: &Self, yy: i32| {
|
||||
g.res.mob_data().state(g.world.block(x, yy, z)).flags
|
||||
& horizon_client::mobs::data::VALID_EMPTY_SPAWN
|
||||
!= 0
|
||||
};
|
||||
if !(0..4).all(|k| free(self, y + k)) {
|
||||
continue;
|
||||
}
|
||||
let at = [f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5];
|
||||
if let Some(id) = self.spawn_mob_for("minecraft:iron_golem", at, false, true)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
let sound = m.kind.sounds[0];
|
||||
if !sound.is_empty() {
|
||||
let at = m.center();
|
||||
self.audio
|
||||
.play(&format!("minecraft:{sound}"), Some(at), 1.0, 1.0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let Some(kind) = self.res.mob_types().get("minecraft:iron_golem").cloned() else {
|
||||
return false;
|
||||
};
|
||||
let id = self.next_entity + 1;
|
||||
let mut random = self.world.random.clone();
|
||||
let golem = villager::summon_golem(
|
||||
&self.mob_world_base(&[]),
|
||||
start,
|
||||
&mut random,
|
||||
|random, at, yaw| {
|
||||
let mut m = Mob::new(id, kind.clone(), at, yaw);
|
||||
villager::finalize_summoned_golem(&mut m, random);
|
||||
m
|
||||
},
|
||||
);
|
||||
self.world.random = random;
|
||||
let Some(golem) = golem else {
|
||||
return false;
|
||||
};
|
||||
self.next_entity = id;
|
||||
let sound = golem.kind.sounds[0];
|
||||
if !sound.is_empty() {
|
||||
let at = golem.center();
|
||||
self.audio
|
||||
.play(&format!("minecraft:{sound}"), Some(at), 1.0, 1.0);
|
||||
}
|
||||
false
|
||||
self.mobs.push(golem);
|
||||
true
|
||||
}
|
||||
|
||||
/// `VillagerMakeLove.tryToGiveBirth`: both eat, a free bed within 48
|
||||
|
||||
@@ -22,6 +22,14 @@ const PLAYER_KNOCKBACK: f32 = 1.22;
|
||||
/// `BreezeWindCharge.RADIUS`.
|
||||
const BREEZE_RADIUS: f32 = 3.0;
|
||||
|
||||
/// A burst `wind_burst` puts off until a guest's turn is over.
|
||||
pub(super) struct Burst {
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
multiplier: f32,
|
||||
breeze: bool,
|
||||
wind_charge: bool,
|
||||
}
|
||||
/// `Projectile.deflect` with `ProjectileDeflection.REVERSE`.
|
||||
pub(super) fn reverse(p: &mut Projectile, jitter: f32) {
|
||||
p.velocity = Vec3::new(
|
||||
@@ -196,7 +204,9 @@ impl Game<'_> {
|
||||
set.into_iter().collect()
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` for a calculator that damages nothing:
|
||||
/// knockback scaled by distance, exposure and the multiplier.
|
||||
/// knockback scaled by distance, exposure and the multiplier. Returns the
|
||||
/// guests it pushed and by how much (`hitPlayers`), which their clients
|
||||
/// apply from the burst's packet.
|
||||
fn wind_knockback(
|
||||
&mut self,
|
||||
center: [f64; 3],
|
||||
@@ -204,7 +214,7 @@ impl Game<'_> {
|
||||
multiplier: f32,
|
||||
wind_charge: bool,
|
||||
blocklike: bool,
|
||||
) {
|
||||
) -> Vec<(u32, [f64; 3])> {
|
||||
let diameter = f64::from(radius * 2.0);
|
||||
let distance = |p: [f64; 3]| {
|
||||
((p[0] - center[0]).powi(2) + (p[1] - center[1]).powi(2) + (p[2] - center[2]).powi(2))
|
||||
@@ -223,42 +233,30 @@ impl Game<'_> {
|
||||
[d[0] / l, d[1] / l, d[2] / l]
|
||||
}
|
||||
};
|
||||
let multiplier = f64::from(multiplier);
|
||||
// The player (not a spectator; a flying creative player takes none).
|
||||
if let Some(p) = self.player.as_ref()
|
||||
&& !self.spectator()
|
||||
{
|
||||
let feet = [p.position.x, p.position.y, p.position.z];
|
||||
let dist = distance(feet) / diameter;
|
||||
let flying = self.creative && p.flying;
|
||||
if dist <= 1.0 && !flying {
|
||||
let w = f64::from(p.dimensions.width) / 2.0;
|
||||
let h = f64::from(p.dimensions.height);
|
||||
let eye = [
|
||||
feet[0],
|
||||
feet[1] + f64::from(p.dimensions.eye_height),
|
||||
feet[2],
|
||||
];
|
||||
let exposure = self.seen_percent(
|
||||
center,
|
||||
[feet[0] - w, feet[1], feet[2] - w],
|
||||
[feet[0] + w, feet[1] + h, feet[2] + w],
|
||||
);
|
||||
let power = (1.0 - dist) * f64::from(exposure) * multiplier;
|
||||
let dir = direction(eye);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.velocity = Vec3::new(
|
||||
p.velocity.x + dir[0] * power,
|
||||
p.velocity.y + dir[1] * power,
|
||||
p.velocity.z + dir[2] * power,
|
||||
);
|
||||
// ServerPlayer.onExplosionHit: a wind charge's burst cancels the
|
||||
// fall; any other burst ends the grace of the last impulse.
|
||||
let at = p.position;
|
||||
p.impulse.set_ignore_fall_damage(wind_charge, Some(at));
|
||||
// The player, then every other player in the level (LAN guests),
|
||||
// each swapped in as the player. In a guest's turn (a mace's wind
|
||||
// burst) the player is that guest.
|
||||
let own = self.wind_push_player(center, radius, multiplier, wind_charge);
|
||||
let mut pushed: Vec<_> = self.lan_acting.zip(own).into_iter().collect();
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
let guests: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in guests {
|
||||
let push = self.as_guest(&mut host, conn, |g| {
|
||||
g.wind_push_player(center, radius, multiplier, wind_charge)
|
||||
});
|
||||
if let Some(Some(push)) = push {
|
||||
pushed.push((conn, push));
|
||||
}
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
let multiplier = f64::from(multiplier);
|
||||
let mut mobs = std::mem::take(&mut self.mobs);
|
||||
for m in mobs.iter_mut().filter(|m| m.alive()) {
|
||||
let feet = [m.position.x, m.position.y, m.position.z];
|
||||
@@ -351,6 +349,67 @@ impl Game<'_> {
|
||||
push(t.position, &mut t.velocity, self);
|
||||
}
|
||||
self.tnts = tnts;
|
||||
pushed
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` for the player (not a spectator, nor a
|
||||
/// creative player flying): the push, less with explosion knockback
|
||||
/// resistance, and `ServerPlayer.onExplosionHit`. Returns the push.
|
||||
fn wind_push_player(
|
||||
&mut self,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
multiplier: f32,
|
||||
wind_charge: bool,
|
||||
) -> Option<[f64; 3]> {
|
||||
if self.spectator() {
|
||||
return None;
|
||||
}
|
||||
let resistance = self
|
||||
.player
|
||||
.as_mut()
|
||||
.and_then(|p| p.attribute("minecraft:explosion_knockback_resistance").ok())
|
||||
.unwrap_or(0.0);
|
||||
let creative = self.creative;
|
||||
let p = self.player.as_ref()?;
|
||||
let feet = [p.position.x, p.position.y, p.position.z];
|
||||
let dist = ((feet[0] - center[0]).powi(2)
|
||||
+ (feet[1] - center[1]).powi(2)
|
||||
+ (feet[2] - center[2]).powi(2))
|
||||
.sqrt()
|
||||
/ f64::from(radius * 2.0);
|
||||
if dist > 1.0 || creative && p.flying {
|
||||
return None;
|
||||
}
|
||||
let w = f64::from(p.dimensions.width) / 2.0;
|
||||
let h = f64::from(p.dimensions.height);
|
||||
let eye = [
|
||||
feet[0] - center[0],
|
||||
feet[1] + f64::from(p.dimensions.eye_height) - center[1],
|
||||
feet[2] - center[2],
|
||||
];
|
||||
let exposure = self.seen_percent(
|
||||
center,
|
||||
[feet[0] - w, feet[1], feet[2] - w],
|
||||
[feet[0] + w, feet[1] + h, feet[2] + w],
|
||||
);
|
||||
let power = (1.0 - dist) * f64::from(exposure) * f64::from(multiplier) * (1.0 - resistance);
|
||||
let l = (eye[0] * eye[0] + eye[1] * eye[1] + eye[2] * eye[2]).sqrt();
|
||||
let push = if l < 1.0e-5 {
|
||||
[0.0; 3]
|
||||
} else {
|
||||
eye.map(|d| d / l * power)
|
||||
};
|
||||
let p = self.player.as_mut()?;
|
||||
p.velocity = Vec3::new(
|
||||
p.velocity.x + push[0],
|
||||
p.velocity.y + push[1],
|
||||
p.velocity.z + push[2],
|
||||
);
|
||||
// ServerPlayer.onExplosionHit: a wind charge's burst cancels the
|
||||
// fall; any other burst ends the grace of the last impulse.
|
||||
let at = p.position;
|
||||
p.impulse.set_ignore_fall_damage(wind_charge, Some(at));
|
||||
Some(push)
|
||||
}
|
||||
/// The burst: knockback, the blocks that react, the gust and the sound.
|
||||
pub(super) fn wind_burst(
|
||||
@@ -361,13 +420,27 @@ impl Game<'_> {
|
||||
breeze: bool,
|
||||
wind_charge: bool,
|
||||
) -> Result<()> {
|
||||
// In a guest's turn (their charge landing, their mace's burst) the
|
||||
// host holds the other players (`as_guest`): the burst waits for the
|
||||
// turn to end so that it reaches every player, the guest too.
|
||||
if self.lan_acting.is_some() && self.lan.is_none() {
|
||||
self.creatures.wind_bursts.push(Burst {
|
||||
center,
|
||||
radius,
|
||||
multiplier,
|
||||
breeze,
|
||||
wind_charge,
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
// ServerExplosion.explode: the blast is heard first.
|
||||
let ctx = horizon_client::sculk::Context::default();
|
||||
self.post_game_event("minecraft:explode", center, ctx);
|
||||
let mut targets = self.wind_positions(center, radius);
|
||||
// ServerExplosion.shouldAffectBlocklikeEntities
|
||||
let blocklike = !breeze && !wind_charge && self.ui.rules.bool("mob_griefing");
|
||||
self.wind_knockback(center, radius, multiplier, wind_charge, blocklike);
|
||||
let pushed = self.wind_knockback(center, radius, multiplier, wind_charge, blocklike);
|
||||
let block_count = targets.len();
|
||||
// Explosion.canTriggerBlocks: a breeze's charge needs mob griefing.
|
||||
if !breeze || self.ui.rules.bool("mob_griefing") {
|
||||
// Util.shuffle(list, level.random)
|
||||
@@ -396,6 +469,14 @@ impl Game<'_> {
|
||||
};
|
||||
self.particles
|
||||
.add(&ParticleOptions::simple(ty), center, [1.0, 0.0, 0.0]);
|
||||
self.lan_wind_burst(center, radius, sound, block_count, &pushed);
|
||||
Ok(())
|
||||
}
|
||||
/// The bursts guests' turns put off.
|
||||
pub(super) fn flush_wind_bursts(&mut self) -> Result<()> {
|
||||
for b in std::mem::take(&mut self.creatures.wind_bursts) {
|
||||
self.wind_burst(b.center, b.radius, b.multiplier, b.breeze, b.wind_charge)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `BlockBehaviour.onExplosionHit` for `TRIGGER_BLOCK`: doors, gates,
|
||||
|
||||
@@ -35,7 +35,7 @@ fn entity_shader() -> String {
|
||||
mod entity_batch;
|
||||
mod weather;
|
||||
use entity_batch::EntityBatch;
|
||||
pub use entity_batch::WATER_MASK_OVERLAY;
|
||||
pub use entity_batch::{SHADOW_OVERLAY, WATER_MASK_OVERLAY};
|
||||
|
||||
/// Window.getAppropriateLineWidth, in framebuffer pixels.
|
||||
pub fn outline_width(width: u32) -> f32 {
|
||||
@@ -65,11 +65,9 @@ mod arena;
|
||||
mod cull;
|
||||
mod end_sky;
|
||||
mod flat;
|
||||
mod java_stages;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
mod occlusion;
|
||||
mod pack_stages;
|
||||
mod page_runs;
|
||||
pub mod panorama;
|
||||
mod resident;
|
||||
@@ -89,7 +87,6 @@ fn world_pass_after_culling<'e>(
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
view: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
pack: bool,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon world, after culling"),
|
||||
@@ -106,11 +103,7 @@ fn world_pass_after_culling<'e>(
|
||||
view: depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: if pack {
|
||||
wgpu::StoreOp::Store
|
||||
} else {
|
||||
wgpu::StoreOp::Discard
|
||||
},
|
||||
store: wgpu::StoreOp::Discard,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
@@ -278,28 +271,6 @@ pub struct Frame<'a> {
|
||||
/// `Screen.extractBlurredBackground`: blur the frame by this radius
|
||||
/// (`menuBackgroundBlurriness`) before the screen is drawn over it.
|
||||
pub menu_blur: Option<f32>,
|
||||
/// What a shader pack's `Frame` uniform reads beyond the rest of the frame.
|
||||
pub shader: ShaderInputs,
|
||||
}
|
||||
/// A shader pack's view of the game this frame (docs/shader-packs.md, `Frame`).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct ShaderInputs {
|
||||
/// `Level.getRainLevel` and `getThunderLevel` at the partial tick.
|
||||
pub rain: f32,
|
||||
pub thunder: f32,
|
||||
/// The camera's fog type: 0 air, 1 water, 2 lava, 3 powder snow.
|
||||
pub submerged: u8,
|
||||
/// 0 overworld, -1 the Nether, 1 the End.
|
||||
pub dimension: i8,
|
||||
pub has_sky_light: bool,
|
||||
/// `DimensionType.moonPhase`, 0..=7.
|
||||
pub moon_phase: u8,
|
||||
/// The time of day as a fraction (0 sunrise, 0.25 noon) and the partial tick.
|
||||
pub day_fraction: f32,
|
||||
pub partial_tick: f32,
|
||||
/// The block and sky light (0..15) of the block holding the camera
|
||||
/// entity's eyes; no sky light without a sky.
|
||||
pub eye_light: [u8; 2],
|
||||
}
|
||||
/// One frame of the world border wall (`WorldBorderRenderer.render`).
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -367,14 +338,10 @@ pub struct Renderer {
|
||||
/// (`crosshair_globals`).
|
||||
crosshair_group: wgpu::BindGroup,
|
||||
crosshair_globals: wgpu::Buffer,
|
||||
shadow_pipeline: wgpu::RenderPipeline,
|
||||
shadow_entity_pipeline: wgpu::RenderPipeline,
|
||||
shadow_globals: wgpu::Buffer,
|
||||
shadow_group: wgpu::BindGroup,
|
||||
shadow_entity_group: wgpu::BindGroup,
|
||||
entity_pipeline: wgpu::RenderPipeline,
|
||||
weather: weather::Weather,
|
||||
crumbling_pipeline: wgpu::RenderPipeline,
|
||||
shadow_blob_pipeline: wgpu::RenderPipeline,
|
||||
/// `RenderPipelines.WATER_MASK`: depth writes only, no colour.
|
||||
water_mask_pipeline: wgpu::RenderPipeline,
|
||||
cloud_pipeline: wgpu::RenderPipeline,
|
||||
@@ -489,8 +456,6 @@ pub struct Renderer {
|
||||
occlusion: occlusion::Occlusion,
|
||||
/// Resolution scale and FXAA (Horizon settings).
|
||||
world_post: world_post::Post,
|
||||
/// The selected shader pack's stages (Horizon setting; none by default).
|
||||
pack: Option<Pack>,
|
||||
pub hud: HudRenderer,
|
||||
/// Vanilla's menu background blur, made on first use.
|
||||
menu_blur: Option<crate::menu_blur::MenuBlur>,
|
||||
@@ -1312,38 +1277,6 @@ impl Renderer {
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let shadow_globals = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack shadow globals"),
|
||||
size: std::mem::size_of::<Globals>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let shadow_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Terrain shadow"),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_terrain"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &terrain_buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: tri,
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: None,
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let prepared = prepared.unwrap_or_else(|| {
|
||||
PreparedTextures::new(sprite_pixels, sprite_size, layers, sprite_mips, forcefield)
|
||||
});
|
||||
@@ -1457,16 +1390,6 @@ impl Renderer {
|
||||
&lightmap_view,
|
||||
&lightmap_sampler,
|
||||
);
|
||||
let shadow_group = terrain_group(
|
||||
&device,
|
||||
"Shader pack shadow terrain resources",
|
||||
&bindings,
|
||||
&shadow_globals,
|
||||
&sprite_views,
|
||||
&sprite_sampler,
|
||||
&lightmap_view,
|
||||
&lightmap_sampler,
|
||||
);
|
||||
let index_quads = 1 << 16;
|
||||
let indices = Self::quad_indices(&device, index_quads);
|
||||
let instance_capacity = 4096;
|
||||
@@ -1628,32 +1551,6 @@ impl Renderer {
|
||||
},
|
||||
],
|
||||
});
|
||||
let shadow_entity_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack shadow entity resources"),
|
||||
layout: &entity_bindings,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: shadow_globals.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&atlas_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&atlas_sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&lightmap_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&lightmap_sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
let entity_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Entity layout"),
|
||||
bind_group_layouts: &[Some(&entity_bindings)],
|
||||
@@ -1764,32 +1661,6 @@ impl Renderer {
|
||||
wgpu::ColorWrites::ALL,
|
||||
)
|
||||
};
|
||||
let shadow_entity_pipeline =
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Entity shadow"),
|
||||
layout: Some(&entity_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &entity_shader,
|
||||
entry_point: Some("vs_entity"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &entity_vertex,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: None,
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
// RenderPipelines.WATER_MASK: the boat's water patch writes depth only.
|
||||
let water_mask_pipeline = entity_pipeline_with(
|
||||
"Water mask",
|
||||
@@ -1815,6 +1686,26 @@ impl Renderer {
|
||||
CRUMBLING_BLEND,
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.ENTITY_SHADOW: translucent, depth test without
|
||||
// writes. The quads lie on the block tops like vanilla's, but the
|
||||
// terrain under them snaps to the subpixel grid at other vertices, so
|
||||
// at a slant their depths differ by up to 1/256 of a pixel's depth
|
||||
// slope and whole shadows failed the test. The bias covers that and
|
||||
// the rounding, and stays below the 0.001 a model's feet sit higher
|
||||
// (1/27 of a pixel's slope at the 16-block shadow range in 720p).
|
||||
let shadow_blob_pipeline = entity_pipeline(
|
||||
"Entity shadows",
|
||||
None,
|
||||
false,
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
wgpu::DepthBiasState {
|
||||
constant: 16,
|
||||
slope_scale: 0.015,
|
||||
clamp: 0.0,
|
||||
},
|
||||
alpha_blend,
|
||||
false,
|
||||
);
|
||||
let cloud_pipeline = entity_pipeline(
|
||||
"Clouds",
|
||||
Some(wgpu::Face::Back),
|
||||
@@ -2324,14 +2215,10 @@ impl Renderer {
|
||||
debug_line_capacity: 4096,
|
||||
crosshair_group,
|
||||
crosshair_globals,
|
||||
shadow_pipeline,
|
||||
shadow_entity_pipeline,
|
||||
shadow_globals,
|
||||
shadow_group,
|
||||
shadow_entity_group,
|
||||
entity_pipeline,
|
||||
weather,
|
||||
crumbling_pipeline,
|
||||
shadow_blob_pipeline,
|
||||
water_mask_pipeline,
|
||||
cloud_pipeline,
|
||||
cloud_mesh: Default::default(),
|
||||
@@ -2413,7 +2300,6 @@ impl Renderer {
|
||||
block_entity_revision: next_block_entity_revision(),
|
||||
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
|
||||
world_post,
|
||||
pack: None,
|
||||
hud,
|
||||
menu_blur: None,
|
||||
info,
|
||||
@@ -2723,10 +2609,11 @@ impl Renderer {
|
||||
first: u32,
|
||||
count: u32,
|
||||
) {
|
||||
let (draws, pages) = (&self.draws[..], &self.draw_pages[..]);
|
||||
let (mut i, end) = (first as usize, (first + count) as usize);
|
||||
while i < end {
|
||||
let (page, index_page) = self.draw_pages[i];
|
||||
let run = self.draw_pages[i..end]
|
||||
let (page, index_page) = pages[i];
|
||||
let run = pages[i..end]
|
||||
.iter()
|
||||
.take_while(|&&p| p == (page, index_page))
|
||||
.count();
|
||||
@@ -2737,7 +2624,7 @@ impl Renderer {
|
||||
let offset = (i - base as usize) as u64 * 20;
|
||||
pass.multi_draw_indexed_indirect(indirect, offset, run as u32);
|
||||
} else {
|
||||
for d in &self.draws[i..i + run] {
|
||||
for d in &draws[i..i + run] {
|
||||
pass.draw_indexed(d[2]..d[2] + d[0], d[3] as i32, d[4]..d[4] + 1);
|
||||
}
|
||||
}
|
||||
@@ -2826,37 +2713,6 @@ impl Renderer {
|
||||
self.release_section(key);
|
||||
}
|
||||
}
|
||||
/// Run `pack`'s stages over the world from now on (`None`: no pack, the
|
||||
/// vanilla frame). On error the previous pack stays.
|
||||
pub fn set_shader_pack(&mut self, pack: Option<&crate::shader_pack::ShaderPack>) -> Result<()> {
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
self.pack = match pack {
|
||||
Some(pack) => Some(match &pack.java {
|
||||
Some(java) => Pack::Java(java_stages::JavaStages::new(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
format,
|
||||
&pack.id,
|
||||
java,
|
||||
)?),
|
||||
None => Pack::Horizon(pack_stages::PackStages::new(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
format,
|
||||
pack,
|
||||
)?),
|
||||
}),
|
||||
None => None,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
/// The folder name of the pack `set_shader_pack` gave.
|
||||
pub fn shader_pack_id(&self) -> Option<&str> {
|
||||
self.pack.as_ref().map(|p| match p {
|
||||
Pack::Horizon(p) => p.id.as_str(),
|
||||
Pack::Java(p) => p.id.as_str(),
|
||||
})
|
||||
}
|
||||
/// Horizon's resolution scale (world pixels per window pixel) and FXAA.
|
||||
pub fn set_world_post(&mut self, scale: f32, antialias: bool) {
|
||||
self.world_post.scale = scale.clamp(0.25, 2.0);
|
||||
@@ -2886,16 +2742,6 @@ impl Renderer {
|
||||
&self.lightmap_view,
|
||||
&self.lightmap_sampler,
|
||||
);
|
||||
self.shadow_group = terrain_group(
|
||||
&self.device,
|
||||
"Shader pack shadow terrain resources",
|
||||
&self.terrain_layout,
|
||||
&self.shadow_globals,
|
||||
&views,
|
||||
&self.sprite_sampler,
|
||||
&self.lightmap_view,
|
||||
&self.lightmap_sampler,
|
||||
);
|
||||
}
|
||||
pub fn view_projection(&self, frame: &Frame) -> Mat4 {
|
||||
self.projection(
|
||||
@@ -3159,15 +3005,6 @@ impl Renderer {
|
||||
};
|
||||
self.uploads
|
||||
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
|
||||
let pack_uniform = shader_pack_uniform(
|
||||
frame,
|
||||
view,
|
||||
world_projection(frame.fov_degrees, aspect, frame.depth_far) * frame.bob,
|
||||
sun,
|
||||
fog_color,
|
||||
globals.fog,
|
||||
globals.fog_ends,
|
||||
);
|
||||
// GameRenderer.render3dHud: the hand's projection at hudFov, with the
|
||||
// bob in its pose rather than the projection (same product).
|
||||
let hand_projection = Mat4::from_cols_array(&crate::camera::hand_projection(
|
||||
@@ -3238,8 +3075,9 @@ impl Renderer {
|
||||
visible_lods.sort_unstable_by_key(|v| (v.distance, v.key));
|
||||
}
|
||||
self.drawn_sections = visible.len() + visible_lods.len();
|
||||
if self.drawn_sections as u64 > self.instance_capacity {
|
||||
self.instance_capacity = (self.drawn_sections as u64).next_power_of_two();
|
||||
let instances = self.drawn_sections;
|
||||
if instances as u64 > self.instance_capacity {
|
||||
self.instance_capacity = (instances as u64).next_power_of_two();
|
||||
self.instances = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Section origins"),
|
||||
size: self.instance_capacity * 16,
|
||||
@@ -3510,6 +3348,7 @@ impl Renderer {
|
||||
crumbling_quads,
|
||||
translucent_crumbling_quads,
|
||||
water_mask_quads,
|
||||
shadow_quads,
|
||||
] = self.entity_batch.update(
|
||||
frame.entities,
|
||||
frame.additive,
|
||||
@@ -3517,8 +3356,11 @@ impl Renderer {
|
||||
frame.see_through,
|
||||
);
|
||||
let weather_quads = frame.weather.len() as u32 / 4;
|
||||
let world_quads =
|
||||
entity_quads + crumbling_quads + translucent_crumbling_quads + water_mask_quads;
|
||||
let world_quads = entity_quads
|
||||
+ crumbling_quads
|
||||
+ translucent_crumbling_quads
|
||||
+ water_mask_quads
|
||||
+ shadow_quads;
|
||||
let cloud_quads = if let Some(input) = &frame.clouds {
|
||||
let (rebuilt, uniform) =
|
||||
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
|
||||
@@ -3737,44 +3579,9 @@ impl Renderer {
|
||||
timer.begin(&mut encoder);
|
||||
}
|
||||
// The world's targets: the screen, or the scaled target of `post`.
|
||||
let pack = self.pack.is_some() && !frame.title;
|
||||
// What the pack reads beyond the world's colour and depth: each costs
|
||||
// a pass (the shadow map, the G-buffer) or a copy (the opaque depth).
|
||||
let (shadow_cfg, gbuffer, opaque_depth) = match self.pack.as_ref().filter(|_| pack) {
|
||||
Some(Pack::Horizon(s)) => (
|
||||
s.needs_shadow.then_some((s.shadow_res, s.shadow_dist)),
|
||||
s.needs_gbuffer,
|
||||
s.needs_opaque_depth,
|
||||
),
|
||||
Some(Pack::Java(s)) => (None, false, s.reads_opaque_depth),
|
||||
None => (None, false, false),
|
||||
};
|
||||
if let Some((shadow_res, shadow_dist)) = shadow_cfg {
|
||||
self.world_post
|
||||
.prepare_shadow(&self.device, shadow_res, shadow_dist, sun, -sun);
|
||||
let shadow_globals = Globals {
|
||||
view_proj: (self.world_post.shadow_proj * self.world_post.shadow_view)
|
||||
.to_cols_array_2d(),
|
||||
..globals
|
||||
};
|
||||
self.uploads.buffer(
|
||||
&mut encoder,
|
||||
&self.shadow_globals,
|
||||
0,
|
||||
bytemuck::bytes_of(&shadow_globals),
|
||||
);
|
||||
} else {
|
||||
self.world_post.shadow_active = false;
|
||||
}
|
||||
let scaled = self.world_post.prepare(
|
||||
&self.device,
|
||||
[self.config.width, self.config.height],
|
||||
pack,
|
||||
world_post::Extras {
|
||||
opaque_depth,
|
||||
gbuffer,
|
||||
},
|
||||
);
|
||||
let scaled = self
|
||||
.world_post
|
||||
.prepare(&self.device, [self.config.width, self.config.height]);
|
||||
let (world_view, world_depth) = match &scaled {
|
||||
Some((color, depth)) => (color, depth),
|
||||
None => (&view_target, &self.depth),
|
||||
@@ -3783,36 +3590,6 @@ impl Renderer {
|
||||
cull.bind_depth(&self.device, world_depth);
|
||||
}
|
||||
let cull = self.cull.as_ref().filter(|_| culling && solid_draws > 0);
|
||||
if shadow_cfg.is_some() {
|
||||
let shadow_view = self.world_post.shadow_depth().clone();
|
||||
let mut shadow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon shadow"),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &shadow_view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
if solid_draws > 0 && !flat_frame {
|
||||
shadow_pass.set_bind_group(0, &self.shadow_group, &[]);
|
||||
shadow_pass.set_pipeline(&self.shadow_pipeline);
|
||||
shadow_pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
shadow_pass.set_vertex_buffer(1, self.instances.slice(..));
|
||||
self.draw_sections(&mut shadow_pass, &self.indirect, 0, 0, solid_draws);
|
||||
}
|
||||
if entity_quads > 0 {
|
||||
shadow_pass.set_pipeline(&self.shadow_entity_pipeline);
|
||||
shadow_pass.set_bind_group(0, &self.shadow_entity_group, &[]);
|
||||
shadow_pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
shadow_pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
shadow_pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
}
|
||||
}
|
||||
{
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: world_view,
|
||||
@@ -3841,9 +3618,8 @@ impl Renderer {
|
||||
view: world_depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
// A shader pack reads the world's depth, and
|
||||
// phase 2 of occlusion culling draws over it.
|
||||
store: if pack || cull.is_some() {
|
||||
// Phase 2 of occlusion culling draws over it.
|
||||
store: if cull.is_some() {
|
||||
wgpu::StoreOp::Store
|
||||
} else {
|
||||
wgpu::StoreOp::Discard
|
||||
@@ -3902,12 +3678,7 @@ 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,
|
||||
pack,
|
||||
);
|
||||
pass = world_pass_after_culling(&mut encoder, world_view, world_depth);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_pipeline(&flat.solid);
|
||||
flat.draw_phase2(&mut pass, vertices);
|
||||
@@ -3931,12 +3702,7 @@ impl Renderer {
|
||||
cull.test(&mut pass);
|
||||
drop(pass);
|
||||
cull.test_pass(&mut encoder);
|
||||
pass = world_pass_after_culling(
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
pack,
|
||||
);
|
||||
pass = world_pass_after_culling(&mut encoder, world_view, world_depth);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_pipeline(&self.solid);
|
||||
pass.set_index_buffer(
|
||||
@@ -3972,6 +3738,17 @@ impl Renderer {
|
||||
pass.draw_indexed(0..crumbling_quads * 6, (entity_quads * 4) as i32, 0..1);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if shadow_quads > 0 {
|
||||
pass.set_pipeline(&self.shadow_blob_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..shadow_quads * 6,
|
||||
((world_quads - shadow_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
// BoatRenderer's water patch (RenderTypes.waterMask): depth only,
|
||||
// before translucent terrain, so no water shows inside the boat.
|
||||
if water_mask_quads > 0 {
|
||||
@@ -4013,33 +3790,6 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
|
||||
pass.draw(0..4, debug_depth..debug_depth + debug_top);
|
||||
}
|
||||
if opaque_depth {
|
||||
drop(pass);
|
||||
self.world_post.capture_opaque_depth(&mut encoder);
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: world_view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon world, translucent"),
|
||||
color_attachments: &attachments,
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: world_depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
match self.flat.as_ref().filter(|_| flat_frame) {
|
||||
Some(flat) => {
|
||||
pass.set_pipeline(&flat.translucent);
|
||||
@@ -4111,49 +3861,7 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
}
|
||||
if gbuffer && scaled.is_some() {
|
||||
self.world_post.build_gbuffer(
|
||||
&self.queue,
|
||||
&mut encoder,
|
||||
&pack_uniform,
|
||||
frame.shader.eye_light,
|
||||
frame.shader.rain,
|
||||
);
|
||||
}
|
||||
if let (true, Some((color, depth)), Some(size), Some(stages)) =
|
||||
(pack, &scaled, self.world_post.size(), self.pack.as_mut())
|
||||
{
|
||||
let output = match stages {
|
||||
Pack::Horizon(stages) => stages.run(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&mut encoder,
|
||||
color,
|
||||
depth,
|
||||
size,
|
||||
&self.world_post,
|
||||
pack_uniform,
|
||||
frame.shader.eye_light,
|
||||
),
|
||||
Pack::Java(stages) => stages.run(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&mut encoder,
|
||||
color,
|
||||
depth,
|
||||
size,
|
||||
&self.world_post,
|
||||
pack_uniform,
|
||||
java_stages::JavaFrame {
|
||||
eye_light: frame.shader.eye_light,
|
||||
sun_angle: frame.sky.sun_angle,
|
||||
moon_angle: frame.sky.moon_angle,
|
||||
},
|
||||
),
|
||||
};
|
||||
self.world_post
|
||||
.finish_with(&mut encoder, &view_target, output);
|
||||
} else if scaled.is_some() {
|
||||
if scaled.is_some() {
|
||||
self.world_post.finish(&mut encoder, &view_target);
|
||||
}
|
||||
if hand_quads > 0 {
|
||||
@@ -4481,66 +4189,6 @@ impl Frustum {
|
||||
/// Vanilla's `Projection`: reversed depth (near maps to 1, far to 0, tests
|
||||
/// are GREATER_THAN_OR_EQUAL), which keeps far faces a hair apart, like
|
||||
/// fire_side's 0.01 pixel in front of its block, from z-fighting.
|
||||
/// The prelude's `Frame` (`shader_pack_prelude.wgsl`); `resolution` and the
|
||||
/// time are filled per stage.
|
||||
fn shader_pack_uniform(
|
||||
frame: &Frame,
|
||||
view: Mat4,
|
||||
projection: Mat4,
|
||||
sun: Vec3,
|
||||
fog_color: [f32; 4],
|
||||
fog: [f32; 4],
|
||||
fog_ends: [f32; 4],
|
||||
) -> pack_stages::FrameUniform {
|
||||
let s = frame.shader;
|
||||
let sky = frame.sky.sky_color;
|
||||
pack_stages::FrameUniform {
|
||||
view: view.to_cols_array_2d(),
|
||||
projection: projection.to_cols_array_2d(),
|
||||
inverse_view: view.inverse().to_cols_array_2d(),
|
||||
inverse_projection: projection.inverse().to_cols_array_2d(),
|
||||
camera_position: [
|
||||
frame.camera.x as f32,
|
||||
frame.camera.y as f32,
|
||||
frame.camera.z as f32,
|
||||
0.0,
|
||||
],
|
||||
time: [0.0, 0.0, s.day_fraction, s.partial_tick],
|
||||
sun: [sun.x, sun.y, sun.z, frame.sky.sun_angle.to_radians()],
|
||||
moon: [-sun.x, -sun.y, -sun.z, f32::from(s.moon_phase)],
|
||||
sky_color: [sky[0], sky[1], sky[2], 1.0],
|
||||
fog_color,
|
||||
fog: [fog[0], fog[1], fog_ends[0], fog_ends[1]],
|
||||
weather: [s.rain, s.thunder, 0.0, 0.0],
|
||||
camera_state: [
|
||||
f32::from(s.submerged),
|
||||
frame.night_vision,
|
||||
frame.darkness.1,
|
||||
0.0,
|
||||
],
|
||||
view_params: [
|
||||
0.05,
|
||||
frame.depth_far,
|
||||
frame.distance,
|
||||
frame.fov_degrees.to_radians(),
|
||||
],
|
||||
dimension: [
|
||||
f32::from(s.dimension),
|
||||
f32::from(u8::from(s.has_sky_light)),
|
||||
0.0,
|
||||
0.0,
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected shader pack on the GPU: a Horizon pack's WGSL stages, or a
|
||||
/// Java pack's translated programs.
|
||||
enum Pack {
|
||||
Horizon(pack_stages::PackStages),
|
||||
Java(java_stages::JavaStages),
|
||||
}
|
||||
|
||||
/// The section the camera is in.
|
||||
fn camera_section(camera: DVec3) -> SectionKey {
|
||||
(
|
||||
@@ -4625,14 +4273,14 @@ mod tests {
|
||||
normal: [0; 4],
|
||||
}; 4]
|
||||
};
|
||||
let entities: Vec<_> = [(0, 0), (4, 1), (11, 2), (7, 3), (4, 4), (12, 7)]
|
||||
let entities: Vec<_> = [(0, 0), (14, 9), (4, 1), (11, 2), (7, 3), (4, 4), (12, 7)]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, id)| quad(layer, id))
|
||||
.collect();
|
||||
let mut batch = super::EntityBatch::default();
|
||||
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8));
|
||||
let vertices = &batch.combined;
|
||||
assert_eq!(counts, [4, 1, 1, 0]);
|
||||
assert_eq!(counts, [4, 1, 1, 0, 1]);
|
||||
assert_eq!(
|
||||
vertices
|
||||
.as_chunks::<4>()
|
||||
@@ -4640,7 +4288,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|q| q[0].light)
|
||||
.collect::<Vec<_>>(),
|
||||
[0, 1, 3, 4, 2, 7, 5, 6, 8]
|
||||
[0, 1, 3, 4, 2, 7, 9, 5, 6, 8]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
@@ -4686,11 +4334,7 @@ mod tests {
|
||||
);
|
||||
assert!(files.len() >= 10, "{files:?}");
|
||||
for path in files {
|
||||
let mut text = std::fs::read_to_string(&path).unwrap();
|
||||
// Api 2's prelude follows api 1's (`shader_pack::ShaderPack::load`).
|
||||
if path.ends_with("shader_pack_prelude_api2.wgsl") {
|
||||
text = format!("{}\n{text}", crate::shader_pack::PRELUDE);
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
// entity.wgsl comes after the overlay's constants (entity_shader).
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.or_else(|_| {
|
||||
|
||||
@@ -261,16 +261,18 @@ impl Cull {
|
||||
bind_group_layouts: &[Some(&layout), depth_layout.as_ref()],
|
||||
..Default::default()
|
||||
});
|
||||
// In the shader: no colour target, and phase 1's depth attached read
|
||||
// only (a pass needs an attachment), its test left to the shader.
|
||||
// In the shader (`InShader`): no colour target, and the depth
|
||||
// attached read only (a pass needs an attachment), its test left to
|
||||
// the shader.
|
||||
let color_target = [Some(wgpu::ColorTargetState {
|
||||
format: color,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})];
|
||||
let (fragment_entry, targets, depth_compare) = match depth_test {
|
||||
DepthTest::Early => (
|
||||
"fs_box_early",
|
||||
&[Some(wgpu::ColorTargetState {
|
||||
format: color,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})][..],
|
||||
&color_target[..],
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
),
|
||||
DepthTest::InShader => ("fs_box", &[][..], wgpu::CompareFunction::Always),
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
use super::Vertex;
|
||||
|
||||
/// Entity materials by a quad's overlay (`Vertex::layer`), in draw order:
|
||||
/// ordinary entities, breaking cracks (11), translucent breaking cracks (12)
|
||||
/// and the boat water mask (13, `RenderPipelines.WATER_MASK`).
|
||||
const MATERIALS: usize = 4;
|
||||
/// ordinary entities, breaking cracks (11), translucent breaking cracks (12),
|
||||
/// the boat water mask (13, `RenderPipelines.WATER_MASK`) and entity shadows
|
||||
/// (14, `RenderPipelines.ENTITY_SHADOW`).
|
||||
const MATERIALS: usize = 5;
|
||||
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
|
||||
pub const WATER_MASK_OVERLAY: u32 = 13;
|
||||
/// The overlay of entity shadow quads (`RenderTypes.entityShadow`).
|
||||
pub const SHADOW_OVERLAY: u32 = 14;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct EntityBatch {
|
||||
@@ -37,6 +40,7 @@ impl EntityBatch {
|
||||
11 => 1,
|
||||
12 => 2,
|
||||
WATER_MASK_OVERLAY => 3,
|
||||
SHADOW_OVERLAY => 4,
|
||||
_ => 0,
|
||||
};
|
||||
self.buckets[material][counts[material]] = index;
|
||||
@@ -88,12 +92,13 @@ mod tests {
|
||||
) -> (Vec<Vertex>, [u32; MATERIALS]) {
|
||||
let mut combined = Vec::with_capacity(entities.len() + additive.len() + hand.len());
|
||||
let mut counts = [0; MATERIALS];
|
||||
for (i, layer) in [0, 11, 12, 13].into_iter().enumerate() {
|
||||
for (i, layer) in [0, 11, 12, 13, 14].into_iter().enumerate() {
|
||||
for quad in entities.as_chunks::<4>().0 {
|
||||
let material = match quad[0].layer {
|
||||
11 => 11,
|
||||
12 => 12,
|
||||
13 => 13,
|
||||
14 => 14,
|
||||
_ => 0,
|
||||
};
|
||||
if material == layer {
|
||||
@@ -113,7 +118,7 @@ mod tests {
|
||||
pos: [index as f32, 2.0, 3.0],
|
||||
uv: [index as f32, 5.0],
|
||||
layer: if mixed {
|
||||
[7, 11, 12, 4, 13, 0][index / 4 % 6]
|
||||
[7, 11, 14, 12, 4, 13, 0][index / 4 % 7]
|
||||
} else {
|
||||
0
|
||||
},
|
||||
|
||||
@@ -640,6 +640,7 @@ impl Flat {
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
|
||||
/// Draws `calls` with a flat pipeline set and `vertices` giving each
|
||||
/// page's vertex buffer.
|
||||
pub fn draw<'a>(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,552 +0,0 @@
|
||||
//! A Horizon shader pack on the GPU (docs/shader-packs.md): each stage
|
||||
//! (`shadow`, `gbuffer`, `deferred`, `composite`, `final`) is a full-screen
|
||||
//! pass that reads the world's colour, depth, opaque depth, G-buffer normals
|
||||
//! and materials, shadow map, noise texture and the previous stage's output,
|
||||
//! and writes its own target; the last target goes to the screen through
|
||||
//! `world_post` (copy or FXAA) under the hand and HUD. Only exists while a
|
||||
//! pack is selected.
|
||||
use crate::shader_pack::{Rgba, ShaderPack, VERTEX_ENTRY};
|
||||
use horizon_shaderpack::uniforms::SmoothFloat;
|
||||
|
||||
/// The `Frame` uniform of the prelude (`shader_pack_prelude.wgsl`).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct FrameUniform {
|
||||
pub view: [[f32; 4]; 4],
|
||||
pub projection: [[f32; 4]; 4],
|
||||
pub inverse_view: [[f32; 4]; 4],
|
||||
pub inverse_projection: [[f32; 4]; 4],
|
||||
pub camera_position: [f32; 4],
|
||||
pub resolution: [f32; 4],
|
||||
pub time: [f32; 4],
|
||||
pub sun: [f32; 4],
|
||||
pub moon: [f32; 4],
|
||||
pub sky_color: [f32; 4],
|
||||
pub fog_color: [f32; 4],
|
||||
pub fog: [f32; 4],
|
||||
pub weather: [f32; 4],
|
||||
pub camera_state: [f32; 4],
|
||||
pub view_params: [f32; 4],
|
||||
pub dimension: [f32; 4],
|
||||
}
|
||||
|
||||
/// The API 2 `FrameExt` uniform of the prelude (`shader_pack_prelude.wgsl`).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct FrameExtUniform {
|
||||
pub previous_view: [[f32; 4]; 4],
|
||||
pub previous_projection: [[f32; 4]; 4],
|
||||
pub shadow_view: [[f32; 4]; 4],
|
||||
pub shadow_projection: [[f32; 4]; 4],
|
||||
pub shadow_inverse_view: [[f32; 4]; 4],
|
||||
pub shadow_inverse_projection: [[f32; 4]; 4],
|
||||
pub previous_camera: [f32; 4],
|
||||
pub shadow_params: [f32; 4],
|
||||
pub environment: [f32; 4],
|
||||
}
|
||||
|
||||
/// Iris's `noisetex` without a `texture.noise`: 256² random RGB, opaque.
|
||||
/// Horizon packs get the same one unless `[textures] noise` replaces it.
|
||||
pub(super) fn noise() -> Rgba {
|
||||
const SIZE: u32 = 256;
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
let pixels = (0..SIZE * SIZE * 4)
|
||||
.map(|i| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
if i % 4 == 3 { 255 } else { (seed >> 24) as u8 }
|
||||
})
|
||||
.collect();
|
||||
Rgba {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
pixels,
|
||||
}
|
||||
}
|
||||
|
||||
/// `image` as a sampled RGBA8 texture.
|
||||
pub(super) fn rgba_view(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
label: &str,
|
||||
image: &Rgba,
|
||||
) -> wgpu::TextureView {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
texture.as_image_copy(),
|
||||
&image.pixels,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(image.width * 4),
|
||||
rows_per_image: None,
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
texture.create_view(&Default::default())
|
||||
}
|
||||
|
||||
struct Pass {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
scale: f32,
|
||||
target_format: wgpu::TextureFormat,
|
||||
uniform: wgpu::Buffer,
|
||||
uniform_ext: wgpu::Buffer,
|
||||
}
|
||||
/// A stage's output and the bindings that read the world into it.
|
||||
struct Target {
|
||||
size: [u32; 2],
|
||||
view: wgpu::TextureView,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
pub struct PackStages {
|
||||
pub id: String,
|
||||
pub needs_shadow: bool,
|
||||
/// The stages read the G-buffer (`build_gbuffer`) or the depth before
|
||||
/// translucent geometry (`capture_opaque_depth`).
|
||||
pub needs_gbuffer: bool,
|
||||
pub needs_opaque_depth: bool,
|
||||
pub shadow_res: u32,
|
||||
pub shadow_dist: f32,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
linear: wgpu::Sampler,
|
||||
nearest: wgpu::Sampler,
|
||||
repeat: wgpu::Sampler,
|
||||
shadow_sampler: wgpu::Sampler,
|
||||
noise_view: wgpu::TextureView,
|
||||
passes: Vec<Pass>,
|
||||
/// Built for this world target size and `world_post` generation.
|
||||
targets: Vec<Target>,
|
||||
/// `world_post`'s bindings of the last stage's output.
|
||||
output: Option<wgpu::BindGroup>,
|
||||
world_size: [u32; 2],
|
||||
post_generation: u64,
|
||||
started: std::time::Instant,
|
||||
last: Option<std::time::Instant>,
|
||||
frames: u32,
|
||||
previous: Option<([[f32; 4]; 4], [[f32; 4]; 4], [f32; 3])>,
|
||||
wetness: SmoothFloat,
|
||||
}
|
||||
|
||||
fn stage_format(name: Option<&str>, default: wgpu::TextureFormat) -> wgpu::TextureFormat {
|
||||
match name.map(str::to_lowercase).as_deref() {
|
||||
Some("rgba16float" | "rgba16f") => wgpu::TextureFormat::Rgba16Float,
|
||||
Some("rgba8unorm" | "rgba8" | "rgba") => wgpu::TextureFormat::Rgba8Unorm,
|
||||
Some("rgb10a2unorm" | "rgb10_a2") => wgpu::TextureFormat::Rgb10a2Unorm,
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
|
||||
impl PackStages {
|
||||
/// The pipelines of a loaded (already validated) pack, or wgpu's error.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
format: wgpu::TextureFormat,
|
||||
pack: &ShaderPack,
|
||||
) -> Result<Self, String> {
|
||||
let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let sampler = |binding, ty| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(ty),
|
||||
count: None,
|
||||
};
|
||||
let uniform_entry = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Shader pack stage"),
|
||||
entries: &[
|
||||
texture(0, float),
|
||||
texture(1, wgpu::TextureSampleType::Depth),
|
||||
texture(2, float),
|
||||
sampler(3, wgpu::SamplerBindingType::Filtering),
|
||||
sampler(4, wgpu::SamplerBindingType::NonFiltering),
|
||||
uniform_entry(5),
|
||||
uniform_entry(6),
|
||||
texture(7, float),
|
||||
texture(8, float),
|
||||
texture(9, wgpu::TextureSampleType::Depth),
|
||||
texture(10, wgpu::TextureSampleType::Depth),
|
||||
sampler(11, wgpu::SamplerBindingType::Comparison),
|
||||
texture(12, float),
|
||||
sampler(13, wgpu::SamplerBindingType::Filtering),
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Shader pack stage"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let constants = pack.constants();
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let stage_count = pack.stages.len();
|
||||
let passes = pack
|
||||
.stages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, stage)| {
|
||||
let is_last = idx + 1 == stage_count;
|
||||
let target_format = if is_last {
|
||||
format
|
||||
} else {
|
||||
stage_format(stage.format.as_deref(), format)
|
||||
};
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&stage.file),
|
||||
source: wgpu::ShaderSource::Wgsl(stage.source.as_str().into()),
|
||||
});
|
||||
// Only the overrides a stage declares may be passed to it.
|
||||
let constants: Vec<(&str, f64)> = constants
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|(key, _)| declares_override(&stage.source, key))
|
||||
.collect();
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&stage.file),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some(VERTEX_ENTRY),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some(&stage.entry),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: target_format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
Pass {
|
||||
pipeline,
|
||||
scale: stage.scale,
|
||||
target_format,
|
||||
uniform: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack frame"),
|
||||
size: std::mem::size_of::<FrameUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
uniform_ext: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack frame ext"),
|
||||
size: std::mem::size_of::<FrameExtUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if let Some(error) = pollster::block_on(scope.pop()) {
|
||||
return Err(error.to_string());
|
||||
}
|
||||
let make_sampler = |filter, address, compare| {
|
||||
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,
|
||||
compare,
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let noise_view = match &pack.custom_noise {
|
||||
Some(image) => rgba_view(device, queue, "Shader pack noise", image),
|
||||
None => rgba_view(device, queue, "Shader pack noise", &noise()),
|
||||
};
|
||||
let (shadow_res, shadow_dist) = pack.shadow_config();
|
||||
Ok(Self {
|
||||
id: pack.id.clone(),
|
||||
needs_shadow: pack.needs_shadow(),
|
||||
needs_gbuffer: pack.needs_gbuffer(),
|
||||
needs_opaque_depth: pack.needs_opaque_depth(),
|
||||
shadow_res,
|
||||
shadow_dist,
|
||||
layout,
|
||||
linear: make_sampler(
|
||||
wgpu::FilterMode::Linear,
|
||||
wgpu::AddressMode::ClampToEdge,
|
||||
None,
|
||||
),
|
||||
nearest: make_sampler(
|
||||
wgpu::FilterMode::Nearest,
|
||||
wgpu::AddressMode::ClampToEdge,
|
||||
None,
|
||||
),
|
||||
repeat: make_sampler(wgpu::FilterMode::Linear, wgpu::AddressMode::Repeat, None),
|
||||
shadow_sampler: make_sampler(
|
||||
wgpu::FilterMode::Linear,
|
||||
wgpu::AddressMode::ClampToEdge,
|
||||
Some(wgpu::CompareFunction::GreaterEqual),
|
||||
),
|
||||
noise_view,
|
||||
passes,
|
||||
targets: Vec::new(),
|
||||
output: None,
|
||||
world_size: [0, 0],
|
||||
post_generation: u64::MAX,
|
||||
started: std::time::Instant::now(),
|
||||
last: None,
|
||||
frames: 0,
|
||||
previous: None,
|
||||
wetness: SmoothFloat::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Run every stage over the world's `color` and `depth` (`size`) and
|
||||
/// return `post`'s bindings of the last stage's output.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
color: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
post: &super::world_post::Post,
|
||||
mut uniform: FrameUniform,
|
||||
eye_light: [u8; 2],
|
||||
) -> &wgpu::BindGroup {
|
||||
if self.world_size != size
|
||||
|| self.post_generation != post.generation()
|
||||
|| self.targets.len() != self.passes.len()
|
||||
{
|
||||
self.world_size = size;
|
||||
self.post_generation = post.generation();
|
||||
self.targets.clear();
|
||||
for pass in &self.passes {
|
||||
let target_size = size.map(|v| ((v as f32 * pass.scale).round() as u32).max(1));
|
||||
let view = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack stage"),
|
||||
size: wgpu::Extent3d {
|
||||
width: target_size[0],
|
||||
height: target_size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: pass.target_format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
let previous = self.targets.last().map_or(color, |t: &Target| &t.view);
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack stage"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(color),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(depth),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(previous),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::Sampler(&self.linear),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&self.nearest),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: pass.uniform.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: pass.uniform_ext.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(post.normal()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: wgpu::BindingResource::TextureView(post.material()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 9,
|
||||
resource: wgpu::BindingResource::TextureView(post.opaque_depth()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 10,
|
||||
resource: wgpu::BindingResource::TextureView(post.shadow_depth()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 11,
|
||||
resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 12,
|
||||
resource: wgpu::BindingResource::TextureView(&self.noise_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 13,
|
||||
resource: wgpu::BindingResource::Sampler(&self.repeat),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.targets.push(Target {
|
||||
size: target_size,
|
||||
view,
|
||||
group,
|
||||
});
|
||||
}
|
||||
let last = &self.targets.last().expect("a pack has a stage").view;
|
||||
self.output = Some(post.bind(device, last));
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
let dt = self.last.map_or(0.0, |t| (now - t).as_secs_f32());
|
||||
self.last = Some(now);
|
||||
let wet = self.wetness.update(uniform.weather[0], 60.0, 120.0, dt);
|
||||
let cam = [
|
||||
uniform.camera_position[0],
|
||||
uniform.camera_position[1],
|
||||
uniform.camera_position[2],
|
||||
];
|
||||
let (prev_view, prev_proj, prev_cam) =
|
||||
self.previous
|
||||
.unwrap_or((uniform.view, uniform.projection, cam));
|
||||
self.previous = Some((uniform.view, uniform.projection, cam));
|
||||
let shadow_view = post.shadow_view;
|
||||
let shadow_proj = post.shadow_proj;
|
||||
let ext = FrameExtUniform {
|
||||
previous_view: prev_view,
|
||||
previous_projection: prev_proj,
|
||||
shadow_view: shadow_view.to_cols_array_2d(),
|
||||
shadow_projection: shadow_proj.to_cols_array_2d(),
|
||||
shadow_inverse_view: shadow_view.inverse().to_cols_array_2d(),
|
||||
shadow_inverse_projection: shadow_proj.inverse().to_cols_array_2d(),
|
||||
previous_camera: [prev_cam[0], prev_cam[1], prev_cam[2], dt],
|
||||
shadow_params: [
|
||||
post.shadow_resolution as f32,
|
||||
post.shadow_distance,
|
||||
0.0,
|
||||
if post.shadow_active { 1.0 } else { 0.0 },
|
||||
],
|
||||
environment: [
|
||||
f32::from(eye_light[0]) * 16.0,
|
||||
f32::from(eye_light[1]) * 16.0,
|
||||
wet,
|
||||
0.0,
|
||||
],
|
||||
};
|
||||
uniform.time[0] = self.started.elapsed().as_secs_f32();
|
||||
uniform.time[1] = self.frames as f32;
|
||||
self.frames = self.frames.wrapping_add(1);
|
||||
for (pass, target) in self.passes.iter().zip(&self.targets) {
|
||||
let [w, h] = target.size.map(|v| v as f32);
|
||||
uniform.resolution = [w, h, 1.0 / w, 1.0 / h];
|
||||
queue.write_buffer(&pass.uniform, 0, bytemuck::bytes_of(&uniform));
|
||||
queue.write_buffer(&pass.uniform_ext, 0, bytemuck::bytes_of(&ext));
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target.view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut render = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack stage"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
render.set_pipeline(&pass.pipeline);
|
||||
render.set_bind_group(0, &target.group, &[]);
|
||||
render.draw(0..3, 0..1);
|
||||
}
|
||||
self.output.as_ref().expect("bound with the targets")
|
||||
}
|
||||
}
|
||||
|
||||
/// `override <key>` appears in the source (a declaration, not just a use).
|
||||
fn declares_override(source: &str, key: &str) -> bool {
|
||||
source.lines().any(|line| {
|
||||
line.trim_start()
|
||||
.strip_prefix("override")
|
||||
.and_then(|rest| rest.trim_start().strip_prefix(key))
|
||||
.is_some_and(|rest| rest.trim_start().starts_with([':', '=']))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn the_frame_uniform_matches_the_prelude() {
|
||||
// 4 matrices and 12 vectors, as `struct Frame` in the prelude.
|
||||
assert_eq!(std::mem::size_of::<super::FrameUniform>(), 4 * 64 + 12 * 16);
|
||||
let prelude = crate::shader_pack::PRELUDE;
|
||||
let body = prelude.split("struct Frame {").nth(1).unwrap();
|
||||
let body = &body[..body.find('}').unwrap()];
|
||||
assert_eq!(body.matches("mat4x4<f32>").count(), 4);
|
||||
assert_eq!(body.matches("vec4<f32>").count(), 12);
|
||||
}
|
||||
#[test]
|
||||
fn only_declared_overrides_are_passed() {
|
||||
let source =
|
||||
"override saturation: f32 = 1.0;\noverride vignette = true;\nlet x = saturation_2;";
|
||||
assert!(super::declares_override(source, "saturation"));
|
||||
assert!(super::declares_override(source, "vignette"));
|
||||
assert!(!super::declares_override(source, "saturation_2"));
|
||||
assert!(!super::declares_override(source, "haze"));
|
||||
}
|
||||
}
|
||||
@@ -1,163 +1,25 @@
|
||||
//! Resolution scale, FXAA, and shader-pack G-buffer/shadow targets (Horizon
|
||||
//! settings): the world renders into its own target at the scaled size, then
|
||||
//! one full-screen pass copies or anti-aliases it onto the screen, under the
|
||||
//! hand and HUD. For a shader pack that reads them, `Post` also keeps:
|
||||
//! - `opaque_depth`: the depth before translucent terrain (water, stained
|
||||
//! glass, ice) draws, a Java pack's `depthtex1`/`depthtex2`;
|
||||
//! - `normal` and `material`, an api 2 pack's `scene_normal` and
|
||||
//! `scene_material`: surface normals and material properties
|
||||
//! reconstructed from depth and scene colour;
|
||||
//! - `shadow_depth`, an api 2 pack's: the sun/moon shadow map and its
|
||||
//! light-space `shadow_view` and `shadow_proj` matrices.
|
||||
//!
|
||||
//! With none of them on, the world renders straight to the screen as before.
|
||||
|
||||
use super::pack_stages::FrameUniform;
|
||||
use glam::{Mat4, Vec3};
|
||||
|
||||
const GBUFFER_WGSL: &str = r#"
|
||||
struct GbufferParams {
|
||||
inverse_view: mat4x4<f32>,
|
||||
inverse_projection: mat4x4<f32>,
|
||||
resolution: vec4<f32>,
|
||||
// x: eye block light (0..1), y: eye sky light (0..1), z: wetness (0..1), w: sun elevation
|
||||
env: vec4<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var world_color: texture_2d<f32>;
|
||||
@group(0) @binding(1) var world_depth: texture_depth_2d;
|
||||
@group(0) @binding(2) var opaque_depth: texture_depth_2d;
|
||||
@group(0) @binding(3) var nearest_sampler: sampler;
|
||||
@group(0) @binding(4) var<uniform> params: GbufferParams;
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_gbuffer(@builtin(vertex_index) i: u32) -> VsOut {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
var out: VsOut;
|
||||
out.clip = vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
out.uv = vec2<f32>(p.x, 1.0 - p.y);
|
||||
return out;
|
||||
}
|
||||
|
||||
struct GbufferOut {
|
||||
@location(0) normal: vec4<f32>,
|
||||
@location(1) material: vec4<f32>,
|
||||
}
|
||||
|
||||
fn world_pos(uv: vec2<f32>, d: f32) -> vec3<f32> {
|
||||
let v = params.inverse_projection * vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, d, 1.0);
|
||||
let view = v.xyz / max(v.w, 1e-6);
|
||||
return (params.inverse_view * vec4<f32>(view, 1.0)).xyz;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_gbuffer(in: VsOut) -> GbufferOut {
|
||||
let d_all = textureSampleLevel(world_depth, nearest_sampler, in.uv, 0);
|
||||
let d_opq = textureSampleLevel(opaque_depth, nearest_sampler, in.uv, 0);
|
||||
let color = textureSampleLevel(world_color, nearest_sampler, in.uv, 0.0);
|
||||
var out: GbufferOut;
|
||||
if d_all <= 0.0 && d_opq <= 0.0 {
|
||||
out.normal = vec4<f32>(0.5, 1.0, 0.5, 0.0);
|
||||
out.material = vec4<f32>(0.0, params.env.y, 0.0, 1.0);
|
||||
return out;
|
||||
}
|
||||
let d = select(d_all, d_opq, d_opq > 0.0);
|
||||
let step_uv = params.resolution.zw;
|
||||
let p0 = world_pos(in.uv, d);
|
||||
let uv_r = clamp(in.uv + vec2<f32>(step_uv.x, 0.0), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
let uv_l = clamp(in.uv - vec2<f32>(step_uv.x, 0.0), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
let uv_d = clamp(in.uv + vec2<f32>(0.0, step_uv.y), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
let uv_u = clamp(in.uv - vec2<f32>(0.0, step_uv.y), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
let dr = textureSampleLevel(opaque_depth, nearest_sampler, uv_r, 0);
|
||||
let dl = textureSampleLevel(opaque_depth, nearest_sampler, uv_l, 0);
|
||||
let dd = textureSampleLevel(opaque_depth, nearest_sampler, uv_d, 0);
|
||||
let du = textureSampleLevel(opaque_depth, nearest_sampler, uv_u, 0);
|
||||
let dx = select(world_pos(uv_r, dr) - p0, p0 - world_pos(uv_l, dl), abs(dl - d) < abs(dr - d));
|
||||
let dy = select(world_pos(uv_d, dd) - p0, p0 - world_pos(uv_u, du), abs(du - d) < abs(dd - d));
|
||||
let raw_n = cross(dy, dx);
|
||||
let n_len = length(raw_n);
|
||||
let n = select(vec3<f32>(0.0, 1.0, 0.0), raw_n / max(n_len, 1e-6), n_len > 1e-6);
|
||||
// Reversed depth: closer translucent geometry has larger depth than opaque geometry.
|
||||
let is_translucent = select(0.0, 1.0, d_all > d_opq + 1e-5);
|
||||
let luma = dot(color.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
let block_light = clamp(max(params.env.x, smoothstep(0.65, 0.98, luma)), 0.0, 1.0);
|
||||
let roughness = select(clamp(0.85 - params.env.z * 0.45, 0.1, 1.0), 0.05, is_translucent > 0.5);
|
||||
out.normal = vec4<f32>(n * 0.5 + 0.5, 1.0);
|
||||
out.material = vec4<f32>(block_light, params.env.y, is_translucent, roughness);
|
||||
return out;
|
||||
}
|
||||
"#;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct GbufferUniform {
|
||||
inverse_view: [[f32; 4]; 4],
|
||||
inverse_projection: [[f32; 4]; 4],
|
||||
resolution: [f32; 4],
|
||||
env: [f32; 4],
|
||||
}
|
||||
|
||||
/// What the frame's shader pack reads beyond the world's colour and depth:
|
||||
/// `Post` keeps these targets only while one does.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Extras {
|
||||
pub opaque_depth: bool,
|
||||
pub gbuffer: bool,
|
||||
}
|
||||
//! Resolution scale and FXAA (Horizon settings): the world renders into its
|
||||
//! own target at the scaled size, then one full-screen pass copies or
|
||||
//! anti-aliases it onto the screen, under the hand and HUD. With neither on,
|
||||
//! the world renders straight to the screen.
|
||||
|
||||
struct WorldTarget {
|
||||
size: [u32; 2],
|
||||
extras: Extras,
|
||||
color: wgpu::TextureView,
|
||||
depth_tex: wgpu::Texture,
|
||||
depth: wgpu::TextureView,
|
||||
/// With `extras.opaque_depth` or `extras.gbuffer` (which reads it).
|
||||
opaque_depth: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
gbuffer: Option<Gbuffer>,
|
||||
group: wgpu::BindGroup,
|
||||
opaque_captured: bool,
|
||||
}
|
||||
|
||||
struct Gbuffer {
|
||||
normal: wgpu::TextureView,
|
||||
material: wgpu::TextureView,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
struct ShadowTarget {
|
||||
resolution: u32,
|
||||
depth: wgpu::TextureView,
|
||||
}
|
||||
|
||||
pub struct Post {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
nearest: wgpu::Sampler,
|
||||
copy: wgpu::RenderPipeline,
|
||||
fxaa: wgpu::RenderPipeline,
|
||||
gbuffer_layout: wgpu::BindGroupLayout,
|
||||
gbuffer_pipeline: wgpu::RenderPipeline,
|
||||
gbuffer_uniform: wgpu::Buffer,
|
||||
format: wgpu::TextureFormat,
|
||||
/// World resolution relative to the window (0.5..=2.0).
|
||||
pub scale: f32,
|
||||
pub antialias: bool,
|
||||
target: Option<WorldTarget>,
|
||||
shadow: Option<ShadowTarget>,
|
||||
/// Bumped whenever a target is made: bindings of the old ones are stale.
|
||||
generation: u64,
|
||||
fallback_shadow: wgpu::TextureView,
|
||||
fallback_color: wgpu::TextureView,
|
||||
pub shadow_view: Mat4,
|
||||
pub shadow_proj: Mat4,
|
||||
pub shadow_distance: f32,
|
||||
pub shadow_resolution: u32,
|
||||
pub shadow_active: bool,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
@@ -225,192 +87,31 @@ impl Post {
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
let nearest = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Horizon post nearest"),
|
||||
mag_filter: wgpu::FilterMode::Nearest,
|
||||
min_filter: wgpu::FilterMode::Nearest,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let gbuffer_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
source: wgpu::ShaderSource::Wgsl(GBUFFER_WGSL.into()),
|
||||
});
|
||||
let gbuffer_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon gbuffer layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 3,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let gbuffer_pipe_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
bind_group_layouts: &[Some(&gbuffer_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let gbuffer_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
layout: Some(&gbuffer_pipe_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &gbuffer_shader,
|
||||
entry_point: Some("vs_gbuffer"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &gbuffer_shader,
|
||||
entry_point: Some("fs_gbuffer"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let gbuffer_uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon gbuffer uniform"),
|
||||
size: std::mem::size_of::<GbufferUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let fallback_shadow = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon fallback shadow"),
|
||||
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::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
let fallback_color = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon fallback gbuffer"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
Self {
|
||||
copy: pipeline("fs_copy"),
|
||||
fxaa: pipeline("fs_fxaa"),
|
||||
layout,
|
||||
sampler,
|
||||
nearest,
|
||||
gbuffer_layout,
|
||||
gbuffer_pipeline,
|
||||
gbuffer_uniform,
|
||||
format,
|
||||
scale: 1.0,
|
||||
antialias: false,
|
||||
target: None,
|
||||
shadow: None,
|
||||
generation: 0,
|
||||
fallback_shadow,
|
||||
fallback_color,
|
||||
shadow_view: Mat4::IDENTITY,
|
||||
shadow_proj: Mat4::IDENTITY,
|
||||
shadow_distance: 128.0,
|
||||
shadow_resolution: 2048,
|
||||
shadow_active: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The world's colour and depth targets this frame, or `None` to draw
|
||||
/// it straight to the screen. `pack`: a shader pack runs this frame,
|
||||
/// reading `extras`.
|
||||
/// it straight to the screen.
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
window: [u32; 2],
|
||||
pack: bool,
|
||||
extras: Extras,
|
||||
) -> Option<(wgpu::TextureView, wgpu::TextureView)> {
|
||||
if self.scale == 1.0 && !self.antialias && !pack {
|
||||
if self.scale == 1.0 && !self.antialias {
|
||||
self.target = None;
|
||||
self.shadow_active = false;
|
||||
return None;
|
||||
}
|
||||
let size = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
if self
|
||||
.target
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size != size || t.extras != extras)
|
||||
{
|
||||
self.generation += 1;
|
||||
if self.target.as_ref().is_none_or(|t| t.size != size) {
|
||||
let extent = wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
@@ -434,273 +135,26 @@ impl Post {
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let depth_tex = make_tex(
|
||||
// TEXTURE_BINDING: occlusion culling reads it.
|
||||
let depth = make_tex(
|
||||
"Scaled world depth",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let depth = depth_tex.create_view(&Default::default());
|
||||
let opaque_depth = (extras.opaque_depth || extras.gbuffer).then(|| {
|
||||
let tex = make_tex(
|
||||
"Opaque world depth",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
);
|
||||
let view = tex.create_view(&Default::default());
|
||||
(tex, view)
|
||||
});
|
||||
let gbuffer = opaque_depth
|
||||
.as_ref()
|
||||
.filter(|_| extras.gbuffer)
|
||||
.map(|(_, opaque)| {
|
||||
let color_target = |label| {
|
||||
make_tex(
|
||||
label,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
)
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
layout: &self.gbuffer_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&color),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&depth),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(opaque),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::Sampler(&self.nearest),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: self.gbuffer_uniform.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
Gbuffer {
|
||||
normal: color_target("World G-buffer normal"),
|
||||
material: color_target("World G-buffer material"),
|
||||
group,
|
||||
}
|
||||
});
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let group = self.bind(device, &color);
|
||||
self.target = Some(WorldTarget {
|
||||
size,
|
||||
extras,
|
||||
color,
|
||||
depth_tex,
|
||||
depth,
|
||||
opaque_depth,
|
||||
gbuffer,
|
||||
group,
|
||||
opaque_captured: false,
|
||||
});
|
||||
}
|
||||
if let Some(t) = &mut self.target {
|
||||
t.opaque_captured = false;
|
||||
}
|
||||
self.target
|
||||
.as_ref()
|
||||
.map(|t| (t.color.clone(), t.depth.clone()))
|
||||
}
|
||||
|
||||
/// Prepare the directional sun/moon shadow map target and compute its
|
||||
/// camera-relative orthographic `shadow_view` and `shadow_proj` matrices.
|
||||
pub fn prepare_shadow(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
resolution: u32,
|
||||
distance: f32,
|
||||
sun_dir: Vec3,
|
||||
moon_dir: Vec3,
|
||||
) -> &wgpu::TextureView {
|
||||
let resolution = resolution.clamp(256, 4096);
|
||||
let distance = distance.clamp(16.0, 512.0);
|
||||
if self
|
||||
.shadow
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.resolution != resolution)
|
||||
{
|
||||
let depth = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon shadow map"),
|
||||
size: wgpu::Extent3d {
|
||||
width: resolution,
|
||||
height: resolution,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
self.shadow = Some(ShadowTarget { resolution, depth });
|
||||
self.generation += 1;
|
||||
}
|
||||
let light_dir = if sun_dir.y >= -0.05 {
|
||||
sun_dir.normalize_or_zero()
|
||||
} else {
|
||||
moon_dir.normalize_or_zero()
|
||||
};
|
||||
let dir = if light_dir.length_squared() > 1e-4 {
|
||||
light_dir
|
||||
} else {
|
||||
Vec3::Y
|
||||
};
|
||||
let up = if dir.y.abs() > 0.99 { Vec3::Z } else { Vec3::Y };
|
||||
self.shadow_view = Mat4::look_at_rh(dir * distance, Vec3::ZERO, up);
|
||||
// Reversed-depth orthographic projection for the world's GreaterEqual
|
||||
// pipelines (near maps to 1.0, far maps to 0.0), and standard 0..1
|
||||
// projection for shader sampling.
|
||||
self.shadow_proj = Mat4::orthographic_rh(
|
||||
-distance,
|
||||
distance,
|
||||
-distance,
|
||||
distance,
|
||||
distance * 2.5,
|
||||
0.05,
|
||||
);
|
||||
self.shadow_distance = distance;
|
||||
self.shadow_resolution = resolution;
|
||||
self.shadow_active = true;
|
||||
&self.shadow.as_ref().expect("created above").depth
|
||||
}
|
||||
|
||||
/// Snapshot `world_depth` into `opaque_depth` (`depthtex1`/`depthtex2`)
|
||||
/// right before translucent water and weather draw.
|
||||
pub fn capture_opaque_depth(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(t) = &mut self.target else { return };
|
||||
let Some((opaque, _)) = &t.opaque_depth else {
|
||||
return;
|
||||
};
|
||||
encoder.copy_texture_to_texture(
|
||||
t.depth_tex.as_image_copy(),
|
||||
opaque.as_image_copy(),
|
||||
t.depth_tex.size(),
|
||||
);
|
||||
t.opaque_captured = true;
|
||||
}
|
||||
|
||||
/// Reconstruct the G-buffer `normal` and `material` textures after the
|
||||
/// world pass finishes (with `Extras::gbuffer`).
|
||||
pub fn build_gbuffer(
|
||||
&mut self,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &FrameUniform,
|
||||
eye_light: [u8; 2],
|
||||
wetness: f32,
|
||||
) {
|
||||
if !self.target.as_ref().is_some_and(|t| t.opaque_captured) {
|
||||
self.capture_opaque_depth(encoder);
|
||||
}
|
||||
let Some(t) = &self.target else { return };
|
||||
let Some(gbuffer) = &t.gbuffer else { return };
|
||||
let [w, h] = t.size.map(|v| v.max(1) as f32);
|
||||
let uniform = GbufferUniform {
|
||||
inverse_view: frame.inverse_view,
|
||||
inverse_projection: frame.inverse_projection,
|
||||
resolution: [w, h, 1.0 / w, 1.0 / h],
|
||||
env: [
|
||||
f32::from(eye_light[0]) / 15.0,
|
||||
f32::from(eye_light[1]) / 15.0,
|
||||
wetness,
|
||||
frame.sun[1],
|
||||
],
|
||||
};
|
||||
queue.write_buffer(&self.gbuffer_uniform, 0, bytemuck::bytes_of(&uniform));
|
||||
let attachments = [
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view: &gbuffer.normal,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
}),
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view: &gbuffer.material,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
}),
|
||||
];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.gbuffer_pipeline);
|
||||
pass.set_bind_group(0, &gbuffer.group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// The world target's size, once `prepare` made one.
|
||||
pub fn size(&self) -> Option<[u32; 2]> {
|
||||
self.target.as_ref().map(|t| t.size)
|
||||
}
|
||||
|
||||
/// Changes whenever `prepare` or `prepare_shadow` makes new targets:
|
||||
/// bindings made from the views below must be made again.
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
/// The depth before translucent geometry (`capture_opaque_depth`), or
|
||||
/// the world's depth when nothing reads it.
|
||||
pub fn opaque_depth(&self) -> &wgpu::TextureView {
|
||||
match &self.target {
|
||||
Some(WorldTarget {
|
||||
opaque_depth: Some((_, view)),
|
||||
..
|
||||
}) => view,
|
||||
Some(t) => &t.depth,
|
||||
None => &self.fallback_shadow,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstructed world-space normals (`scene_normal`).
|
||||
pub fn normal(&self) -> &wgpu::TextureView {
|
||||
self.gbuffer().map_or(&self.fallback_color, |g| &g.normal)
|
||||
}
|
||||
|
||||
/// Reconstructed material and light (`scene_material`).
|
||||
pub fn material(&self) -> &wgpu::TextureView {
|
||||
self.gbuffer().map_or(&self.fallback_color, |g| &g.material)
|
||||
}
|
||||
|
||||
fn gbuffer(&self) -> Option<&Gbuffer> {
|
||||
self.target.as_ref()?.gbuffer.as_ref()
|
||||
}
|
||||
|
||||
/// The sun/moon shadow map (`shadow_depth` of api 2 packs).
|
||||
pub fn shadow_depth(&self) -> &wgpu::TextureView {
|
||||
self.shadow
|
||||
.as_ref()
|
||||
.map_or(&self.fallback_shadow, |s| &s.depth)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -252,9 +252,6 @@ pub struct Graphics {
|
||||
/// or behind other windows, and half the worker threads. Off by default;
|
||||
/// it changes when frames are drawn, never what they show or the ticks.
|
||||
pub battery: bool,
|
||||
/// The Horizon shader pack in `shaderpacks/` (its folder name); empty:
|
||||
/// none, the vanilla picture (docs/shader-packs.md).
|
||||
pub shader_pack: String,
|
||||
/// The graphics API (Windows only; see `GraphicsApi`).
|
||||
pub api: GraphicsApi,
|
||||
}
|
||||
@@ -280,7 +277,6 @@ impl Default for Graphics {
|
||||
resolution_scale: 100,
|
||||
fxaa: false,
|
||||
battery: false,
|
||||
shader_pack: String::new(),
|
||||
api: GraphicsApi::Auto,
|
||||
}
|
||||
}
|
||||
@@ -332,7 +328,6 @@ impl Graphics {
|
||||
"resolutionScale" => g.resolution_scale = number(g.resolution_scale).clamp(50, 200),
|
||||
"fxaa" => g.fxaa = flag(g.fxaa),
|
||||
"batteryMode" => g.battery = flag(g.battery),
|
||||
"shaderPack" => g.shader_pack = value.to_string(),
|
||||
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
|
||||
"overlay" => {
|
||||
g.overlay = match value {
|
||||
@@ -348,7 +343,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\ncullLeaves:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\nshaderPack:{}\ngraphicsApi:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\ncullLeaves:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\ngraphicsApi:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -375,7 +370,6 @@ impl Graphics {
|
||||
self.resolution_scale,
|
||||
bool_text(self.fxaa),
|
||||
bool_text(self.battery),
|
||||
self.shader_pack,
|
||||
self.api.name(),
|
||||
)
|
||||
}
|
||||
@@ -469,13 +463,17 @@ mod tests {
|
||||
resolution_scale: 75,
|
||||
fxaa: true,
|
||||
battery: true,
|
||||
shader_pack: "horizon-sample".into(),
|
||||
api: GraphicsApi::Dx12,
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g);
|
||||
assert_eq!(Graphics::parse(""), Graphics::default());
|
||||
// The old menus' Simple / Advanced choice is ignored.
|
||||
// The old menus' Simple / Advanced choice is ignored, and so is the
|
||||
// shader pack older versions saved.
|
||||
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
|
||||
assert_eq!(
|
||||
Graphics::parse("shaderPack:Pack.zip\n"),
|
||||
Graphics::default()
|
||||
);
|
||||
assert_eq!(
|
||||
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:maybe\nentityCulling:maybe\n"),
|
||||
Graphics::default()
|
||||
|
||||
@@ -39,7 +39,6 @@ pub enum H {
|
||||
ResolutionScale,
|
||||
Antialiasing,
|
||||
BatteryMode,
|
||||
ShaderPack,
|
||||
GraphicsApi,
|
||||
}
|
||||
|
||||
@@ -112,18 +111,13 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
"Waits for the display's refresh: no tearing, a little latency.",
|
||||
);
|
||||
let gui = v(Opt::GuiScale, None, "Size of menus and the HUD.");
|
||||
let shader_pack = h(
|
||||
H::ShaderPack,
|
||||
High,
|
||||
"Redraws the world with a pack from the shaderpacks folder. Off is vanilla.",
|
||||
);
|
||||
let preset = h(
|
||||
H::Preset,
|
||||
None,
|
||||
"Sets the options below together. Changing one makes it Custom.",
|
||||
);
|
||||
let mut rows = match level {
|
||||
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui, shader_pack],
|
||||
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui],
|
||||
Level::Advanced => vec![
|
||||
Row::Header("Rendering"),
|
||||
render,
|
||||
@@ -251,7 +245,6 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
None,
|
||||
"Lowers the frame rate when minimised, or idle too.",
|
||||
),
|
||||
shader_pack,
|
||||
Row::Header("Preferences"),
|
||||
v(Opt::ViewBobbing, None, "Sways the view while walking."),
|
||||
v(Opt::ChunkFade, None, "Fades newly loaded chunks in."),
|
||||
@@ -381,10 +374,6 @@ impl H {
|
||||
H::Antialiasing => labelled("Anti-aliasing", if g.fxaa { "FXAA" } else { "Off" }),
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::GraphicsApi => labelled("Graphics API", g.api.label()),
|
||||
H::ShaderPack => match g.shader_pack.as_str() {
|
||||
"" => labelled("Shader Pack", "Off"),
|
||||
id => labelled("Shader Pack", id),
|
||||
},
|
||||
H::Overlay => labelled(
|
||||
"Performance Overlay",
|
||||
match g.overlay {
|
||||
@@ -527,7 +516,6 @@ impl Item {
|
||||
| H::PresentMode
|
||||
| H::Workers
|
||||
| H::BatteryMode
|
||||
| H::ShaderPack
|
||||
| H::GraphicsApi
|
||||
) | Item::Vanilla(
|
||||
Opt::GuiScale
|
||||
@@ -557,8 +545,6 @@ pub enum Action {
|
||||
Changed,
|
||||
OpenAdvanced,
|
||||
OpenSimple,
|
||||
/// The Shader Pack row: the Shader Packs page (where packs are picked).
|
||||
OpenShaderPacks,
|
||||
Done,
|
||||
}
|
||||
|
||||
@@ -784,9 +770,6 @@ impl GraphicsUi {
|
||||
}
|
||||
return match wd.target {
|
||||
Target::Button(action) => vec![Action::ButtonClick, action],
|
||||
Target::Item(Item::Horizon(H::ShaderPack)) => {
|
||||
vec![Action::ButtonClick, Action::OpenShaderPacks]
|
||||
}
|
||||
Target::Item(item) => {
|
||||
if item.slider(s).is_some() {
|
||||
self.dragging = Some(item);
|
||||
@@ -994,44 +977,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shader_pack_row_is_off_by_default_and_outside_presets() {
|
||||
let mut s = Settings::default();
|
||||
let row = Item::Horizon(H::ShaderPack);
|
||||
assert_eq!(row.label(&s).plain(), "Shader Pack: Off");
|
||||
assert!(
|
||||
rows(Level::Advanced)
|
||||
.iter()
|
||||
.any(|r| matches!(r, Row::Setting(x) if x.item == row))
|
||||
);
|
||||
s.horizon.shader_pack = "horizon-sample".into();
|
||||
assert_eq!(row.label(&s).plain(), "Shader Pack: horizon-sample");
|
||||
// The row opens the Shader Packs page, where packs are picked.
|
||||
let mut ui = GraphicsUi::default();
|
||||
let find = |ui: &mut GraphicsUi| {
|
||||
let (widgets, ..) = ui.layout(Level::Advanced, 854.0, 480.0);
|
||||
widgets
|
||||
.into_iter()
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == row))
|
||||
.unwrap()
|
||||
};
|
||||
ui.scroll = find(&mut ui).y - GraphicsUi::bounds(480.0).0 - 4.0;
|
||||
let w = find(&mut ui);
|
||||
assert_eq!(
|
||||
ui.click(
|
||||
Level::Advanced,
|
||||
&mut s,
|
||||
(w.x + 1.0, w.y + 1.0),
|
||||
(854.0, 480.0),
|
||||
[854, 480],
|
||||
false,
|
||||
false
|
||||
),
|
||||
[Action::ButtonClick, Action::OpenShaderPacks]
|
||||
);
|
||||
assert_eq!(s.horizon.shader_pack, "horizon-sample");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_set_vanilla_and_horizon_options() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -298,6 +298,7 @@ impl PlayerMotion {
|
||||
blocking,
|
||||
blocked: 0.0,
|
||||
block_sound: false,
|
||||
knocked: false,
|
||||
totem: totem_ready,
|
||||
totem_fired: false,
|
||||
};
|
||||
@@ -313,6 +314,7 @@ impl PlayerMotion {
|
||||
hurt,
|
||||
blocked: environment.blocked,
|
||||
sound: environment.block_sound,
|
||||
knockback: environment.knocked,
|
||||
};
|
||||
if environment.totem_fired {
|
||||
h.totem_used = true;
|
||||
@@ -373,6 +375,8 @@ pub(crate) struct Environment<'a> {
|
||||
pub(crate) blocking: Option<Blocking>,
|
||||
pub(crate) blocked: f32,
|
||||
pub(crate) block_sound: bool,
|
||||
/// `dealDefaultKnockback` ran: a fresh hit, not fully blocked.
|
||||
pub(crate) knocked: bool,
|
||||
/// LivingEntity.checkTotemDeathProtection: a totem is held / was used.
|
||||
pub(crate) totem: bool,
|
||||
pub(crate) totem_fired: bool,
|
||||
@@ -435,6 +439,9 @@ pub struct BlockOutcome {
|
||||
pub blocked: f32,
|
||||
/// `BlocksAttacks.onBlocked` ran (the block sound plays).
|
||||
pub sound: bool,
|
||||
/// The hit knocks the player back (`dealDefaultKnockback`): not one in
|
||||
/// the hurt cooldown, nor one the shield took whole.
|
||||
pub knockback: bool,
|
||||
}
|
||||
struct MatchedSets;
|
||||
impl horizon_world::blocking_rules::TypeMatchWorld for MatchedSets {
|
||||
@@ -667,9 +674,11 @@ impl HurtWorld for Environment<'_> {
|
||||
*self.sync_velocity = true;
|
||||
Ok(())
|
||||
}
|
||||
/// The caller applies it: these sources carry no entity or position.
|
||||
fn knockback(&mut self, _: &mut State, _: f32, _: bool) -> io::Result<()> {
|
||||
self.knocked = true;
|
||||
Ok(())
|
||||
} // no entity or position on these sources
|
||||
}
|
||||
fn death_protection(&mut self, _: &mut State) -> io::Result<bool> {
|
||||
if !self.totem
|
||||
|| self
|
||||
@@ -761,6 +770,37 @@ impl ServerPlayerHurtWorld for Environment<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_hit_in_the_hurt_cooldown_knocks_nobody_back() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let resources = crate::motion::MotionResources::load(&root, &assets).unwrap();
|
||||
let mut player =
|
||||
crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO)
|
||||
.unwrap();
|
||||
let (health, rules) = (&resources.health, super::HealthRules::default());
|
||||
let id = health.damage.id("minecraft:mob_attack").unwrap();
|
||||
let mut hit = |amount| {
|
||||
player
|
||||
.hurt_blocking(health, rules, id, amount, None)
|
||||
.unwrap()
|
||||
};
|
||||
let first = hit(1.0);
|
||||
assert!(first.hurt && first.knockback);
|
||||
// A stronger hit inside the cooldown hurts by the difference only
|
||||
// (LivingEntity.hurtServer), with no dealDefaultKnockback.
|
||||
let second = hit(2.0);
|
||||
assert!(second.hurt && !second.knockback);
|
||||
// A weaker one doesn't hurt at all.
|
||||
let third = hit(1.5);
|
||||
assert!(!third.hurt && !third.knockback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hurt_sounds_follow_damage_effects() {
|
||||
// DamageEffects: HURT, THORNS, DROWNING, BURNING, POKING, FREEZING.
|
||||
|
||||
@@ -156,6 +156,7 @@ impl FoodWorld for TickWorld<'_> {
|
||||
blocking: None,
|
||||
blocked: 0.0,
|
||||
block_sound: false,
|
||||
knocked: false,
|
||||
totem: false,
|
||||
totem_fired: false,
|
||||
};
|
||||
|
||||
@@ -271,23 +271,15 @@ fn read_levels(t: Option<&Tag>) -> Levels {
|
||||
return Vec::new();
|
||||
};
|
||||
// Pre-1.21.5 worlds wrap the map in {levels: {...}}.
|
||||
if let Some(Tag::Compound(inner)) = map.get(&JavaString::from("levels")) {
|
||||
return inner
|
||||
.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
Some((
|
||||
Arc::from(k.to_string_lossy().as_str()),
|
||||
int(v)?.clamp(1, 255) as u32,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let map = match map.get(&JavaString::from("levels")) {
|
||||
Some(Tag::Compound(inner)) => inner,
|
||||
_ => map,
|
||||
};
|
||||
// The keys are enchantment Identifiers: `knockback` is `minecraft:knockback`.
|
||||
map.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
Some((
|
||||
Arc::from(k.to_string_lossy().as_str()),
|
||||
int(v)?.clamp(1, 255) as u32,
|
||||
))
|
||||
let id = horizon_world::datafix::dynamic::namespaced(&k.to_string_lossy());
|
||||
Some((Arc::from(id), int(v)?.clamp(1, 255) as u32))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -863,6 +855,22 @@ mod tests {
|
||||
assert_eq!(back.custom_name(), Some("Blade"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enchantment_ids_without_a_namespace_are_minecraft_ones() {
|
||||
let levels = Tag::compound([
|
||||
("knockback", Tag::Int(2)),
|
||||
("minecraft:sharpness", Tag::Int(1)),
|
||||
]);
|
||||
let components = Tag::compound([("minecraft:enchantments", levels)]);
|
||||
let (_, extra) = decode_components(Some(&components));
|
||||
let stack = ItemStack {
|
||||
extra,
|
||||
..ItemStack::new("minecraft:wooden_sword", 1)
|
||||
};
|
||||
assert_eq!(stack.enchantment_level("minecraft:knockback"), 2);
|
||||
assert_eq!(stack.enchantment_level("minecraft:sharpness"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_name_reads_the_extra_spans() {
|
||||
let name = Tag::compound([
|
||||
|
||||
@@ -120,6 +120,8 @@ pub struct ItemInfo {
|
||||
pub dye: Option<String>,
|
||||
/// `block_transformer`: the registry entry an axe, hoe or shovel uses on blocks.
|
||||
pub block_transformer: Option<String>,
|
||||
/// Has a default `bundle_contents` (the bundles, `BundleItem`).
|
||||
pub bundle: bool,
|
||||
}
|
||||
/// `Item.Properties.craftRemainder` (not a data component, so not in the
|
||||
/// default-component report): what an item leaves in the grid when crafted with.
|
||||
@@ -593,6 +595,7 @@ impl Items {
|
||||
jukebox_song: c["minecraft:jukebox_playable"].as_str().map(str::to_owned),
|
||||
dye: c["minecraft:dye"].as_str().map(str::to_owned),
|
||||
block_transformer: c["minecraft:block_transformer"].as_str().map(str::to_owned),
|
||||
bundle: c.get("minecraft:bundle_contents").is_some(),
|
||||
crafting_remainder: CRAFT_REMAINDERS
|
||||
.iter()
|
||||
.find(|(item, _)| *item == name)
|
||||
@@ -1048,7 +1051,7 @@ impl LootContext<'_> {
|
||||
continue;
|
||||
}
|
||||
// LootPool: rolls.getInt (Mth.randomBetweenInclusive for uniform rolls).
|
||||
let rolls = ctx.int_number(&pool["rolls"], random).max(0) as u32;
|
||||
let rolls = LootContext::int_number(&pool["rolls"], random).max(0) as u32;
|
||||
for _ in 0..rolls {
|
||||
let mut produced = Vec::new();
|
||||
ctx.choose(
|
||||
@@ -1082,7 +1085,7 @@ impl LootContext<'_> {
|
||||
fn enchantment_level(&self, id: &str) -> u32 {
|
||||
self.held.map_or(0, |h| h.enchantment_level(id))
|
||||
}
|
||||
fn number<R: RandomSource>(&self, v: &Value, random: &mut R) -> f32 {
|
||||
fn number<R: RandomSource>(v: &Value, random: &mut R) -> f32 {
|
||||
match v {
|
||||
Value::Number(n) => n.as_f64().unwrap_or(0.0) as f32,
|
||||
Value::Object(o) => match o
|
||||
@@ -1094,8 +1097,8 @@ impl LootContext<'_> {
|
||||
o.get("value").and_then(Value::as_f64).unwrap_or(0.0) as f32
|
||||
}
|
||||
"minecraft:uniform" => {
|
||||
let min = self.number(o.get("min").unwrap_or(&Value::Null), random);
|
||||
let max = self.number(o.get("max").unwrap_or(&Value::Null), random);
|
||||
let min = Self::number(o.get("min").unwrap_or(&Value::Null), random);
|
||||
let max = Self::number(o.get("max").unwrap_or(&Value::Null), random);
|
||||
// UniformGenerator.getFloat / getInt (Mth.nextInt) differ; use int for counts.
|
||||
if max <= min {
|
||||
min
|
||||
@@ -1104,8 +1107,8 @@ impl LootContext<'_> {
|
||||
}
|
||||
}
|
||||
"minecraft:binomial" => {
|
||||
let n = self.number(o.get("n").unwrap_or(&Value::Null), random) as u32;
|
||||
let p = self.number(o.get("p").unwrap_or(&Value::Null), random);
|
||||
let n = Self::number(o.get("n").unwrap_or(&Value::Null), random) as u32;
|
||||
let p = Self::number(o.get("p").unwrap_or(&Value::Null), random);
|
||||
(0..n).filter(|_| random.next_float() < p).count() as f32
|
||||
}
|
||||
_ => 1.0,
|
||||
@@ -1113,22 +1116,22 @@ impl LootContext<'_> {
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
fn int_number<R: RandomSource>(&self, v: &Value, random: &mut R) -> i32 {
|
||||
fn int_number<R: RandomSource>(v: &Value, random: &mut R) -> i32 {
|
||||
if let Value::Object(o) = v
|
||||
&& o.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("minecraft:uniform")
|
||||
== "minecraft:uniform"
|
||||
{
|
||||
let min = self.int_number(o.get("min").unwrap_or(&Value::Null), random);
|
||||
let max = self.int_number(o.get("max").unwrap_or(&Value::Null), random);
|
||||
let min = Self::int_number(o.get("min").unwrap_or(&Value::Null), random);
|
||||
let max = Self::int_number(o.get("max").unwrap_or(&Value::Null), random);
|
||||
return if max <= min {
|
||||
min
|
||||
} else {
|
||||
min + random.next_int_bound(max - min + 1).unwrap_or(0)
|
||||
};
|
||||
}
|
||||
self.number(v, random).round() as i32
|
||||
Self::number(v, random).round() as i32
|
||||
}
|
||||
fn condition<R: RandomSource>(&self, c: &Value, random: &mut R) -> bool {
|
||||
let (kind, c) = match c {
|
||||
@@ -1154,7 +1157,7 @@ impl LootContext<'_> {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.all(|t| self.condition(t, random)),
|
||||
"minecraft:random_chance" => random.next_float() < self.number(&c["chance"], random),
|
||||
"minecraft:random_chance" => random.next_float() < Self::number(&c["chance"], random),
|
||||
"minecraft:table_bonus" => {
|
||||
// BonusLevelTableCondition: chances[min(level, len - 1)].
|
||||
let level =
|
||||
@@ -1375,7 +1378,7 @@ impl LootContext<'_> {
|
||||
}
|
||||
match f["type"].as_str().or(f["function"].as_str()).unwrap_or("") {
|
||||
"minecraft:set_count" => {
|
||||
let n = self.int_number(&f["count"], random);
|
||||
let n = Self::int_number(&f["count"], random);
|
||||
let base = if f["add"].as_bool() == Some(true) {
|
||||
stack.count as i32
|
||||
} else {
|
||||
@@ -1418,7 +1421,7 @@ impl LootContext<'_> {
|
||||
// Attacker's level x count, rounded (looting).
|
||||
let level = self.enchantment_level(f["enchantment"].as_str().unwrap_or(""));
|
||||
if level > 0 && self.killed_by_player && self.block.is_empty() {
|
||||
let add = level as f32 * self.number(&f["count"], random);
|
||||
let add = level as f32 * Self::number(&f["count"], random);
|
||||
stack.count = (stack.count as i32 + add.round() as i32).max(0) as u32;
|
||||
if let Some(limit) = f["limit"].as_i64().filter(|l| *l > 0) {
|
||||
stack.count = stack.count.min(limit as u32);
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
//! Java (OptiFine/Iris) shader packs in `shaderpacks/` (docs/shader-packs.md):
|
||||
//! a folder holding `shaders/`, or a `.zip`. `horizon-shaderpack` translates
|
||||
//! their full-screen programs (`begin`, `prepare`, `deferred`, `composite`,
|
||||
//! `final`) to WGSL, and `gfx/java_stages.rs` runs them over Horizon's world
|
||||
//! as Iris runs them over its own. Geometry programs (`gbuffers_*`,
|
||||
//! `shadow`) don't run yet: the world is drawn by Horizon.
|
||||
use horizon_shaderpack::{
|
||||
JavaPack,
|
||||
pipeline::{Directives, Program, TargetSettings},
|
||||
preprocess::Environment,
|
||||
properties::SkyDirectives,
|
||||
uniforms::CustomUniforms,
|
||||
};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
/// The passes of one program folder (`world0`, or `""` for the pack's root).
|
||||
pub struct JavaFolder {
|
||||
pub folder: String,
|
||||
pub targets: Vec<TargetSettings>,
|
||||
/// `begin*`, `prepare*`, `deferred*`, `composite*`, in order.
|
||||
pub passes: Vec<Program>,
|
||||
/// How many of `passes` run before the world (`begin*`, `prepare*`): the
|
||||
/// world's colour and depth enter `world_target` and `depthtex*` after them.
|
||||
pub world_at: usize,
|
||||
/// The colour target the world goes into, where the pack's terrain
|
||||
/// program draws (MakeUp: colortex1).
|
||||
pub world_target: u32,
|
||||
/// The targets whose alternate side the world is written to.
|
||||
pub flipped_after_prepare: BTreeSet<u32>,
|
||||
pub final_pass: Option<Program>,
|
||||
/// The targets whose alternate side holds the frame's last write.
|
||||
pub flipped_at_end: BTreeSet<u32>,
|
||||
/// `sunPathRotation`, `eyeBrightnessHalflife`, `wetnessHalflife` and
|
||||
/// `drynessHalflife`.
|
||||
pub directives: Directives,
|
||||
}
|
||||
|
||||
/// The key of `texture.noise` (`noisetex`) in [`JavaPasses::custom_textures`].
|
||||
pub const NOISE_KEY: &str = "noise";
|
||||
|
||||
/// The key of `texture.<stage>.<sampler>` in [`JavaPasses::custom_textures`]
|
||||
/// for a pass of `program` (`world0/composite2`): Iris's texture stages, where
|
||||
/// `final` reads the `composite` ones.
|
||||
pub fn texture_key(program: &str, sampler: &str) -> String {
|
||||
let name = program.rsplit('/').next().unwrap_or(program);
|
||||
let stage = match name.trim_end_matches(|c: char| c.is_ascii_digit()) {
|
||||
"final" => "composite",
|
||||
stage => stage,
|
||||
};
|
||||
format!("{stage}.{sampler}")
|
||||
}
|
||||
|
||||
/// A loaded Java pack.
|
||||
pub struct JavaPasses {
|
||||
pub folders: Vec<JavaFolder>,
|
||||
/// `uniform.*` / `variable.*` from `shaders.properties`.
|
||||
pub uniforms: CustomUniforms,
|
||||
/// What the pack keeps of the vanilla sky (`clouds`, `sun`, `moon`).
|
||||
pub sky: SkyDirectives,
|
||||
/// `texture.<stage>.<sampler>` and `texture.noise` images, by
|
||||
/// `"<stage>.<sampler>"` and [`NOISE_KEY`].
|
||||
pub custom_textures: BTreeMap<String, crate::shader_pack::Rgba>,
|
||||
}
|
||||
|
||||
impl JavaPasses {
|
||||
/// The folder Iris uses in `dimension` (0 overworld, -1 the Nether, 1 the
|
||||
/// End): `world<N>` when the pack has one, else `world0`, else the root.
|
||||
pub fn folder_for(&self, dimension: i8) -> Option<usize> {
|
||||
let find = |name: &str| self.folders.iter().position(|f| f.folder == name);
|
||||
find(&format!("world{dimension}"))
|
||||
.or_else(|| find("world0"))
|
||||
.or_else(|| find(""))
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder holding `shaders/`, or a zip.
|
||||
pub fn is_java_pack(path: &Path) -> bool {
|
||||
path.join("shaders").is_dir()
|
||||
|| path.is_file()
|
||||
&& path
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("zip"))
|
||||
}
|
||||
|
||||
/// The `MC_GL_VENDOR_*` name of a wgpu adapter (`AdapterInfo::vendor`, a
|
||||
/// PCI id; Apple's GPUs report 0).
|
||||
pub fn vendor_name(vendor: u32, adapter: &str) -> &'static str {
|
||||
match vendor {
|
||||
0x10de => "NVIDIA Corporation",
|
||||
0x1002 | 0x1022 => "AMD",
|
||||
0x8086 => "Intel",
|
||||
_ if adapter.starts_with("Apple") => "Apple",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the pack at `path` and translate its full-screen passes with the
|
||||
/// player's `values`; `vendor` and `renderer` name the GPU to the pack.
|
||||
pub fn load(
|
||||
path: &Path,
|
||||
values: &BTreeMap<String, String>,
|
||||
vendor: &str,
|
||||
renderer: &str,
|
||||
) -> Result<JavaPasses, String> {
|
||||
let pack = JavaPack::open(path)?;
|
||||
let values = pack.values(values);
|
||||
let env = Environment::iris(vendor, renderer, 4);
|
||||
let properties = pack.properties(&values, &env)?;
|
||||
let mut names: Vec<&str> = ["world0", "world-1", "world1"]
|
||||
.into_iter()
|
||||
.filter(|d| {
|
||||
pack.programs
|
||||
.iter()
|
||||
.any(|p| p.starts_with(&format!("{d}/")))
|
||||
})
|
||||
.collect();
|
||||
if pack.programs.iter().any(|p| !p.contains('/')) {
|
||||
names.push("");
|
||||
}
|
||||
let mut folders = Vec::new();
|
||||
for name in names {
|
||||
let plan = pack.passes(name, &values, &env, &properties)?;
|
||||
let compile = |pass| {
|
||||
let program = pack.compile(pass, &plan.targets, &values, &env, &properties)?;
|
||||
if let Some((image, _)) = program.fragment.interface.images.first() {
|
||||
return Err(format!(
|
||||
"{}: images ({image}) aren't supported yet",
|
||||
program.pass.program
|
||||
));
|
||||
}
|
||||
Ok::<_, String>(program)
|
||||
};
|
||||
let early = plan.begin.iter().chain(&plan.prepare);
|
||||
let passes = early
|
||||
.clone()
|
||||
.chain(&plan.deferred)
|
||||
.chain(&plan.composite)
|
||||
.map(compile)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let final_pass = plan.final_pass.as_ref().map(compile).transpose()?;
|
||||
if passes.is_empty() && final_pass.is_none() {
|
||||
continue;
|
||||
}
|
||||
folders.push(JavaFolder {
|
||||
folder: name.to_owned(),
|
||||
targets: plan.targets,
|
||||
world_at: early.count(),
|
||||
world_target: plan.world_target,
|
||||
passes,
|
||||
flipped_after_prepare: plan.flipped_after_prepare,
|
||||
final_pass,
|
||||
flipped_at_end: plan.flipped_at_end,
|
||||
directives: plan.directives,
|
||||
});
|
||||
}
|
||||
if folders.is_empty() {
|
||||
return Err("no deferred, composite or final programs".into());
|
||||
}
|
||||
let mut custom_textures = BTreeMap::new();
|
||||
for tex in properties.custom_textures() {
|
||||
let key = match tex.stage {
|
||||
Some(stage) => format!("{stage}.{}", tex.sampler),
|
||||
None if tex.sampler == NOISE_KEY => NOISE_KEY.to_owned(),
|
||||
None => continue,
|
||||
};
|
||||
// Only PNGs for now: resource locations and raw `.dat` textures are
|
||||
// skipped and the sampler reads what it would without them.
|
||||
let image = pack
|
||||
.files
|
||||
.read_bytes(&tex.path)
|
||||
.ok_or_else(|| "not in the pack".to_owned())
|
||||
.and_then(|bytes| crate::shader_pack::decode_png(&bytes));
|
||||
match image {
|
||||
Ok(image) => {
|
||||
custom_textures.insert(key, image);
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: shader pack texture {}: {e}", tex.path),
|
||||
}
|
||||
}
|
||||
let uniforms = properties.custom_uniforms();
|
||||
for problem in &uniforms.problems {
|
||||
log::warn!("Horizon: shader pack custom uniform {problem}");
|
||||
}
|
||||
Ok(JavaPasses {
|
||||
folders,
|
||||
uniforms,
|
||||
sky: properties.sky(),
|
||||
custom_textures,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn custom_textures_belong_to_iris_s_stages() {
|
||||
assert_eq!(texture_key("world0/composite2", "gaux1"), "composite.gaux1");
|
||||
assert_eq!(texture_key("final", "colortex4"), "composite.colortex4");
|
||||
assert_eq!(
|
||||
texture_key("world-1/deferred10", "noisetex"),
|
||||
"deferred.noisetex"
|
||||
);
|
||||
assert_eq!(texture_key("prepare", "depthtex2"), "prepare.depthtex2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_the_full_screen_passes_of_a_java_pack() {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-shaderpack/tests/fixtures/passes");
|
||||
assert!(is_java_pack(&dir));
|
||||
let pack = load(&dir, &BTreeMap::new(), "Apple", "Apple M4 Pro").unwrap();
|
||||
let [folder] = &pack.folders[..] else {
|
||||
panic!("one folder, the root");
|
||||
};
|
||||
assert_eq!(folder.folder, "");
|
||||
assert_eq!(
|
||||
folder
|
||||
.passes
|
||||
.iter()
|
||||
.map(|p| p.pass.program.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["deferred", "composite", "composite1"]
|
||||
);
|
||||
assert_eq!(folder.world_at, 0);
|
||||
assert!(folder.final_pass.is_some());
|
||||
// The overworld, the Nether and the End all use the root.
|
||||
assert_eq!([-1, 0, 1].map(|d| pack.folder_for(d)), [Some(0); 3]);
|
||||
assert_eq!(
|
||||
vendor_name(0x10de, "NVIDIA GeForce RTX 4080"),
|
||||
"NVIDIA Corporation"
|
||||
);
|
||||
assert_eq!(vendor_name(0, "Apple M4 Pro"), "Apple");
|
||||
}
|
||||
|
||||
/// wgpu is given the WGSL text: it must parse again.
|
||||
fn reparse_error(pack: &JavaPasses) -> Option<String> {
|
||||
let passes = pack
|
||||
.folders
|
||||
.iter()
|
||||
.flat_map(|f| f.passes.iter().chain(&f.final_pass));
|
||||
passes
|
||||
.flat_map(|p| [(p, &p.vertex, "vsh"), (p, &p.fragment, "fsh")])
|
||||
.find_map(|(p, t, ext)| {
|
||||
let e = naga::front::wgsl::parse_str(&t.wgsl).err()?;
|
||||
Some(format!(
|
||||
"{}.{ext}: {}",
|
||||
p.pass.program,
|
||||
e.emit_to_string(&t.wgsl)
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The packs in `HORIZON_TEST_SHADERPACKS` (folders or zips) load as the
|
||||
/// game loads them, with their default options: the compatibility table
|
||||
/// of docs/shader-packs.md. Skipped when the variable isn't set.
|
||||
#[test]
|
||||
fn real_packs_load() {
|
||||
let Some(dir) = std::env::var_os("HORIZON_TEST_SHADERPACKS") else {
|
||||
return;
|
||||
};
|
||||
let mut entries: Vec<_> = std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| is_java_pack(p))
|
||||
.collect();
|
||||
entries.sort();
|
||||
let mut failures = Vec::new();
|
||||
for path in entries {
|
||||
let name = path.file_name().unwrap().to_string_lossy().into_owned();
|
||||
match load(&path, &BTreeMap::new(), "Apple", "Apple M4 Pro") {
|
||||
Ok(pack) if let Some(e) = reparse_error(&pack) => {
|
||||
println!("{name}: {e}");
|
||||
failures.push(name);
|
||||
}
|
||||
Ok(pack) => {
|
||||
let folders: Vec<String> = pack
|
||||
.folders
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let n = f.passes.len() + usize::from(f.final_pass.is_some());
|
||||
format!("{} {n} passes", f.folder)
|
||||
})
|
||||
.collect();
|
||||
println!("{name}: loads ({})", folders.join(", "));
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{name}: {e}");
|
||||
failures.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{failures:?}");
|
||||
}
|
||||
}
|
||||
@@ -489,12 +489,16 @@ impl ServerEntity {
|
||||
// A value back at its default is left out of the state, but the
|
||||
// clients still hold the old one: Entity's shared flags and pose go
|
||||
// back to 0 (a player standing up again, a mob no longer on fire),
|
||||
// a sleeper's bed to none.
|
||||
// a sleeper's bed to none, an item frame's item to empty and its
|
||||
// rotation to 0 (ItemFrame.DATA_ITEM and DATA_ROTATION).
|
||||
let frame = e.kind.ends_with("item_frame");
|
||||
for (index, kind, _) in &self.meta {
|
||||
let default = match (*index, *kind) {
|
||||
(0, serializer::BYTE) => Value::Byte(0),
|
||||
(6, serializer::POSE) => Value::Id(0),
|
||||
(14, serializer::OPTIONAL_BLOCK_POS) => Value::OptPos(None),
|
||||
(9, serializer::ITEM_STACK) if frame => Value::Item(None),
|
||||
(10, serializer::INT) if frame => Value::Int(0),
|
||||
_ => match e.defaults.iter().find(|d| (d.0, d.1) == (*index, *kind)) {
|
||||
Some(d) => d.2.clone(),
|
||||
None => continue,
|
||||
@@ -1106,6 +1110,39 @@ mod tests {
|
||||
assert!(changes(&mut s, &e).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_emptied_item_frame_sends_its_empty_item_and_rotation() {
|
||||
let mut e = state(0.0);
|
||||
e.kind = "minecraft:item_frame".into();
|
||||
let mut s = added(&e);
|
||||
changes(&mut s, &e);
|
||||
e.meta = vec![
|
||||
(
|
||||
9,
|
||||
serializer::ITEM_STACK,
|
||||
Value::Item(Some(Stack::new(5, 1))),
|
||||
),
|
||||
(10, serializer::INT, Value::Int(3)),
|
||||
];
|
||||
changes(&mut s, &e);
|
||||
// Punched out (ItemFrame.removeFramedMap, setItem(EMPTY)): both back to the default.
|
||||
e.meta.clear();
|
||||
let data = changes(&mut s, &e)
|
||||
.into_iter()
|
||||
.find_map(|p| match p {
|
||||
play::Clientbound::SetEntityData(d) => Some(d),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the empty frame is sent");
|
||||
assert_eq!(
|
||||
data.values,
|
||||
vec![
|
||||
(9, serializer::ITEM_STACK, Value::Item(None)),
|
||||
(10, serializer::INT, Value::Int(0)),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_back_at_a_listed_default_is_sent() {
|
||||
let mut e = state(0.0);
|
||||
|
||||
@@ -206,6 +206,9 @@ pub struct Guest {
|
||||
/// (Entity.DATA_AIR_SUPPLY_ID, Player.DATA_PLAYER_ABSORPTION_ID); their
|
||||
/// defaults until either changes.
|
||||
pub sent_air: (i32, f32),
|
||||
/// The shared flags (Entity.DATA_SHARED_FLAGS_ID) the player's client was
|
||||
/// last sent about itself.
|
||||
pub sent_shared: i8,
|
||||
/// Where the player last died (Player.lastDeathLocation), sent with the respawn.
|
||||
pub last_death: Option<[i32; 3]>,
|
||||
/// The last action sequence to acknowledge at the next tick (-1: none).
|
||||
@@ -336,6 +339,7 @@ impl Guest {
|
||||
sent_health: None,
|
||||
sent_xp: None,
|
||||
sent_air: (300, 0.0),
|
||||
sent_shared: 0,
|
||||
last_death: None,
|
||||
ack_up_to: std::cell::Cell::new(-1),
|
||||
sent_selected: 0,
|
||||
@@ -432,6 +436,13 @@ pub fn stack_to_network(
|
||||
item_id: impl Fn(&str) -> i32,
|
||||
enchantment_id: impl Fn(&str) -> Option<i32>,
|
||||
s: &Option<ItemStack>,
|
||||
) -> Option<Stack> {
|
||||
network_stack(&item_id, &enchantment_id, s)
|
||||
}
|
||||
fn network_stack(
|
||||
item_id: &dyn Fn(&str) -> i32,
|
||||
enchantment_id: &dyn Fn(&str) -> Option<i32>,
|
||||
s: &Option<ItemStack>,
|
||||
) -> Option<Stack> {
|
||||
let s = s.as_ref()?;
|
||||
let mut stack = Stack::new(item_id(&s.item), s.count as i32);
|
||||
@@ -488,6 +499,18 @@ pub fn stack_to_network(
|
||||
horizon_protocol::item::Component::Int(i32::from(mode)),
|
||||
));
|
||||
}
|
||||
// A loaded crossbow's ammunition.
|
||||
if !d.charged.is_empty() {
|
||||
let charged = d
|
||||
.charged
|
||||
.iter()
|
||||
.filter_map(|c| network_stack(item_id, enchantment_id, &Some(c.clone())))
|
||||
.collect();
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::CHARGED_PROJECTILES,
|
||||
horizon_protocol::item::Component::Stacks(charged),
|
||||
));
|
||||
}
|
||||
// The books a lectern shows and a guest reads.
|
||||
if !d.writable_pages.is_empty() {
|
||||
stack.added.push((
|
||||
|
||||
@@ -1477,16 +1477,7 @@ impl Hub {
|
||||
attributes: Vec<play::AttributeSnapshot>,
|
||||
) -> EntityState {
|
||||
let (sneaking, sprinting, gliding, sleeping) = flags;
|
||||
let mut shared = 0i8;
|
||||
if sneaking {
|
||||
shared |= 0x02;
|
||||
}
|
||||
if sprinting {
|
||||
shared |= 0x08;
|
||||
}
|
||||
if gliding {
|
||||
shared |= -128;
|
||||
}
|
||||
let shared = shared_flags(sneaking, sprinting, gliding);
|
||||
let pose = if sleeping {
|
||||
2
|
||||
} else if gliding {
|
||||
@@ -1764,6 +1755,18 @@ impl Hub {
|
||||
}));
|
||||
}
|
||||
}
|
||||
// ServerEntity.sendDirtyEntityData: the shared flags go to the player
|
||||
// too, so the sprint a hit stops (Player.causeExtraKnockback) stops
|
||||
// on its client.
|
||||
let g = &mut self.guests[i];
|
||||
let shared = shared_flags(g.sneaking, g.sprinting, g.fall_flying);
|
||||
if shared != g.sent_shared {
|
||||
g.sent_shared = shared;
|
||||
g.send(&C::SetEntityData(play::SetEntityData {
|
||||
id: g.entity_id,
|
||||
values: vec![(0, serializer::BYTE, Value::Byte(shared))],
|
||||
}));
|
||||
}
|
||||
// Sent when the health or the food level changes, or the saturation
|
||||
// reaches or leaves zero; a dead player's health of 0 too.
|
||||
let g = &mut self.guests[i];
|
||||
@@ -1981,14 +1984,15 @@ impl Hub {
|
||||
self.guests[i].sneaking = p.flags & 32 != 0;
|
||||
self.guests[i].sprinting = p.flags & 64 != 0 && p.flags & 1 != 0;
|
||||
}
|
||||
// OPEN_INVENTORY is the game's: the ridden mount's inventory.
|
||||
S::PlayerCommand(p) if p.action == 5 => {
|
||||
// OPEN_INVENTORY (the ridden mount's inventory) and
|
||||
// START_FALL_FLYING (checked against the guest's chest slot
|
||||
// and effects) are the game's.
|
||||
S::PlayerCommand(p) if matches!(p.action, 5 | 6) => {
|
||||
events.push(HostEvent::Packet(conn, Box::new(S::PlayerCommand(p))));
|
||||
}
|
||||
S::PlayerCommand(p) => match p.action {
|
||||
1 => self.guests[i].sprinting = true,
|
||||
2 => self.guests[i].sprinting = false,
|
||||
6 => self.guests[i].fall_flying = true,
|
||||
// STOP_SLEEPING.
|
||||
0 => self.wake(i),
|
||||
_ => {}
|
||||
@@ -2479,6 +2483,7 @@ impl Hub {
|
||||
g.sent_health = None;
|
||||
g.sent_xp = None;
|
||||
g.sent_air = (300, 0.0);
|
||||
g.sent_shared = 0;
|
||||
let (cx, cz) = g.chunk();
|
||||
g.send(&C::SetChunkCacheCenter(play::SetChunkCacheCenter {
|
||||
x: cx,
|
||||
@@ -2518,6 +2523,21 @@ fn ticking_state(rate: f32, frozen: bool) -> C {
|
||||
raw(ids::TICKING_STATE, w.data)
|
||||
}
|
||||
|
||||
/// Entity.DATA_SHARED_FLAGS_ID of a player: crouching, sprinting, gliding.
|
||||
fn shared_flags(sneaking: bool, sprinting: bool, gliding: bool) -> i8 {
|
||||
let mut shared = 0i8;
|
||||
if sneaking {
|
||||
shared |= 0x02;
|
||||
}
|
||||
if sprinting {
|
||||
shared |= 0x08;
|
||||
}
|
||||
if gliding {
|
||||
shared |= -128;
|
||||
}
|
||||
shared
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -4,10 +4,15 @@
|
||||
//! clone, throw, quick craft (drag) and pickup all; items thrown out of a
|
||||
//! menu are returned for the game to drop.
|
||||
use super::guest::Guest;
|
||||
use crate::{items::ItemStack, world::World};
|
||||
use crate::{
|
||||
bundle::{Contents, can_be_in_bundle},
|
||||
items::{ItemStack, Items},
|
||||
world::World,
|
||||
};
|
||||
|
||||
/// What the menus need to know about items.
|
||||
pub trait Rules {
|
||||
fn items(&self) -> &Items;
|
||||
fn max_stack(&self, item: &str) -> u32;
|
||||
/// The armor slot (0 head .. 3 feet) an item can be worn in.
|
||||
fn armor_index(&self, item: &str) -> Option<usize>;
|
||||
@@ -364,6 +369,9 @@ fn pickup(view: &mut View, slot: i32, button: i32, rules: &dyn Rules) {
|
||||
return;
|
||||
}
|
||||
let right = button == 1;
|
||||
if bundle_click(view, i, right, rules) {
|
||||
return;
|
||||
}
|
||||
let (slot_item, carried) = (view.get(i), view.guest.carried.clone());
|
||||
match (slot_item, carried) {
|
||||
(None, None) => {}
|
||||
@@ -410,6 +418,84 @@ fn pickup(view: &mut View, slot: i32, button: i32, rules: &dyn Rules) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `tryItemClickBehaviourOverride`: the carried stack's
|
||||
/// `BundleItem.overrideStackedOnOther`, then the clicked one's
|
||||
/// `overrideOtherStackedOnMe`. True when a bundle took the click.
|
||||
fn bundle_click(view: &mut View, i: usize, right: bool, rules: &dyn Rules) -> bool {
|
||||
let items = rules.items();
|
||||
let other = view.get(i);
|
||||
if let Some(mut carried) = view.guest.carried.clone()
|
||||
&& let Some(mut contents) = Contents::of(items, &carried)
|
||||
&& other.is_some() != right
|
||||
{
|
||||
match other {
|
||||
// tryTransfer: as much as fits, through `safeTake`.
|
||||
Some(mut other) => {
|
||||
if can_be_in_bundle(&other) {
|
||||
let amount = other.count.min(contents.room_for(items, &other));
|
||||
if amount > 0 {
|
||||
let mut taken = other.clone();
|
||||
taken.count = amount;
|
||||
other.count -= amount;
|
||||
view.set(i, (other.count > 0).then_some(other));
|
||||
contents.try_insert(items, &mut taken);
|
||||
}
|
||||
}
|
||||
}
|
||||
// removeOne into the empty slot (`safeInsert`); the rest goes back.
|
||||
None => {
|
||||
if let Some(mut stack) = contents.remove_one(items) {
|
||||
if view.may_place(i, &stack, rules) {
|
||||
let amount = stack.count.min(view.slot_max(i, &stack, rules));
|
||||
view.set(
|
||||
i,
|
||||
Some(ItemStack {
|
||||
count: amount,
|
||||
..stack.clone()
|
||||
}),
|
||||
);
|
||||
stack.count -= amount;
|
||||
}
|
||||
if stack.count > 0 {
|
||||
contents.try_insert(items, &mut stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
contents.store(&mut carried);
|
||||
view.guest.carried = Some(carried);
|
||||
return true;
|
||||
}
|
||||
let Some(mut bundle) = other else {
|
||||
return false;
|
||||
};
|
||||
let Some(mut contents) = Contents::of(items, &bundle) else {
|
||||
return false;
|
||||
};
|
||||
// Slot.allowModification: mayPickup and mayPlace of its item.
|
||||
let allowed = view.may_place(i, &bundle, rules);
|
||||
match (view.guest.carried.take(), right) {
|
||||
(Some(mut carried), false) => {
|
||||
if allowed {
|
||||
contents.try_insert(items, &mut carried);
|
||||
}
|
||||
view.guest.carried = (carried.count > 0).then_some(carried);
|
||||
}
|
||||
(None, true) => {
|
||||
if allowed {
|
||||
view.guest.carried = contents.remove_one(items);
|
||||
}
|
||||
}
|
||||
(carried, _) => {
|
||||
view.guest.carried = carried;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
contents.store(&mut bundle);
|
||||
view.set(i, Some(bundle));
|
||||
true
|
||||
}
|
||||
|
||||
fn swap(view: &mut View, slot: i32, button: i32, rules: &dyn Rules) {
|
||||
let Ok(i) = usize::try_from(slot) else {
|
||||
return;
|
||||
@@ -538,3 +624,116 @@ fn quick_craft(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::lan::{Joined, PlayerSender};
|
||||
use horizon_protocol::packets::configuration::ClientInformation;
|
||||
use std::sync::{Arc, atomic::AtomicI32, mpsc};
|
||||
|
||||
struct ItemRules(Items);
|
||||
impl Rules for ItemRules {
|
||||
fn items(&self) -> &Items {
|
||||
&self.0
|
||||
}
|
||||
fn max_stack(&self, item: &str) -> u32 {
|
||||
self.0.get(item).map_or(64, |i| i.max_stack)
|
||||
}
|
||||
fn armor_index(&self, _: &str) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn guest() -> Guest {
|
||||
let (tx, _rx) = mpsc::channel();
|
||||
Guest::new(
|
||||
Joined {
|
||||
conn: 2,
|
||||
uuid: 2,
|
||||
name: "Alex".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(8),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
},
|
||||
2,
|
||||
[0.0, 64.0, 0.0],
|
||||
8,
|
||||
)
|
||||
}
|
||||
|
||||
fn contents(stack: &Option<ItemStack>) -> Vec<(String, u32)> {
|
||||
let inside = stack.as_ref().and_then(|s| s.data());
|
||||
inside.map_or(Vec::new(), |d| {
|
||||
d.bundle_contents
|
||||
.iter()
|
||||
.map(|s| (s.item.to_string(), s.count))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// A guest's chest clicks run `BundleItem`'s overrides: a carried
|
||||
/// bundle takes a slot's stack and drops its first one into an empty
|
||||
/// slot; a stack clicked onto a bundle goes in, as far as it fits.
|
||||
#[test]
|
||||
fn guest_chest_clicks_fill_and_empty_bundles() {
|
||||
let root = std::env::var_os("HORIZON_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
});
|
||||
if !root.join("items.json").exists() {
|
||||
eprintln!("skipping resource-backed bundle check: {}", root.display());
|
||||
return;
|
||||
}
|
||||
let registry = horizon_world::level::data::load_registry(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, ®istry).unwrap();
|
||||
let rules = ItemRules(Items::load(&root, ®istry, &facts).unwrap());
|
||||
let mut g = guest();
|
||||
let mut chest = vec![
|
||||
Some(ItemStack::new("minecraft:stone", 48)),
|
||||
None,
|
||||
Some(ItemStack::new("minecraft:bundle", 1)),
|
||||
];
|
||||
let mut drag = None;
|
||||
let mut view = View {
|
||||
guest: &mut g,
|
||||
container: Some(&mut chest),
|
||||
};
|
||||
view.guest.carried = Some(ItemStack::new("minecraft:bundle", 1));
|
||||
// Left click with the bundle: the stone goes in.
|
||||
click(&mut view, &mut drag, 0, 0, input::PICKUP, &rules);
|
||||
assert_eq!(view.get(0), None);
|
||||
assert_eq!(
|
||||
contents(&view.guest.carried),
|
||||
[("minecraft:stone".into(), 48)]
|
||||
);
|
||||
// Right click on an empty slot: the stone comes back out.
|
||||
click(&mut view, &mut drag, 1, 1, input::PICKUP, &rules);
|
||||
assert_eq!(view.get(1).map(|s| s.count), Some(48));
|
||||
assert_eq!(contents(&view.guest.carried), []);
|
||||
// Ender pearls (1/16 each) onto the bundle in the chest: 16 fit.
|
||||
view.guest.carried = Some(ItemStack::new("minecraft:ender_pearl", 16));
|
||||
click(&mut view, &mut drag, 2, 0, input::PICKUP, &rules);
|
||||
assert_eq!(view.guest.carried, None);
|
||||
let bundle = view.get(2);
|
||||
assert_eq!(contents(&bundle), [("minecraft:ender_pearl".into(), 16)]);
|
||||
// Full: more stone stays in hand.
|
||||
view.guest.carried = Some(ItemStack::new("minecraft:stone", 5));
|
||||
click(&mut view, &mut drag, 2, 0, input::PICKUP, &rules);
|
||||
assert_eq!(view.guest.carried.as_ref().map(|s| s.count), Some(5));
|
||||
// Right click on it with an empty hand takes the pearls out.
|
||||
view.guest.carried = None;
|
||||
click(&mut view, &mut drag, 2, 1, input::PICKUP, &rules);
|
||||
assert_eq!(
|
||||
view.guest.carried.as_ref().map(|s| (&*s.item, s.count)),
|
||||
Some(("minecraft:ender_pearl", 16))
|
||||
);
|
||||
assert_eq!(contents(&view.get(2)), []);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ pub mod border_render;
|
||||
pub mod bossbar;
|
||||
pub mod breaking;
|
||||
pub mod brewing;
|
||||
pub mod bundle;
|
||||
pub mod camera;
|
||||
pub mod cauldron;
|
||||
pub mod chat;
|
||||
@@ -94,7 +95,6 @@ pub mod item_data;
|
||||
pub mod item_render;
|
||||
pub mod item_tooltip;
|
||||
pub mod items;
|
||||
pub mod java_pack;
|
||||
pub mod java_text;
|
||||
pub mod key_names;
|
||||
pub mod keybinds;
|
||||
@@ -158,7 +158,6 @@ pub mod screen_layout;
|
||||
pub mod sculk;
|
||||
pub use horizon_server_files as server;
|
||||
pub mod settings;
|
||||
pub mod shader_pack;
|
||||
pub mod shadows;
|
||||
pub mod signs;
|
||||
pub mod skins;
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::{
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
mod bundle;
|
||||
pub mod place;
|
||||
|
||||
/// What a menu needs to know about items and recipes.
|
||||
@@ -276,6 +277,9 @@ pub struct Menu {
|
||||
pub loom_patterns: Vec<Arc<str>>,
|
||||
/// Sounds the menu plays (`ui.loom.take_result`), for the caller.
|
||||
pub sounds: Vec<&'static str>,
|
||||
/// Sounds the player makes (a bundle's insert and remove), for the
|
||||
/// caller to play at the player as `Entity.playSound`.
|
||||
pub player_sounds: Vec<&'static str>,
|
||||
/// Recipes whose result was taken (`awardUsedRecipes`), for the caller.
|
||||
pub crafted: Vec<String>,
|
||||
/// `LivingEntity.onEquipItem` calls that change the item: the
|
||||
@@ -671,6 +675,7 @@ impl Menu {
|
||||
quick_slots: Vec::new(),
|
||||
loom_patterns: Vec::new(),
|
||||
sounds: Vec::new(),
|
||||
player_sounds: Vec::new(),
|
||||
crafted: Vec::new(),
|
||||
equipped: Vec::new(),
|
||||
merchant: None,
|
||||
@@ -931,6 +936,23 @@ impl Menu {
|
||||
}
|
||||
}
|
||||
|
||||
/// `slotsChanged(player.getInventory())`, which a bundle click calls
|
||||
/// (`BundleItem.broadcastChangesOnContainerMenu`): the menus that don't
|
||||
/// check which container changed recompute from their inputs.
|
||||
fn inventory_slots_changed(&mut self, env: &Env) {
|
||||
match self.kind {
|
||||
Kind::Inventory | Kind::Crafting => self.recompute(env),
|
||||
Kind::Loom => self.loom_changed(env),
|
||||
Kind::Stonecutter => self.stone_changed(env),
|
||||
Kind::Cartography => self.carto_changed(env),
|
||||
Kind::Merchant => self.merchant_update(env),
|
||||
// ItemCombinerMenu.slotsChanged only for its inputs; the
|
||||
// smithing table's hasRecipeError always.
|
||||
Kind::Smithing => self.smith_error(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `EnchantmentMenu.clickMenuButton`'s checks: the lapis, the cost and
|
||||
/// the levels (creative pays nothing). The enchanting itself needs a
|
||||
/// level and is the caller's (`enchant_button`).
|
||||
@@ -2536,10 +2558,12 @@ impl Menu {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The PICKUP branch of `doClick` (without the bundle overrides), ending
|
||||
/// with `slot.setChanged()`.
|
||||
/// The PICKUP branch of `doClick`: a bundle's override, else the
|
||||
/// plain click, ending with `slot.setChanged()`.
|
||||
fn pickup(&mut self, env: &Env, s: usize, primary: bool) {
|
||||
self.pickup_inner(env, s, primary);
|
||||
if !self.bundle_click(env, s, primary) {
|
||||
self.pickup_inner(env, s, primary);
|
||||
}
|
||||
self.slot_changed(env, s);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! `BundleItem`'s click overrides in a PICKUP click
|
||||
//! (`tryItemClickBehaviourOverride`).
|
||||
use super::{Env, Menu, tidy};
|
||||
use crate::bundle::{Contents, INSERT, INSERT_FAIL, REMOVE_ONE, can_be_in_bundle};
|
||||
|
||||
impl Menu {
|
||||
/// The carried stack's `overrideStackedOnOther`, then the clicked
|
||||
/// one's `overrideOtherStackedOnMe`. True when a bundle took the click.
|
||||
pub(super) fn bundle_click(&mut self, env: &Env, s: usize, primary: bool) -> bool {
|
||||
let took = self.carried_bundle_click(env, s, primary)
|
||||
|| self.clicked_bundle_click(env, s, primary);
|
||||
if took {
|
||||
// broadcastChangesOnContainerMenu: the open menu's
|
||||
// slotsChanged for the player's inventory.
|
||||
self.inventory_slots_changed(env);
|
||||
}
|
||||
took
|
||||
}
|
||||
|
||||
/// `BundleItem.overrideStackedOnOther`: the carried bundle on a slot.
|
||||
fn carried_bundle_click(&mut self, env: &Env, s: usize, primary: bool) -> bool {
|
||||
let Some(mut contents) = self
|
||||
.carried
|
||||
.as_ref()
|
||||
.and_then(|c| Contents::of(env.items, c))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
match (self.slot(s).cloned(), primary) {
|
||||
(Some(other), true) => {
|
||||
// tryTransfer: what fits, taken by the slot's rules.
|
||||
let mut moved = 0;
|
||||
if can_be_in_bundle(&other) {
|
||||
let room = contents.room_for(env.items, &other);
|
||||
if let Some(mut taken) = self.safe_take(env, s, other.count, room) {
|
||||
moved = contents.try_insert(env.items, &mut taken);
|
||||
}
|
||||
}
|
||||
self.player_sounds
|
||||
.push(if moved > 0 { INSERT } else { INSERT_FAIL });
|
||||
}
|
||||
(None, false) => {
|
||||
if let Some(stack) = contents.remove_one(env.items) {
|
||||
let count = stack.count;
|
||||
let mut remainder = Some(stack);
|
||||
self.safe_insert(env, s, &mut remainder, count);
|
||||
match remainder {
|
||||
Some(mut rest) => {
|
||||
contents.try_insert(env.items, &mut rest);
|
||||
}
|
||||
None => self.player_sounds.push(REMOVE_ONE),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
if let Some(carried) = self.carried.as_mut() {
|
||||
contents.store(carried);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `BundleItem.overrideOtherStackedOnMe`: a click on a bundle in a slot.
|
||||
fn clicked_bundle_click(&mut self, env: &Env, s: usize, primary: bool) -> bool {
|
||||
let Some(mut bundle) = self.slot(s).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let Some(mut contents) = Contents::of(env.items, &bundle) else {
|
||||
return false;
|
||||
};
|
||||
let allowed = self.allow_modification(env, s);
|
||||
match (self.carried.take(), primary) {
|
||||
(Some(mut other), true) => {
|
||||
let moved = if allowed {
|
||||
contents.try_insert(env.items, &mut other)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.player_sounds
|
||||
.push(if moved > 0 { INSERT } else { INSERT_FAIL });
|
||||
self.carried = Some(other);
|
||||
tidy(&mut self.carried);
|
||||
}
|
||||
(None, false) => {
|
||||
if allowed && let Some(removed) = contents.remove_one(env.items) {
|
||||
self.player_sounds.push(REMOVE_ONE);
|
||||
self.carried = Some(removed);
|
||||
}
|
||||
}
|
||||
(carried, _) => {
|
||||
// toggleSelectedItem(-1): Horizon keeps no selected item.
|
||||
self.carried = carried;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// `self.set(BUNDLE_CONTENTS, ...)` changes the slot's stack in place.
|
||||
contents.store(&mut bundle);
|
||||
let slot = self.slots[s];
|
||||
self.containers[slot.container][slot.index] = Some(bundle);
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -987,6 +987,12 @@ pub enum MobEvent {
|
||||
pos: [i32; 3],
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
},
|
||||
/// `DoorBlock.setOpen` by a mob (`InteractWithDoor`): the door opens or
|
||||
/// closes, both halves, with its sound and game event.
|
||||
SetDoorOpen {
|
||||
pos: [i32; 3],
|
||||
open: bool,
|
||||
},
|
||||
/// Destroy a block, dropping nothing (sheep eating grass becomes dirt via SetBlock).
|
||||
BreakBlock {
|
||||
pos: [i32; 3],
|
||||
@@ -1602,6 +1608,8 @@ impl Mob {
|
||||
| Species::Fox
|
||||
| Species::Panda
|
||||
);
|
||||
// The PathNavigation constructor: the base follow range.
|
||||
mob.ai.nav.max_visited_nodes = (mob.kind.follow_range * 16.0).floor() as i32;
|
||||
ai::register(&mut mob);
|
||||
mob
|
||||
}
|
||||
@@ -2277,7 +2285,7 @@ mod sensor_scan_tests {
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::json;
|
||||
|
||||
fn kind() -> Arc<MobType> {
|
||||
pub(super) fn kind() -> Arc<MobType> {
|
||||
Arc::new(MobType {
|
||||
id: "minecraft:cow".into(),
|
||||
species: Species::Cow,
|
||||
@@ -2481,56 +2489,9 @@ mod sensor_scan_tests {
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod push_tests {
|
||||
use super::sensor_scan_tests::kind;
|
||||
use super::*;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::json;
|
||||
|
||||
fn kind() -> Arc<MobType> {
|
||||
Arc::new(MobType {
|
||||
id: "minecraft:cow".into(),
|
||||
species: Species::Cow,
|
||||
width: 0.9,
|
||||
height: 1.4,
|
||||
eye_height: 1.3,
|
||||
category: "creature".into(),
|
||||
max_health: 10.0,
|
||||
speed: 0.2,
|
||||
flying_speed: 0.4,
|
||||
attack_damage: 0.0,
|
||||
attack_knockback: 0.0,
|
||||
follow_range: 16.0,
|
||||
step_height: 0.6,
|
||||
armor: 0.0,
|
||||
armor_toughness: 0.0,
|
||||
knockback_resistance: 0.0,
|
||||
jump_strength: 0.42,
|
||||
attributes: Arc::default(),
|
||||
gravity: 0.08,
|
||||
safe_fall_distance: 3.0,
|
||||
fall_damage_multiplier: 1.0,
|
||||
water_efficiency: 0.0,
|
||||
water_slowdown: 0.8,
|
||||
air_speed: 0.02,
|
||||
baby_scale: 0.5,
|
||||
head_rot_speed: 10.0,
|
||||
max_head_x_rot: 40.0,
|
||||
max_head_y_rot: 75.0,
|
||||
ambient_interval: 120,
|
||||
flags: Flags {
|
||||
animal: true,
|
||||
..Default::default()
|
||||
},
|
||||
model: Arc::new(Part::parse("root", &json!({}))),
|
||||
layers: HashMap::new(),
|
||||
texture: String::new(),
|
||||
brain: Brain::Passive,
|
||||
anim: Anim::Rabbit,
|
||||
sounds: [""; 3],
|
||||
step_sound: None,
|
||||
food: Arc::default(),
|
||||
tempt: Arc::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The pending pushes as one list, scanned for each mob, as before.
|
||||
fn take_old(events: &mut Vec<MobEvent>, from: usize, pending: &mut Vec<(u32, Vec3)>) {
|
||||
|
||||
@@ -405,7 +405,7 @@ pub fn tick(
|
||||
}
|
||||
// Mob.tickHeadTurn -> BodyRotationControl.clientTick (a frozen
|
||||
// creaking's waits).
|
||||
if m.alive() && !(super::creaking::is_creaking(m) && !super::creaking::can_move(m)) {
|
||||
if m.alive() && (!super::creaking::is_creaking(m) || super::creaking::can_move(m)) {
|
||||
m.body_rotation_tick();
|
||||
}
|
||||
events.append(&mut m.outbox);
|
||||
@@ -752,8 +752,8 @@ impl Mob {
|
||||
pub fn pushable(&self) -> bool {
|
||||
self.alive()
|
||||
&& !matches!(self.species(), Species::Bat | Species::Shulker)
|
||||
&& !(self.kind.flags.climber && self.climbing)
|
||||
&& !(self.species() == Species::Creaking && !super::creaking::can_move(self))
|
||||
&& (!self.kind.flags.climber || !self.climbing)
|
||||
&& (self.species() != Species::Creaking || super::creaking::can_move(self))
|
||||
}
|
||||
}
|
||||
/// LivingEntity.onClimbable for the pusher (Spider.onClimbable: climbing a wall).
|
||||
@@ -780,7 +780,7 @@ fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
let self_pushable = m.alive()
|
||||
&& m.species() != Species::Shulker
|
||||
&& !on_climbable(m, w)
|
||||
&& !(m.species() == Species::Creaking && !super::creaking::can_move(m));
|
||||
&& (m.species() != Species::Creaking || super::creaking::can_move(m));
|
||||
let mut targets: Vec<(Who, Vec3)> = Vec::new();
|
||||
if let Some(p) = &w.player
|
||||
// Entity.push: passengers of the same vehicle never push each other.
|
||||
|
||||
@@ -26,7 +26,7 @@ pub fn register(m: &mut Mob) {
|
||||
};
|
||||
m.ai.nav.kind = NavKind::Flying;
|
||||
// FlyingPathNavigation.setRequiredPathLength(48).
|
||||
m.ai.nav.required_path_length = 48.0;
|
||||
m.set_required_path_length(48.0);
|
||||
let g = &mut m.ai.goals;
|
||||
g.add(0, Panic::new(2.5));
|
||||
g.add(1, Fetch);
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
//! own random drives positions, sounds and jumps. `RunOne` shuffles with a
|
||||
//! random of its own, seeded with the brain's `shuffle_seed` when it is
|
||||
//! built.
|
||||
use super::{Mob, MobEvent, MobWorld, Species, Who, path::Path, sniffer};
|
||||
use super::{
|
||||
Mob, MobEvent, MobWorld, Species, Who,
|
||||
brain::{Timed, living_near},
|
||||
path::Path,
|
||||
sniffer,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
@@ -603,22 +608,7 @@ fn tick_sensors(m: &mut Mob, w: &MobWorld) {
|
||||
/// `NearestLivingEntitySensor`: everything alive within the follow range,
|
||||
/// nearest first.
|
||||
fn sense_living(m: &mut Mob, w: &MobWorld) {
|
||||
let r = m.attrs.follow_range;
|
||||
let area = m.bounds().inflate(Vec3::new(r, r, r));
|
||||
let mut found: Vec<(Who, f64)> = Vec::new();
|
||||
for o in w.others_near(area) {
|
||||
if o.id != m.id && o.alive && o.bounds.intersects(area) {
|
||||
found.push((Who::Mob(o.id), m.distance_sqr(o.position)));
|
||||
}
|
||||
}
|
||||
if let Some(p) = &w.player
|
||||
&& p.alive
|
||||
&& p.bounds.intersects(area)
|
||||
{
|
||||
found.push((Who::Player, m.distance_sqr(p.position)));
|
||||
}
|
||||
found.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
let list: Vec<Who> = found.into_iter().map(|(w, _)| w).collect();
|
||||
let list = living_near(m, w);
|
||||
let mm = mem(m);
|
||||
mm.mobs.set_list(list.clone());
|
||||
mm.visible_mobs.set(Visible {
|
||||
@@ -794,37 +784,6 @@ fn followable_friendly(s: Species) -> bool {
|
||||
|
||||
// ---- Behaviours ------------------------------------------------------------
|
||||
|
||||
/// `Behavior`'s running state and end time.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Timed {
|
||||
running: bool,
|
||||
end: i64,
|
||||
min: i32,
|
||||
max: i32,
|
||||
}
|
||||
impl Timed {
|
||||
fn new(min: i32, max: i32) -> Self {
|
||||
Self {
|
||||
running: false,
|
||||
end: 0,
|
||||
min,
|
||||
max,
|
||||
}
|
||||
}
|
||||
/// `Behavior.tryStart`'s duration draw.
|
||||
fn begin(&mut self, cx: &mut Bx) {
|
||||
self.running = true;
|
||||
let bound = self.max.wrapping_add(1).wrapping_sub(self.min);
|
||||
let d = self
|
||||
.min
|
||||
.wrapping_add(cx.lr.next_int_bound(bound).unwrap_or(0));
|
||||
self.end = cx.now + i64::from(d);
|
||||
}
|
||||
fn timed_out(&self, now: i64) -> bool {
|
||||
now > self.end
|
||||
}
|
||||
}
|
||||
|
||||
/// `RunOne`: shuffles its children by weight and starts the first that can.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Gate {
|
||||
@@ -1076,7 +1035,7 @@ impl B {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
mem(m).walk_target.clear();
|
||||
mem(m).look_target.clear();
|
||||
m.add_effect("minecraft:regeneration", 200, 0);
|
||||
@@ -1086,7 +1045,7 @@ impl B {
|
||||
if !swim_condition(m) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::AnimalPanic { t, .. } => {
|
||||
@@ -1094,7 +1053,7 @@ impl B {
|
||||
if !(mm.hurt_by.get() == Some(&true) || mm.is_panicking.present()) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
if brain_mut(m).kind == Kind::Sniffer {
|
||||
reset_sniffing(m, cx);
|
||||
}
|
||||
@@ -1123,7 +1082,7 @@ impl B {
|
||||
let reached = reached(m, cx.w, &walk);
|
||||
if !reached && compute_path(m, cx, &walk, path, speed) {
|
||||
*last_target = walk.target.block(cx.w);
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
mem(m).path.set_opt(path.clone());
|
||||
m.nav_move_to_path(path.clone(), f64::from(*speed));
|
||||
return true;
|
||||
@@ -1139,21 +1098,21 @@ impl B {
|
||||
if !mem(m).temptation_cooldown_ticks.present() {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::CountDownGaze(t) => {
|
||||
if !mem(m).gaze_cooldown_ticks.present() {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::CountDownTransport(t) => {
|
||||
if !mem(m).transport_items_cooldown_ticks.present() {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::Transport(x) => x.try_start(m, cx),
|
||||
@@ -1171,7 +1130,7 @@ impl B {
|
||||
let Some(partner) = breed_partner(m, cx.w) else {
|
||||
return false;
|
||||
};
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
if brain_mut(m).kind == Kind::Sniffer {
|
||||
reset_sniffing(m, cx);
|
||||
}
|
||||
@@ -1200,7 +1159,7 @@ impl B {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
if brain_mut(m).kind == Kind::Sniffer {
|
||||
reset_sniffing(m, cx);
|
||||
}
|
||||
@@ -1210,14 +1169,14 @@ impl B {
|
||||
if !mem(m).look_target.present() {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::FeelingHappy(t) => {
|
||||
if !mem(m).sniffer_happy.present() {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
transition(m, cx, sniffer::FEELING_HAPPY);
|
||||
true
|
||||
}
|
||||
@@ -1233,7 +1192,7 @@ impl B {
|
||||
if !can_sniff(m) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
transition(m, cx, sniffer::DIGGING);
|
||||
true
|
||||
}
|
||||
@@ -1246,7 +1205,7 @@ impl B {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
transition(m, cx, sniffer::RISING);
|
||||
true
|
||||
}
|
||||
@@ -1261,7 +1220,7 @@ impl B {
|
||||
if !can_sniff(m) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
transition(m, cx, sniffer::SEARCHING);
|
||||
true
|
||||
}
|
||||
@@ -1278,7 +1237,7 @@ impl B {
|
||||
if is_tempted(m) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
transition(m, cx, sniffer::SCENTING);
|
||||
true
|
||||
}
|
||||
@@ -1293,12 +1252,12 @@ impl B {
|
||||
if m.is_baby() || !can_sniff(m) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
transition(m, cx, sniffer::SNIFFING);
|
||||
true
|
||||
}
|
||||
B::DoNothing(t) => {
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::AxolotlAction { .. }
|
||||
|
||||
@@ -138,7 +138,7 @@ pub fn register(m: &mut Mob) {
|
||||
};
|
||||
m.ai.nav.kind = NavKind::Flying;
|
||||
m.ai.nav.can_open_doors = false;
|
||||
m.ai.nav.required_path_length = 48.0;
|
||||
m.set_required_path_length(48.0);
|
||||
m.set_malus(PathType::Fire, -1.0);
|
||||
m.set_malus(PathType::Water, -1.0);
|
||||
m.set_malus(PathType::WaterBorder, 16.0);
|
||||
@@ -1046,7 +1046,7 @@ impl Goal for GrowCrop {
|
||||
}
|
||||
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
// The 0.3 roll comes only after the crop count: it draws randomness.
|
||||
let usable = !get(m).is_some_and(|b| b.crops_grown >= 10)
|
||||
let usable = get(m).is_none_or(|b| b.crops_grown < 10)
|
||||
&& m.random.next_float() >= 0.3
|
||||
&& has_nectar(m)
|
||||
&& hive_valid(m, cx.w);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Pieces of vanilla's `Brain` that species implemented as brains share:
|
||||
//! memories that expire, behaviour durations, walk targets and
|
||||
//! `MoveToTargetSink`.
|
||||
use super::{Mob, goals::Cx};
|
||||
use horizon_core::random::RandomSource;
|
||||
use super::{Mob, MobWorld, Who, goals::Cx};
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
|
||||
/// A memory with an expiry (`Brain.setMemoryWithExpiry`): present for the
|
||||
/// tick it was set and `ttl` more ticks. `Ttl::default()` is absent.
|
||||
@@ -25,6 +28,57 @@ impl Ttl {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Behavior`'s bookkeeping in the brains ported behaviour for behaviour
|
||||
/// (villager, animal, monster, warden): running, and until when.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Timed {
|
||||
pub running: bool,
|
||||
pub end: i64,
|
||||
pub min: i32,
|
||||
pub max: i32,
|
||||
}
|
||||
impl Timed {
|
||||
pub fn new(min: i32, max: i32) -> Self {
|
||||
Self {
|
||||
running: false,
|
||||
end: 0,
|
||||
min,
|
||||
max,
|
||||
}
|
||||
}
|
||||
/// `Behavior.tryStart`'s duration draw from the level random, in Java's
|
||||
/// int arithmetic (made even when it cannot vary).
|
||||
pub fn begin(&mut self, lr: &mut LegacyRandom, now: i64) {
|
||||
self.running = true;
|
||||
let bound = self.max.wrapping_add(1).wrapping_sub(self.min);
|
||||
let d = self.min.wrapping_add(lr.next_int_bound(bound).unwrap_or(0));
|
||||
self.end = now + i64::from(d);
|
||||
}
|
||||
/// `Behavior.timedOut`.
|
||||
pub fn timed_out(&self, now: i64) -> bool {
|
||||
now > self.end
|
||||
}
|
||||
}
|
||||
/// `NearestLivingEntitySensor`'s scan: the living entities (the player
|
||||
/// among them) whose boxes meet the follow range around `m`, nearest first.
|
||||
pub fn living_near(m: &Mob, w: &MobWorld) -> Vec<Who> {
|
||||
let r = m.attrs.follow_range;
|
||||
let area = m.bounds().inflate(Vec3::new(r, r, r));
|
||||
let mut found: Vec<(Who, f64)> = Vec::new();
|
||||
for o in w.others_near(area) {
|
||||
if o.id != m.id && o.alive && o.bounds.intersects(area) {
|
||||
found.push((Who::Mob(o.id), m.distance_sqr(o.position)));
|
||||
}
|
||||
}
|
||||
if let Some(p) = &w.player
|
||||
&& p.alive
|
||||
&& p.bounds.intersects(area)
|
||||
{
|
||||
found.push((Who::Player, m.distance_sqr(p.position)));
|
||||
}
|
||||
found.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
found.into_iter().map(|(w, _)| w).collect()
|
||||
}
|
||||
/// A random duration in `min..=max` (`Behavior`'s end time).
|
||||
pub fn duration(m: &mut Mob, min: i32, max: i32) -> i32 {
|
||||
if max <= min {
|
||||
|
||||
@@ -26,7 +26,7 @@ pub const DROPPING_NO_ITEM: u8 = 4;
|
||||
pub const WEATHER: [&str; 4] = ["unaffected", "exposed", "weathered", "oxidized"];
|
||||
|
||||
pub fn register(m: &mut Mob) {
|
||||
m.ai.nav.required_path_length = 48.0;
|
||||
m.set_required_path_length(48.0);
|
||||
m.ai.nav.can_open_doors = true;
|
||||
m.persistent = true;
|
||||
// nextWeatheringTick starts unset (-1); waxed is -2.
|
||||
|
||||
@@ -464,6 +464,7 @@ pub fn finalize_spawn(m: &mut Mob, lr: &mut LegacyRandom) {
|
||||
let bonus = lr.triangle(0.0, 0.114_850_000_000_000_01);
|
||||
// ADD_MULTIPLIED_BASE on the base value.
|
||||
m.attrs.follow_range += m.kind.follow_range * bonus;
|
||||
m.update_max_visited_nodes();
|
||||
let _left_handed = lr.next_float() < 0.05;
|
||||
}
|
||||
fn wrap_degrees(angle: f32) -> f32 {
|
||||
|
||||
@@ -559,7 +559,10 @@ fn refresh(m: &mut Mob, a: Attr) {
|
||||
match a {
|
||||
Attr::MaxHealth => m.attrs.max_health = value(m, a) as f32,
|
||||
Attr::MovementSpeed => m.attrs.speed = base_with_modifiers(m, a),
|
||||
Attr::FollowRange => m.attrs.follow_range = value(m, a),
|
||||
Attr::FollowRange => {
|
||||
m.attrs.follow_range = value(m, a);
|
||||
m.update_max_visited_nodes();
|
||||
}
|
||||
Attr::AttackDamage => m.attrs.attack_damage = value(m, a) as f32,
|
||||
Attr::KnockbackResistance => m.attrs.knockback_resistance = value(m, a),
|
||||
Attr::SpawnReinforcements => m.attrs.spawn_reinforcements = value(m, a),
|
||||
|
||||
@@ -39,6 +39,10 @@ fn setup(m: &mut Mob, baby: bool) {
|
||||
m.ai.look.wanted = [0.0; 3];
|
||||
m.ai.look.cooldown = 0;
|
||||
m.extra.counter2 = 0;
|
||||
// The new navigation's PathNavigation constructor.
|
||||
m.ai.nav.required_path_length = 16.0;
|
||||
m.ai.nav.max_visited_nodes =
|
||||
(super::finalize::base_value(m, super::finalize::Attr::FollowRange) * 16.0).floor() as i32;
|
||||
if baby {
|
||||
m.ai.look.kind = LookKind::Default;
|
||||
m.ai.mov.kind = MoveKind::Flying {
|
||||
@@ -48,7 +52,7 @@ fn setup(m: &mut Mob, baby: bool) {
|
||||
// BabyFlyingPathNavigation.
|
||||
m.ai.nav.kind = NavKind::Flying;
|
||||
m.ai.nav.can_float = true;
|
||||
m.ai.nav.required_path_length = 48.0;
|
||||
m.set_required_path_length(48.0);
|
||||
return;
|
||||
}
|
||||
if was == 1 {
|
||||
|
||||
@@ -167,9 +167,9 @@ impl MobWorld<'_> {
|
||||
let Ok(block) = self.collision.collision_block(state) else {
|
||||
continue;
|
||||
};
|
||||
if !block.large_collision_shape
|
||||
&& !block.moving_piston
|
||||
&& !(column && meets(Axis::Y, y))
|
||||
if !(block.large_collision_shape
|
||||
|| block.moving_piston
|
||||
|| (column && meets(Axis::Y, y)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn is_illager(s: &Seen) -> bool {
|
||||
)
|
||||
}
|
||||
pub fn register(m: &mut Mob) {
|
||||
m.ai.nav.required_path_length = 40.0;
|
||||
m.set_required_path_length(40.0);
|
||||
let tempt = m.kind.tempt.clone();
|
||||
let trader = is_trader(m);
|
||||
if trader {
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
//! seeded when the brain is built.
|
||||
use super::{
|
||||
Mob, MobEvent, MobWorld, Species, Who,
|
||||
brain::{Timed, living_near},
|
||||
goals::Cx,
|
||||
path::Path,
|
||||
villager_brain::{NEVER, Slot, Tracker, Visible, WalkTarget},
|
||||
villager_brain::{NEVER, Slot, Tracker, Visible, WalkTarget, compute_path, reached},
|
||||
};
|
||||
use crate::projectiles::{Projectile, ProjectileKind};
|
||||
use horizon_core::{
|
||||
@@ -129,6 +130,10 @@ fn brain_mut(m: &mut Mob) -> &mut Brain {
|
||||
fn mem(m: &mut Mob) -> &mut Memories {
|
||||
&mut brain_mut(m).mem
|
||||
}
|
||||
/// `CANT_REACH_WALK_TARGET_SINCE`, for `compute_path`.
|
||||
fn cant_reach(m: &mut Mob) -> &mut Slot<i64> {
|
||||
&mut mem(m).cant_reach_walk_target_since
|
||||
}
|
||||
fn mem_ref(m: &Mob) -> &Memories {
|
||||
&brain(m).expect("brain").mem
|
||||
}
|
||||
@@ -292,22 +297,7 @@ fn tick_sensors(m: &mut Mob, w: &MobWorld) {
|
||||
/// `NearestLivingEntitySensor`: everything alive within the follow range,
|
||||
/// nearest first.
|
||||
fn sense_living(m: &mut Mob, w: &MobWorld) {
|
||||
let r = m.attrs.follow_range;
|
||||
let area = m.bounds().inflate(Vec3::new(r, r, r));
|
||||
let mut found: Vec<(Who, f64)> = Vec::new();
|
||||
for o in w.others_near(area) {
|
||||
if o.id != m.id && o.alive && o.bounds.intersects(area) {
|
||||
found.push((Who::Mob(o.id), m.distance_sqr(o.position)));
|
||||
}
|
||||
}
|
||||
if let Some(p) = &w.player
|
||||
&& p.alive
|
||||
&& p.bounds.intersects(area)
|
||||
{
|
||||
found.push((Who::Player, m.distance_sqr(p.position)));
|
||||
}
|
||||
found.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
let list: Vec<Who> = found.into_iter().map(|(w, _)| w).collect();
|
||||
let list = living_near(m, w);
|
||||
let mm = mem(m);
|
||||
mm.mobs.set_list(list.clone());
|
||||
mm.visible_mobs.set(Visible {
|
||||
@@ -456,31 +446,6 @@ fn sense_breeze_attack(m: &mut Mob, w: &MobWorld) {
|
||||
|
||||
// ---- Behaviours ---------------------------------------------------------
|
||||
|
||||
/// `Behavior`'s bookkeeping: running, and until when.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Timed {
|
||||
running: bool,
|
||||
end: i64,
|
||||
min: i32,
|
||||
max: i32,
|
||||
}
|
||||
impl Timed {
|
||||
fn new(min: i32, max: i32) -> Self {
|
||||
Self {
|
||||
running: false,
|
||||
end: 0,
|
||||
min,
|
||||
max,
|
||||
}
|
||||
}
|
||||
/// `Behavior.tryStart`'s duration draw from the level random.
|
||||
fn begin(&mut self, cx: &mut Bx) {
|
||||
self.running = true;
|
||||
let d = self.min + cx.lr.next_int_bound(self.max + 1 - self.min).unwrap_or(0);
|
||||
self.end = cx.now + i64::from(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// `StartAttacking`'s target finders.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Finder {
|
||||
@@ -607,14 +572,14 @@ impl B {
|
||||
if *creaking && !super::creaking::can_move(m) || !swim_condition(m) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::LookAtTargetSink(t) => {
|
||||
if !mem(m).look_target.present() {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::MoveToTargetSink {
|
||||
@@ -636,9 +601,9 @@ impl B {
|
||||
}
|
||||
let walk = mm.walk_target.value().unwrap();
|
||||
let reached = reached(m, cx.w, &walk);
|
||||
if !reached && compute_path(m, cx, &walk, path, speed) {
|
||||
if !reached && compute_path(m, cx.w, cx.now, &walk, path, speed, cant_reach) {
|
||||
*last_target = walk.target.block(cx.w);
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
// start
|
||||
mem(m).path.set_opt(path.clone());
|
||||
m.nav_move_to_path(path.clone(), f64::from(*speed));
|
||||
@@ -712,14 +677,14 @@ impl B {
|
||||
.is_some_and(|l| l.contains(&Who::Player));
|
||||
!reachable
|
||||
};
|
||||
if !(can_attack(m, cx.w, target) && !tired && alive && !stop_when) {
|
||||
if !can_attack(m, cx.w, target) || tired || !alive || stop_when {
|
||||
set_attack_target(m, None);
|
||||
}
|
||||
*running = true;
|
||||
true
|
||||
}
|
||||
B::DoNothing(t) => {
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
true
|
||||
}
|
||||
B::RandomStroll(running, speed) => {
|
||||
@@ -848,7 +813,7 @@ impl B {
|
||||
mem(m).breeze_shoot.clear();
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
// start
|
||||
super::breeze::set_pose(m, super::breeze::SHOOTING);
|
||||
mem(m).breeze_shoot_charging.set_ttl((), 15);
|
||||
@@ -867,7 +832,7 @@ impl B {
|
||||
if !long_jump_can_run(m, cx) {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
// start
|
||||
if !mem(m).breeze_jump_inhaling.present() {
|
||||
mem(m).breeze_jump_inhaling.set_ttl((), 10);
|
||||
@@ -905,7 +870,7 @@ impl B {
|
||||
if !stuck {
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
mem(m).breeze_shoot.set_ttl((), 60);
|
||||
true
|
||||
}
|
||||
@@ -924,7 +889,7 @@ impl B {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
t.begin(cx);
|
||||
t.begin(cx.lr, cx.now);
|
||||
slide_start(m, cx);
|
||||
true
|
||||
}
|
||||
@@ -989,7 +954,7 @@ impl B {
|
||||
&& let Some(now_target) = walk.target.block(cx.w)
|
||||
{
|
||||
let d = manhattan_sq(now_target, last);
|
||||
if d > 4 && compute_path(m, cx, &walk, path, speed) {
|
||||
if d > 4 && compute_path(m, cx.w, cx.now, &walk, path, speed, cant_reach) {
|
||||
*last_target = Some(now_target);
|
||||
mem(m).path.set_opt(path.clone());
|
||||
m.nav_move_to_path(path.clone(), f64::from(*speed));
|
||||
@@ -1122,53 +1087,6 @@ fn tracker_visible(m: &mut Mob, w: &MobWorld, t: Tracker) -> bool {
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
/// `MoveToTargetSink.reachedTarget`.
|
||||
fn reached(m: &Mob, w: &MobWorld, walk: &WalkTarget) -> bool {
|
||||
walk.target.block(w).is_some_and(|p| {
|
||||
let h = block_of(m.position);
|
||||
(p[0] - h[0]).abs() + (p[1] - h[1]).abs() + (p[2] - h[2]).abs() <= walk.close
|
||||
})
|
||||
}
|
||||
/// `MoveToTargetSink.tryComputePath`.
|
||||
fn compute_path(
|
||||
m: &mut Mob,
|
||||
cx: &mut Bx,
|
||||
walk: &WalkTarget,
|
||||
path: &mut Option<Path>,
|
||||
speed: &mut f32,
|
||||
) -> bool {
|
||||
let Some(target) = walk.target.block(cx.w) else {
|
||||
return false;
|
||||
};
|
||||
*path = m.create_path_to(cx.w, target, 0);
|
||||
*speed = walk.speed;
|
||||
if reached(m, cx.w, walk) {
|
||||
mem(m).cant_reach_walk_target_since.clear();
|
||||
} else {
|
||||
let can_reach = path.as_ref().is_some_and(|p| p.reached);
|
||||
let mm = mem(m);
|
||||
if can_reach {
|
||||
mm.cant_reach_walk_target_since.clear();
|
||||
} else if !mm.cant_reach_walk_target_since.present() {
|
||||
mm.cant_reach_walk_target_since.set(cx.now);
|
||||
}
|
||||
if path.is_some() {
|
||||
return true;
|
||||
}
|
||||
let to = [
|
||||
f64::from(target[0]) + 0.5,
|
||||
f64::from(target[1]),
|
||||
f64::from(target[2]) + 0.5,
|
||||
];
|
||||
if let Some(step) =
|
||||
m.default_random_pos_towards(cx.w, 10, 7, to, f64::from(std::f32::consts::FRAC_PI_2))
|
||||
{
|
||||
*path = m.create_path_to(cx.w, block_of(Vec3::new(step[0], step[1], step[2])), 0);
|
||||
return path.is_some();
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `BehaviorUtils.isWithinAttackRange(body, target, 1)` for a mob without a
|
||||
/// ranged weapon: `Mob.isWithinMeleeAttackRange`.
|
||||
fn within_attack_range(m: &Mob, w: &MobWorld, target: Who) -> bool {
|
||||
|
||||
Loaded 100 of 468 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user